feat(tui): infer workspace agent when launching TUI (#39591)

Merged via squash.

Prepared head SHA: 23533e24c4
Co-authored-by: arceus77-7 <261276524+arceus77-7@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
arceus77-7
2026-03-08 06:31:11 -04:00
committed by GitHub
parent f4c4856254
commit 492fe679a7
6 changed files with 231 additions and 3 deletions

View File

@@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai
### Changes
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
### Fixes
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.

View File

@@ -17,6 +17,7 @@ Related:
Notes:
- `tui` resolves configured gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` providers).
- When launched from inside a configured agent workspace directory, TUI auto-selects that agent for the session key default (unless `--session` is explicitly `agent:<id>:...`).
## Examples
@@ -24,4 +25,6 @@ Notes:
openclaw tui
openclaw tui --url ws://127.0.0.1:18789 --token <token>
openclaw tui --session main --deliver
# when run inside an agent workspace, infers that agent automatically
openclaw tui --session bugfix
```

View File

@@ -1,3 +1,5 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
@@ -13,6 +15,8 @@ import {
resolveAgentModelPrimary,
resolveRunModelFallbacksOverride,
resolveAgentWorkspaceDir,
resolveAgentIdByWorkspacePath,
resolveAgentIdsByWorkspacePath,
} from "./agent-scope.js";
afterEach(() => {
@@ -428,3 +432,92 @@ describe("resolveAgentConfig", () => {
expect(agentDir).toBe(path.join(path.resolve(home), ".openclaw", "agents", "main", "agent"));
});
});
describe("resolveAgentIdByWorkspacePath", () => {
it("returns the most specific workspace match for a directory", () => {
const workspaceRoot = `/tmp/openclaw-agent-scope-${Date.now()}-root`;
const opsWorkspace = `${workspaceRoot}/projects/ops`;
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: workspaceRoot },
{ id: "ops", workspace: opsWorkspace },
],
},
};
expect(resolveAgentIdByWorkspacePath(cfg, `${opsWorkspace}/src`)).toBe("ops");
});
it("returns undefined when directory has no matching workspace", () => {
const workspaceRoot = `/tmp/openclaw-agent-scope-${Date.now()}-root`;
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: workspaceRoot },
{ id: "ops", workspace: `${workspaceRoot}-ops` },
],
},
};
expect(
resolveAgentIdByWorkspacePath(cfg, `/tmp/openclaw-agent-scope-${Date.now()}-unrelated`),
).toBeUndefined();
});
it("matches workspace paths through symlink aliases", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-scope-"));
const realWorkspaceRoot = path.join(tempRoot, "real-root");
const realOpsWorkspace = path.join(realWorkspaceRoot, "projects", "ops");
const aliasWorkspaceRoot = path.join(tempRoot, "alias-root");
try {
fs.mkdirSync(path.join(realOpsWorkspace, "src"), { recursive: true });
fs.symlinkSync(
realWorkspaceRoot,
aliasWorkspaceRoot,
process.platform === "win32" ? "junction" : "dir",
);
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: realWorkspaceRoot },
{ id: "ops", workspace: realOpsWorkspace },
],
},
};
expect(
resolveAgentIdByWorkspacePath(cfg, path.join(aliasWorkspaceRoot, "projects", "ops")),
).toBe("ops");
expect(
resolveAgentIdByWorkspacePath(cfg, path.join(aliasWorkspaceRoot, "projects", "ops", "src")),
).toBe("ops");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
});
describe("resolveAgentIdsByWorkspacePath", () => {
it("returns matching workspaces ordered by specificity", () => {
const workspaceRoot = `/tmp/openclaw-agent-scope-${Date.now()}-root`;
const opsWorkspace = `${workspaceRoot}/projects/ops`;
const opsDevWorkspace = `${opsWorkspace}/dev`;
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: workspaceRoot },
{ id: "ops", workspace: opsWorkspace },
{ id: "ops-dev", workspace: opsDevWorkspace },
],
},
};
expect(resolveAgentIdsByWorkspacePath(cfg, `${opsDevWorkspace}/pkg`)).toEqual([
"ops-dev",
"ops",
"main",
]);
});
});

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
@@ -270,6 +271,62 @@ export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) {
return stripNullBytes(path.join(stateDir, `workspace-${id}`));
}
function normalizePathForComparison(input: string): string {
const resolved = path.resolve(stripNullBytes(resolveUserPath(input)));
let normalized = resolved;
// Prefer realpath when available to normalize aliases/symlinks (for example /tmp -> /private/tmp)
// and canonical path case without forcing case-folding on case-sensitive macOS volumes.
try {
normalized = fs.realpathSync.native(resolved);
} catch {
// Keep lexical path for non-existent directories.
}
if (process.platform === "win32") {
return normalized.toLowerCase();
}
return normalized;
}
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
const relative = path.relative(rootPath, candidatePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
export function resolveAgentIdsByWorkspacePath(
cfg: OpenClawConfig,
workspacePath: string,
): string[] {
const normalizedWorkspacePath = normalizePathForComparison(workspacePath);
const ids = listAgentIds(cfg);
const matches: Array<{ id: string; workspaceDir: string; order: number }> = [];
for (let index = 0; index < ids.length; index += 1) {
const id = ids[index];
const workspaceDir = normalizePathForComparison(resolveAgentWorkspaceDir(cfg, id));
if (!isPathWithinRoot(normalizedWorkspacePath, workspaceDir)) {
continue;
}
matches.push({ id, workspaceDir, order: index });
}
matches.sort((left, right) => {
const workspaceLengthDelta = right.workspaceDir.length - left.workspaceDir.length;
if (workspaceLengthDelta !== 0) {
return workspaceLengthDelta;
}
return left.order - right.order;
});
return matches.map((entry) => entry.id);
}
export function resolveAgentIdByWorkspacePath(
cfg: OpenClawConfig,
workspacePath: string,
): string | undefined {
return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0];
}
export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { getSlashCommands, parseCommand } from "./commands.js";
import {
createBackspaceDeduper,
@@ -6,6 +7,7 @@ import {
resolveCtrlCAction,
resolveFinalAssistantText,
resolveGatewayDisconnectState,
resolveInitialTuiAgentId,
resolveTuiSessionKey,
stopTuiSafely,
} from "./tui.js";
@@ -107,6 +109,50 @@ describe("resolveTuiSessionKey", () => {
});
});
describe("resolveInitialTuiAgentId", () => {
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "main", workspace: "/tmp/openclaw" },
{ id: "ops", workspace: "/tmp/openclaw/projects/ops" },
],
},
};
it("infers agent from cwd when session is not agent-prefixed", () => {
expect(
resolveInitialTuiAgentId({
cfg,
fallbackAgentId: "main",
initialSessionInput: "",
cwd: "/tmp/openclaw/projects/ops/src",
}),
).toBe("ops");
});
it("keeps explicit agent prefix from --session", () => {
expect(
resolveInitialTuiAgentId({
cfg,
fallbackAgentId: "main",
initialSessionInput: "agent:main:incident",
cwd: "/tmp/openclaw/projects/ops/src",
}),
).toBe("main");
});
it("falls back when cwd has no matching workspace", () => {
expect(
resolveInitialTuiAgentId({
cfg,
fallbackAgentId: "main",
initialSessionInput: "",
cwd: "/var/tmp/unrelated",
}),
).toBe("main");
});
});
describe("resolveGatewayDisconnectState", () => {
it("returns pairing recovery guidance when disconnect reason requires pairing", () => {
const state = resolveGatewayDisconnectState("gateway closed (1008): pairing required");

View File

@@ -8,8 +8,8 @@ import {
Text,
TUI,
} from "@mariozechner/pi-tui";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
@@ -208,6 +208,28 @@ export function resolveTuiSessionKey(params: {
return `agent:${params.currentAgentId}:${trimmed.toLowerCase()}`;
}
export function resolveInitialTuiAgentId(params: {
cfg: OpenClawConfig;
fallbackAgentId: string;
initialSessionInput?: string;
cwd?: string;
}) {
const parsed = parseAgentSessionKey((params.initialSessionInput ?? "").trim());
if (parsed?.agentId) {
return normalizeAgentId(parsed.agentId);
}
const inferredFromWorkspace = resolveAgentIdByWorkspacePath(
params.cfg,
params.cwd ?? process.cwd(),
);
if (inferredFromWorkspace) {
return inferredFromWorkspace;
}
return normalizeAgentId(params.fallbackAgentId);
}
export function resolveGatewayDisconnectState(reason?: string): {
connectionStatus: string;
activityStatus: string;
@@ -303,7 +325,12 @@ export async function runTui(opts: TuiOptions) {
let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope;
let sessionMainKey = normalizeMainKey(config.session?.mainKey);
let agentDefaultId = resolveDefaultAgentId(config);
let currentAgentId = agentDefaultId;
let currentAgentId = resolveInitialTuiAgentId({
cfg: config,
fallbackAgentId: agentDefaultId,
initialSessionInput,
cwd: process.cwd(),
});
let agents: AgentSummary[] = [];
const agentNames = new Map<string, string>();
let currentSessionKey = "";