mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user