diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 7fcc8a20b..9a87e302c 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1500,6 +1500,83 @@ describe("ADE CLI", () => { }); }); + it("attaches shell starts to the active ADE chat session from the environment", () => { + const previous = process.env.ADE_CHAT_SESSION_ID; + try { + process.env.ADE_CHAT_SESSION_ID = "chat-env-1"; + const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "pty", + action: "create", + args: { + laneId: "lane-1", + chatSessionId: "chat-env-1", + startupCommand: "npm test", + }, + }, + }); + } finally { + if (previous === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previous; + } + }); + + it("lets an explicit shell chat session override the environment", () => { + const previous = process.env.ADE_CHAT_SESSION_ID; + try { + process.env.ADE_CHAT_SESSION_ID = "chat-env-1"; + const plan = buildCliPlan([ + "shell", + "start", + "--lane", + "lane-1", + "--chat-session", + "chat-explicit-1", + "--command", + "npm test", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "pty", + action: "create", + args: { + chatSessionId: "chat-explicit-1", + }, + }, + }); + } finally { + if (previous === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previous; + } + }); + + it("ignores a blank ADE chat session environment value for shell starts", () => { + const previous = process.env.ADE_CHAT_SESSION_ID; + try { + process.env.ADE_CHAT_SESSION_ID = " "; + const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "pty", + action: "create", + args: expect.not.objectContaining({ + chatSessionId: expect.anything(), + }), + }, + }); + } finally { + if (previous === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previous; + } + }); + it("`ios` and `simulator` are accepted as aliases for `ios-sim`", () => { for (const alias of ["ios", "simulator"]) { const plan = buildCliPlan([alias, "devices"]); @@ -1660,10 +1737,24 @@ describe("ADE CLI", () => { arguments: { domain: "built_in_browser", action: "navigate", - args: { url: "localhost:5173", newTab: true }, + args: { url: "localhost:5173", newTab: true, openPanel: true }, }, }); + const panel = buildCliPlan(["browser", "panel"]); + expect(panel.kind).toBe("execute"); + if (panel.kind !== "execute") return; + expect(panel.steps[0]?.params).toMatchObject({ + arguments: { domain: "built_in_browser", action: "showPanel", args: {} }, + }); + + const panelWithUrl = buildCliPlan(["browser", "panel", "--url", "localhost:5173"]); + expect(panelWithUrl.kind).toBe("execute"); + if (panelWithUrl.kind !== "execute") return; + expect(panelWithUrl.steps[0]?.params).toMatchObject({ + arguments: { domain: "built_in_browser", action: "showPanel", args: { url: "localhost:5173" } }, + }); + const targetedOpen = buildCliPlan(["browser", "open", "https://example.com", "--tab", "tab-1"]); expect(targetedOpen.kind).toBe("execute"); if (targetedOpen.kind !== "execute") return; @@ -1671,7 +1762,40 @@ describe("ADE CLI", () => { arguments: { domain: "built_in_browser", action: "navigate", - args: { url: "https://example.com", tabId: "tab-1" }, + args: { url: "https://example.com", tabId: "tab-1", openPanel: true }, + }, + }); + + const hiddenOpen = buildCliPlan(["browser", "open", "https://example.com", "--no-panel"]); + expect(hiddenOpen.kind).toBe("execute"); + if (hiddenOpen.kind !== "execute") return; + expect(hiddenOpen.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "navigate", + args: { url: "https://example.com", openPanel: false }, + }, + }); + + const openWithGenericArg = buildCliPlan(["browser", "open", "https://example.com", "--arg", "openPanel=false"]); + expect(openWithGenericArg.kind).toBe("execute"); + if (openWithGenericArg.kind !== "execute") return; + expect(openWithGenericArg.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "navigate", + args: { url: "https://example.com", openPanel: false }, + }, + }); + + const openFromGenericUrl = buildCliPlan(["browser", "open", "--arg", "url=https://example.com"]); + expect(openFromGenericUrl.kind).toBe("execute"); + if (openFromGenericUrl.kind !== "execute") return; + expect(openFromGenericUrl.steps[0]?.params).toMatchObject({ + arguments: { + domain: "built_in_browser", + action: "navigate", + args: { url: "https://example.com", openPanel: true }, }, }); @@ -1682,7 +1806,7 @@ describe("ADE CLI", () => { arguments: { domain: "built_in_browser", action: "createTab", - args: { url: "https://example.com", activate: false }, + args: { url: "https://example.com", activate: false, openPanel: true }, }, }); @@ -1690,7 +1814,7 @@ describe("ADE CLI", () => { expect(switchTab.kind).toBe("execute"); if (switchTab.kind !== "execute") return; expect(switchTab.steps[0]?.params).toMatchObject({ - arguments: { domain: "built_in_browser", action: "switchTab", args: { tabId: "tab-1" } }, + arguments: { domain: "built_in_browser", action: "switchTab", args: { tabId: "tab-1", openPanel: true } }, }); const selectPoint = buildCliPlan(["browser", "select", "--x", "120", "--y", "420", "--no-screenshot"]); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index db4da1c01..fb45c6fcf 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -750,9 +750,11 @@ const HELP_BY_COMMAND: Record = { Shell sessions Shell commands create tracked PTY sessions that ADE can display and audit. + Inside an ADE chat, shell starts attach to the active chat automatically. $ ade shell start --lane -- npm test Start a tracked shell session $ ade shell start --lane -c "npm test" Start with a command string + $ ade shell start --lane --chat-session -c "npm test" $ ade shell write --data "q" Write data to a PTY $ ade shell resize --cols 120 --rows 36 $ ade shell close Dispose a PTY @@ -792,7 +794,7 @@ const HELP_BY_COMMAND: Record = { requires the desktop socket because the app owns provider/session state. $ ade chat list --text List chat sessions - $ ade chat create --lane --provider codex --model + $ ade chat create --lane --provider codex --model [--fast] $ ade chat send --text "next step" Send a message $ ade chat interrupt Stop an active turn $ ade chat resume Resume a session @@ -933,8 +935,10 @@ const HELP_BY_COMMAND: Record = { Tabs and navigation: $ ade --socket browser status --text Show active tab and tab list + $ ade --socket browser panel --text Open the Work sidebar Browser panel $ ade --socket browser open https://example.com --text $ ade --socket browser open localhost:5173 --new-tab --text + $ ade --socket browser open https://example.com --no-panel $ ade --socket browser new-tab --url https://example.com $ ade --socket browser switch --tab $ ade --socket browser close --tab @@ -955,8 +959,11 @@ const HELP_BY_COMMAND: Record = { $ ade --socket browser clear-selection Flags: - --url URL for open/new-tab. Bare localhost gets http://. + --url URL for panel/open/new-tab. Bare localhost gets http://. --new-tab Open navigation in a new tab instead of active tab. + --active-tab Navigate the active tab; aliases: --current-tab, --same-tab. + --background Create a new tab without activating it. + --no-panel Keep the Work sidebar panel hidden; alias: --hidden. --tab, --tab-id Target tab for switch/close/open. `, tests: `${ADE_BANNER} @@ -2187,6 +2194,10 @@ function buildShellPlan(args: string[]): CliPlan { if (sub === "actions") return { kind: "execute", label: "shell actions", steps: [listActionsStep("actions", "pty")] }; if (sub === "start" || sub === "create") { const laneId = readLaneId(args); + const chatSessionId = asString( + readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) + ?? process.env.ADE_CHAT_SESSION_ID, + ); const startupCommandIndex = args.indexOf("--"); const startupCommand = startupCommandIndex >= 0 ? args.splice(startupCommandIndex + 1).map(shellEscapeToken).join(" ") @@ -2194,6 +2205,7 @@ function buildShellPlan(args: string[]): CliPlan { if (startupCommandIndex >= 0) args.splice(startupCommandIndex, 1); const input = collectGenericObjectArgs(args, { ...(laneId ? { laneId } : {}), + ...(chatSessionId ? { chatSessionId } : {}), cwd: readValue(args, ["--cwd"]), title: readValue(args, ["--title"]), startupCommand, @@ -2275,7 +2287,15 @@ function buildChatPlan(args: string[]): CliPlan { if (sub === "show" || sub === "status") return { kind: "execute", label: "chat status", steps: [actionArgsListStep("result", "chat", "getSessionSummary", [requireValue(sessionId, "sessionId")])] }; if (sub === "create" || sub === "spawn") { const modelArg = readValue(args, ["--model", "--model-id"]); - return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), model: modelArg, modelId: modelArg, permissionMode: readValue(args, ["--permission-mode", "--permissions"]), droidPermissionMode: readValue(args, ["--droid-permission-mode", "--droid-autonomy", "--autonomy"]), title: readValue(args, ["--title"]), surface: readValue(args, ["--surface"]) ?? "work" }))] }; + const fastRequested = readFlag(args, ["--fast", "--codex-fast"]); + const standardRequested = readFlag(args, ["--standard", "--no-fast", "--no-codex-fast"]); + if (fastRequested && standardRequested) { + throw new CliUsageError( + "Use either --fast/--codex-fast or --standard/--no-fast/--no-codex-fast, not both.", + ); + } + const codexFastMode: boolean | undefined = fastRequested ? true : standardRequested ? false : undefined; + return { kind: "execute", label: "chat create", steps: [actionStep("result", "chat", "createSession", collectGenericObjectArgs(args, { laneId: readLaneId(args), provider: readValue(args, ["--provider"]), model: modelArg, modelId: modelArg, permissionMode: readValue(args, ["--permission-mode", "--permissions"]), droidPermissionMode: readValue(args, ["--droid-permission-mode", "--droid-autonomy", "--autonomy"]), title: readValue(args, ["--title"]), surface: readValue(args, ["--surface"]) ?? "work", ...(codexFastMode !== undefined ? { codexFastMode } : {}) }))] }; } if (sub === "send") return { kind: "execute", label: "chat send", steps: [actionStep("result", "chat", "sendMessage", withSession({ sessionId: requireValue(sessionId, "sessionId"), text: requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text") }))] }; if (sub === "interrupt") return { kind: "execute", label: "chat interrupt", steps: [actionStep("result", "chat", "interrupt", withSession({ sessionId: requireValue(sessionId, "sessionId") }))] }; @@ -2732,35 +2752,63 @@ function buildBrowserPlan(args: string[]): CliPlan { if (sub === "status" || sub === "tabs" || sub === "list") { return { kind: "execute", label: "browser status", steps: [actionStep("result", "built_in_browser", "getStatus", collectGenericObjectArgs(args))] }; } + if (sub === "panel" || sub === "show" || sub === "open-panel" || sub === "reveal") { + const panelArgs: JsonObject = {}; + maybePut(panelArgs, "url", readValue(args, ["--url"])); + maybePut(panelArgs, "tabId", readValue(args, ["--tab", "--tab-id"])); + return { kind: "execute", label: "browser panel", steps: [actionStep("result", "built_in_browser", "showPanel", collectGenericObjectArgs(args, panelArgs))] }; + } if (sub === "open" || sub === "navigate" || sub === "go") { const explicitUrl = readValue(args, ["--url"]); const tabId = readValue(args, ["--tab", "--tab-id"]); + const activeTab = readFlag(args, ["--active-tab", "--current-tab", "--same-tab"]); const newTab = readFlag(args, ["--new-tab"]); - const url = explicitUrl ?? args.filter((arg) => arg !== "--active-tab").join(" "); + const noPanel = readFlag(args, ["--no-panel", "--hidden"]); + const genericArgs = collectGenericObjectArgs(args); + const genericUrl = typeof genericArgs.url === "string" ? genericArgs.url : null; + const url = explicitUrl ?? genericUrl ?? args.join(" "); if (!url.trim()) throw new CliUsageError("browser open requires a URL."); - return { kind: "execute", label: "browser open", steps: [actionStep("result", "built_in_browser", "navigate", collectGenericObjectArgs(args, { + return { kind: "execute", label: "browser open", steps: [actionStep("result", "built_in_browser", "navigate", { url, tabId, - newTab: newTab ? true : undefined, - }))] }; + newTab: newTab && !activeTab ? true : undefined, + openPanel: !noPanel, + ...genericArgs, + })] }; } if (sub === "new-tab" || sub === "tab" || sub === "new") { const background = readFlag(args, ["--background"]); - const url = readValue(args, ["--url"]) ?? (args.length ? args.join(" ") : undefined); - return { kind: "execute", label: "browser new tab", steps: [actionStep("result", "built_in_browser", "createTab", collectGenericObjectArgs(args, { + const noPanel = readFlag(args, ["--no-panel", "--hidden"]); + const explicitUrl = readValue(args, ["--url"]); + const genericArgs = collectGenericObjectArgs(args); + const genericUrl = typeof genericArgs.url === "string" ? genericArgs.url : null; + const url = explicitUrl ?? genericUrl ?? (args.length ? args.join(" ") : undefined); + return { kind: "execute", label: "browser new tab", steps: [actionStep("result", "built_in_browser", "createTab", { url, activate: background ? false : undefined, - }))] }; + openPanel: !noPanel, + ...genericArgs, + })] }; } if (sub === "switch" || sub === "activate") { - return { kind: "execute", label: "browser switch", steps: [actionStep("result", "built_in_browser", "switchTab", collectGenericObjectArgs(args, { - tabId: requireValue(readValue(args, ["--tab", "--tab-id"]) ?? firstPositional(args), "tabId"), - }))] }; + const noPanel = readFlag(args, ["--no-panel", "--hidden"]); + const explicitTabId = readValue(args, ["--tab", "--tab-id"]); + const genericArgs = collectGenericObjectArgs(args); + const genericTabId = typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; + return { kind: "execute", label: "browser switch", steps: [actionStep("result", "built_in_browser", "switchTab", { + tabId: requireValue(explicitTabId ?? genericTabId ?? firstPositional(args), "tabId"), + openPanel: !noPanel, + ...genericArgs, + })] }; } if (sub === "close" || sub === "close-tab") { - return { kind: "execute", label: "browser close", steps: [actionStep("result", "built_in_browser", "closeTab", collectGenericObjectArgs(args, { - tabId: requireValue(readValue(args, ["--tab", "--tab-id"]) ?? firstPositional(args), "tabId"), - }))] }; + const explicitTabId = readValue(args, ["--tab", "--tab-id"]); + const genericArgs = collectGenericObjectArgs(args); + const genericTabId = typeof genericArgs.tabId === "string" ? genericArgs.tabId : null; + return { kind: "execute", label: "browser close", steps: [actionStep("result", "built_in_browser", "closeTab", { + tabId: requireValue(explicitTabId ?? genericTabId ?? firstPositional(args), "tabId"), + ...genericArgs, + })] }; } if (sub === "reload" || sub === "refresh") return { kind: "execute", label: "browser reload", steps: [actionStep("result", "built_in_browser", "reload", collectGenericObjectArgs(args))] }; if (sub === "back") return { kind: "execute", label: "browser back", steps: [actionStep("result", "built_in_browser", "goBack", collectGenericObjectArgs(args))] }; @@ -3568,6 +3616,7 @@ function checkProviderReadiness(value: unknown): ReadinessCheck { codex: commandExists("codex"), opencode: commandExists("opencode"), cursor: commandExists("agent") || commandExists("cursor-agent"), + droid: commandExists("droid"), }; const apiKeyProviders = Object.keys(apiKeys).filter((key) => Boolean(asString(apiKeys[key]))); const ready = Boolean(defaultProvider || defaultModel || apiKeyProviders.length || Object.values(cliProviders).some(Boolean)); @@ -4773,7 +4822,7 @@ function inferFormatter(plan: CliPlan & { kind: "execute" }): FormatterId | unde if (label === "app control status" || label === "app control launch" || label === "app control connect" || label === "app control stop") return "app-control-status"; if (label === "app control snapshot" || label === "app control screenshot") return "app-control-snapshot"; if (label === "app control select" || label === "app control inspect point") return "app-control-selection"; - if (label === "browser status" || label === "browser open" || label === "browser new tab" || label === "browser switch" || label === "browser close") return "browser-status"; + if (label === "browser status" || label === "browser panel" || label === "browser open" || label === "browser new tab" || label === "browser switch" || label === "browser close") return "browser-status"; if (label === "terminal list" || label === "terminal active") return "terminal-list"; if (label === "terminal read") return "terminal-read"; if (label === "actions list") return "actions-list"; diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index d96a7ac36..da362b430 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -24,6 +24,7 @@ _ ____ _____ $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions + $ ade terminal list | read | write | signal Control the active in-chat terminal $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats @@ -33,6 +34,8 @@ _ ____ _____ $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory $ ade settings action Call project config actions $ ade actions list | run | status Escape hatch for every ADE service action @@ -60,6 +63,9 @@ _ ____ _____ $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text + $ ade app-control launch --command "pnpm dev" --text + $ ade --socket browser open http://localhost:5173 --new-tab --text + $ ade terminal read --chat-session --text Generic ADE action JSON contract: Object-shaped call: @@ -101,6 +107,7 @@ _ ____ _____ $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions + $ ade terminal list | read | write | signal Control the active in-chat terminal $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats @@ -110,6 +117,8 @@ _ ____ _____ $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory $ ade settings action Call project config actions $ ade actions list | run | status Escape hatch for every ADE service action @@ -137,6 +146,9 @@ _ ____ _____ $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text + $ ade app-control launch --command "pnpm dev" --text + $ ade --socket browser open http://localhost:5173 --new-tab --text + $ ade terminal read --chat-session --text Generic ADE action JSON contract: Object-shaped call: @@ -178,6 +190,7 @@ _ ____ _____ $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions + $ ade terminal list | read | write | signal Control the active in-chat terminal $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats @@ -187,6 +200,8 @@ _ ____ _____ $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory $ ade settings action Call project config actions $ ade actions list | run | status Escape hatch for every ADE service action @@ -214,6 +229,9 @@ _ ____ _____ $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text + $ ade app-control launch --command "pnpm dev" --text + $ ade --socket browser open http://localhost:5173 --new-tab --text + $ ade terminal read --chat-session --text Generic ADE action JSON contract: Object-shaped call: @@ -272,6 +290,8 @@ _ ____ _____ $ ade git unstage --lane src/file.ts Unstage one file $ ade git commit --lane [-m ] Commit, generating a message when omitted $ ade git push --lane --set-upstream Push through ADE + $ ade git branches --lane --text List branches with last-commit metadata + $ ade git user-identity --lane --text Read lane checkout's git user.name/email $ ade git stash push|list|apply|pop Use ADE lane stash actions $ ade git rebase --lane --ai Rebase with ADE conflict support $ ade diff changes --lane --text Inspect changed files @@ -324,12 +344,16 @@ _ ____ _____ Creating or linking a PR persists the lane mapping in ADE so the PR tab tracks it. $ ade prs list --text List PRs known to ADE + $ ade prs list-open --text List every open GitHub PR in the repo, keyed by head branch $ ade prs create --lane --base main Open and map a GitHub PR from a lane $ ade prs link --lane --url Map an existing GitHub PR to a lane $ ade prs checks --text Show check status $ ade prs comments --text Show unresolved review work $ ade prs inventory Refresh ADE issue inventory $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge + $ ade prs path-to-merge --model --conflict-strategy auto --force-finalize conditional + $ ade prs path-to-merge --model --no-early-merge-on-green + $ ade prs pipeline save --conflict-strategy rebase --early-merge-on-green $ ade prs resolve-thread --thread Resolve a review thread $ ade prs labels set ready-to-merge Replace labels $ ade prs reviewers request alice bob Request reviewers @@ -364,13 +388,34 @@ _ ____ _____ Shell sessions Shell commands create tracked PTY sessions that ADE can display and audit. + Inside an ADE chat, shell starts attach to the active chat automatically. $ ade shell start --lane -- npm test Start a tracked shell session $ ade shell start --lane -c "npm test" Start with a command string + $ ade shell start --lane --chat-session -c "npm test" $ ade shell write --data "q" Write data to a PTY $ ade shell resize --cols 120 --rows 36 $ ade shell close Dispose a PTY +## ade terminal --help +_ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| + + Chat terminal + + Terminal commands control the active in-chat terminal for an ADE chat. Use + desktop socket mode when you want the same terminal the user sees in the app. + + $ ade terminal list --chat-session --text List terminals for a chat + $ ade terminal active --chat-session --text Show the active chat terminal + $ ade terminal read --terminal --text Read terminal scrollback + $ ade app-control logs --text Read the active App Control launch terminal + $ ade terminal write --terminal --data "y\n" + $ ade terminal signal --terminal --signal SIGINT + ## ade chat --help _ ____ _____ / \ | _ \| ____| @@ -384,7 +429,7 @@ _ ____ _____ requires the desktop socket because the app owns provider/session state. $ ade chat list --text List chat sessions - $ ade chat create --lane --provider codex --model + $ ade chat create --lane --provider codex --model [--fast] $ ade chat send --text "next step" Send a message $ ade chat interrupt Stop an active turn $ ade chat resume Resume a session @@ -467,6 +512,21 @@ _ ____ _____ Run filter: --status +## ade flow --help +_ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| + + Flow policy + + $ ade flow policy get --text Read current workflow policy + $ ade flow policy validate --input-json '{...}' Validate policy JSON + $ ade flow policy save --input-json '{...}' Save policy JSON + $ ade flow policy revisions --text List saved revisions + $ ade flow policy rollback Restore a prior revision + ## ade coordinator --help _ ____ _____ / \ | _ \| ____| @@ -535,7 +595,7 @@ _ ____ _____ drawer simulator. Aliases: `ade ios` and `ade simulator` route to the same surface. For drawer/shared session state, prefer desktop socket mode (--socket) so launch/select/tap operate on the same long-lived ADE service. - Launch keeps Simulator.app hidden by default; use --foreground only when you + Launch is headless by default; use --foreground only when you need the native Simulator window in front. idb is optional for direct pointer/text control and the low-latency MJPEG live stream. @@ -550,7 +610,7 @@ _ ____ _____ $ ade ios-sim apps --device --text List launchable apps (listLaunchTargets) $ ade --socket ios-sim launch --target Build/install/launch and update drawer state $ ade --socket ios-sim launch --bundle-id com.example Launch installed app - $ ade --socket ios-sim shutdown Tear down session, streams, idb companion (alias: stop) + $ ade --socket ios-sim shutdown Tear down session, streams, helper processes (alias: stop) $ ade --socket ios-sim shutdown --force Force-release a session owned by another chat $ ade ios-sim actions --text List every callable ios_simulator action @@ -564,7 +624,7 @@ _ ____ _____ $ ade ios-sim preview-render --source Render a SwiftUI preview through Xcode MCP Streaming: - $ ade ios-sim live-start --fps 30 Low-latency idb live stream + $ ade ios-sim live-start --fps 30 Auto live stream (IOSurface first) $ ade ios-sim preview-start --fps 8 simctl screenshot-poll fallback $ ade ios-sim window-start --fps 60 Native Simulator.app window capture diagnostic $ ade ios-sim stream-status --text Backend/fps/latency/URL (getStreamStatus) @@ -572,11 +632,114 @@ _ ____ _____ Input and selection: $ ade --socket ios-sim select --x 120 --y 420 Add UI context to drawer chat (selectPoint) - $ ade ios-sim tap 120 420 Tap through idb (tap) - $ ade ios-sim drag 120 700 120 250 Drag through idb (drag) - $ ade ios-sim swipe 120 700 120 250 Swipe through idb (swipe) + $ ade ios-sim tap 120 420 Tap active simulator app (tap) + $ ade ios-sim drag 120 700 120 250 Drag active simulator app (drag) + $ ade ios-sim swipe 120 700 120 250 Swipe active simulator app (swipe) $ ade ios-sim type "hello" --text Type into the launched app (typeText) +## ade app-control --help +_ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| + + App Control + + App Control is ADE's bridge for developer-owned app sessions. The first + supported kind is Electron: ADE can launch or connect to an Electron renderer + that exposes a Chrome DevTools Protocol port, then capture screenshots, DOM + elements, selected UI context, and basic input in the same style as the iOS + simulator drawer. App Control is intentionally a bridge: Playwright, + agent-browser, Computer Use, and other tools may also attach to the same app; + ADE keeps the launch/session state and turns snapshots into chat context. + + Launching runs the command in the chat terminal instead of a hidden child + process. ADE sets ADE_APP_CONTROL_CDP_PORT and ADE_APP_CONTROL_DEBUG_FLAGS in + the environment and auto-forwards debug flags for common npm/pnpm/yarn/bun + script launches and direct electron commands. Custom launchers should forward + ADE_APP_CONTROL_DEBUG_FLAGS or ADE_APP_CONTROL_CDP_PORT. You can also put + {ADE_APP_CONTROL_DEBUG_FLAGS} in the command string for explicit substitution. + + Reuse a Run-tab command: list configured processes with + `ade settings get --text`, then pass `--cwd` so the launch runs from the + same directory the Run tab uses. Relative cwds resolve against the lane root. + + Discovery and lifecycle: + $ ade app-control status --text Show active session and provider readiness + $ ade app-control launch --command "npm run dev" --text + $ ade app-control launch pnpm dev --text Launch via the visible chat terminal + $ ade app-control launch --command "pnpm dev" --cwd apps/desktop --text + $ ade app-control launch --command "/path/script.sh {ADE_APP_CONTROL_DEBUG_FLAGS}" + $ ade app-control connect --cdp-port 9222 Attach to an already-running app + $ ade app-control targets --text List debuggable CDP targets + $ ade app-control attach-target --target Attach to one renderer target + $ ade app-control logs --text Read the active App Control launch terminal + $ ade app-control terminal write --data "y\n" Answer a prompt in that terminal + $ ade app-control stop --text Signal the App Control terminal session + $ ade app-control actions --text List every callable app_control action + $ ade terminal read --terminal --text Read a specific chat terminal + $ ade terminal write --chat-session --data "y\n" Answer a prompt + + Capture and context: + $ ade app-control screenshot --text Capture the active renderer screenshot + $ ade app-control snapshot --text Screenshot + DOM element refs + $ ade app-control inspect --x 120 --y 420 Hit-test a point without committing context + $ ade app-control select --x 120 --y 420 Add selected app context to the drawer chat + + Input: + $ ade app-control click 120 420 Click screenshot coordinates + $ ade app-control scroll --x 120 --y 420 --delta-y 600 + $ ade app-control key --key Enter + $ ade app-control type "hello" --text Type text into the focused element + +## ade browser --help +_ ____ _____ + / \ | _ \| ____| + / _ \ | | | | _| + / ___ \| |_| | |___ + /_/ \_\____/|_____| + + ADE browser + + Browser commands control ADE's global built-in browser pane. Use desktop + socket mode so CLI calls, chat link clicks, terminal localhost links, and the + Work sidebar all share the same browser tabs. The browser is global, not + lane-scoped. + + Tabs and navigation: + $ ade --socket browser status --text Show active tab and tab list + $ ade --socket browser panel --text Open the Work sidebar Browser panel + $ ade --socket browser open https://example.com --text + $ ade --socket browser open localhost:5173 --new-tab --text + $ ade --socket browser open https://example.com --no-panel + $ ade --socket browser new-tab --url https://example.com + $ ade --socket browser switch --tab + $ ade --socket browser close --tab + $ ade --socket browser actions --text List built_in_browser actions + + Page controls: + $ ade --socket browser reload + $ ade --socket browser back + $ ade --socket browser forward + $ ade --socket browser stop + + Capture and context: + $ ade --socket browser screenshot --text Capture the active browser tab + $ ade --socket browser select --x 120 --y 420 Attach DOM context at a viewport point + $ ade --socket browser inspect-start Start DOM inspect mode + $ ade --socket browser inspect-stop Stop DOM inspect mode + $ ade --socket browser select-current --text Return the selected DOM item + $ ade --socket browser clear-selection + + Flags: + --url URL for panel/open/new-tab. Bare localhost gets http://. + --new-tab Open navigation in a new tab instead of active tab. + --active-tab Navigate the active tab; aliases: --current-tab, --same-tab. + --background Create a new tab without activating it. + --no-panel Keep the Work sidebar panel hidden; alias: --hidden. + --tab, --tab-id Target tab for switch/close/open. + ## ade memory --help _ ____ _____ / \ | _ \| ____| @@ -614,6 +777,7 @@ _ ____ _____ $ ade prs list | create | path-to-merge Manage PRs, queues, and Path to Merge repair rounds $ ade run defs | ps | start | logs Manage Run tab process definitions and runtime $ ade shell start | write | resize | close Launch and control tracked shell sessions + $ ade terminal list | read | write | signal Control the active in-chat terminal $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats @@ -623,6 +787,8 @@ _ ____ _____ $ ade tests list | run | stop | runs | logs Run configured test suites $ ade proof status | list | screenshot | record Manage proof and computer-use artifacts $ ade ios-sim devices | apps | launch | tap Control iOS Simulator apps, capture, and input + $ ade app-control launch | snapshot | click Inspect and drive Electron apps + $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory $ ade settings action Call project config actions $ ade actions list | run | status Escape hatch for every ADE service action @@ -650,6 +816,9 @@ _ ____ _____ $ ade proof record --seconds 20 $ ade ios-sim apps --text $ ade ios-sim launch --target --text + $ ade app-control launch --command "pnpm dev" --text + $ ade --socket browser open http://localhost:5173 --new-tab --text + $ ade terminal read --chat-session --text Generic ADE action JSON contract: Object-shaped call: diff --git a/apps/desktop/scripts/regen-ade-cli-help.cjs b/apps/desktop/scripts/regen-ade-cli-help.cjs index 2976116bf..9dcf021c1 100644 --- a/apps/desktop/scripts/regen-ade-cli-help.cjs +++ b/apps/desktop/scripts/regen-ade-cli-help.cjs @@ -27,6 +27,7 @@ const SUBCOMMANDS = [ "proof", "ios-sim", "app-control", + "browser", "memory", "settings", "actions", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 595cefb10..409cd2b81 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1033,7 +1033,7 @@ app.whenReady().then(async () => { projectLastActivatedAt.set(activeProjectRoot, Date.now()); const activeCtx = projectContexts.get(activeProjectRoot); if (activeCtx) { - persistRecentProject(activeCtx.project, { recordLastProject: false }); + persistRecentProject(activeCtx.project, { recordLastProject: false, preserveRecentOrder: true }); } try { adeArtifactAllowedDir = @@ -1286,6 +1286,7 @@ app.whenReady().then(async () => { ensureExclude, recordLastProject = true, recordRecent = true, + preserveRecentOrder = false, userSelectedProject = false, }: { projectRoot: string; @@ -1293,6 +1294,7 @@ app.whenReady().then(async () => { ensureExclude: boolean; recordLastProject?: boolean; recordRecent?: boolean; + preserveRecentOrder?: boolean; userSelectedProject?: boolean; }): Promise => { // The .ade directory may exist from git (shared scaffold files like ade.yaml), @@ -3527,6 +3529,7 @@ app.whenReady().then(async () => { { recordLastProject, recordRecent, + preserveRecentOrder, }, ); writeGlobalState(globalStatePath, state); @@ -4338,6 +4341,7 @@ app.whenReady().then(async () => { ensureExclude: true, recordLastProject: false, recordRecent: true, + preserveRecentOrder: true, userSelectedProject: false, }); projectContexts.set(normalizedRoot, ctx); @@ -4515,7 +4519,7 @@ app.whenReady().then(async () => { const persistRecentProject = ( project: ProjectInfo, - options: { recordLastProject?: boolean; recordRecent?: boolean } = {}, + options: { recordLastProject?: boolean; recordRecent?: boolean; preserveRecentOrder?: boolean } = {}, ): void => { const state = upsertRecentProject( readGlobalState(globalStatePath), @@ -4538,6 +4542,10 @@ app.whenReady().then(async () => { try { const resolveStartedAt = Date.now(); repoRoot = normalizeProjectRoot(await resolveRepoRoot(selectedPath)); // require a real git repo for onboarding. + const isKnownRecentProject = (readGlobalState(globalStatePath).recentProjects ?? []).some((entry) => { + if (typeof entry?.rootPath !== "string") return false; + return normalizeProjectRoot(entry.rootPath) === repoRoot; + }); projectOpenLogger.info("project.open.repo_resolved", { selectedPath, repoRoot, @@ -4549,7 +4557,7 @@ app.whenReady().then(async () => { setActiveProject(repoRoot); persistRecentProject(existing.project, { recordLastProject: true, - recordRecent: false, + preserveRecentOrder: isKnownRecentProject, }); emitProjectChanged(existing.project); scheduleProjectContextRebalance(); @@ -4580,6 +4588,7 @@ app.whenReady().then(async () => { ensureExclude: true, recordLastProject: true, recordRecent: true, + preserveRecentOrder: isKnownRecentProject, userSelectedProject: true, }); projectOpenLogger.info("project.open.context_initialized", { diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index a38aa71a6..0e5864095 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -133,4 +133,11 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(actions).toContain(name); } }); + + it("exposes the browser panel and tab control surface", () => { + const actions = ADE_ACTION_ALLOWLIST.built_in_browser ?? []; + for (const name of ["showPanel", "navigate", "createTab", "switchTab", "closeTab"]) { + expect(actions).toContain(name); + } + }); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 80db3e890..3e75e1e61 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -346,7 +346,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { type DebuggerHandler = (...args: unknown[]) => void; type WindowOpenHandler = (details: { url: string }) => { action: string }; + type BeforeSendHeadersHandler = ( + details: { requestHeaders: Record }, + callback: (response: { requestHeaders: Record }) => void, + ) => void; class FakeDebugger { attached = false; @@ -35,6 +39,7 @@ const fakes = vi.hoisted(() => { id = Math.floor(Math.random() * 1_000_000); debugger = new FakeDebugger(); audioMutedCalls: boolean[] = []; + userAgentCalls: string[] = []; currentUrl = ""; private listeners: Record void>> = {}; private windowOpenHandler: WindowOpenHandler | null = null; @@ -59,6 +64,9 @@ const fakes = vi.hoisted(() => { setAudioMuted = (muted: boolean): void => { this.audioMutedCalls.push(muted); }; + setUserAgent = (userAgent: string): void => { + this.userAgentCalls.push(userAgent); + }; setWindowOpenHandler = (handler: WindowOpenHandler): void => { this.windowOpenHandler = handler; }; @@ -83,6 +91,7 @@ const fakes = vi.hoisted(() => { // Track the most recently constructed FakeDebugger so tests can wire sendCommand impls. const debuggerInstances: FakeDebugger[] = []; const webContentsInstances: FakeWebContents[] = []; + const beforeSendHeadersHandlers: BeforeSendHeadersHandler[] = []; const OriginalFakeDebugger = FakeDebugger; class TrackedFakeDebugger extends OriginalFakeDebugger { constructor() { @@ -117,6 +126,17 @@ const fakes = vi.hoisted(() => { debuggerInstances, webContentsInstances, openExternal: vi.fn(async (_url: string) => undefined), + beforeSendHeadersHandlers, + dispatchBeforeSendHeaders: ( + requestHeaders: Record, + ): { requestHeaders: Record } | null => { + let response: { requestHeaders: Record } | null = null; + const handler = beforeSendHeadersHandlers.at(-1); + handler?.({ requestHeaders }, (next) => { + response = next; + }); + return response as { requestHeaders: Record } | null; + }, setSendCommand: (impl: (method: string, params?: Record) => Promise) => { activeImpl = impl; }, @@ -129,13 +149,26 @@ const fakes = vi.hoisted(() => { clearWebContentsInstances: () => { webContentsInstances.length = 0; }, + clearBeforeSendHeadersHandlers: () => { + beforeSendHeadersHandlers.length = 0; + }, }; }); vi.mock("electron", () => ({ WebContentsView: fakes.WebContentsView, nativeImage: { createFromDataURL: () => ({ getSize: () => ({ width: 0, height: 0 }) }) }, - session: { fromPartition: () => ({ setPermissionCheckHandler: () => {}, setPermissionRequestHandler: () => {} }) }, + session: { + fromPartition: () => ({ + webRequest: { + onBeforeSendHeaders: (handler: unknown) => { + fakes.beforeSendHeadersHandlers.push(handler as Parameters[0]); + }, + }, + setPermissionCheckHandler: () => {}, + setPermissionRequestHandler: () => {}, + }), + }, shell: { openExternal: fakes.openExternal }, webContents: { fromId: () => null }, })); @@ -178,6 +211,7 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { beforeEach(() => { collector = captureStatusEvents(); fakes.clearWebContentsInstances(); + fakes.clearBeforeSendHeadersHandlers(); fakes.openExternal.mockClear(); }); @@ -279,20 +313,18 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { expect(wc?.audioMutedCalls.at(-1)).toBe(true); }); - it("opens Google account sign-in externally without creating an ADE browser tab", async () => { + it("keeps Google account sign-in inside ADE browser tabs", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); const googleAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=test"; await service.navigate({ url: googleAuthUrl, newTab: true }); - expect(fakes.openExternal).toHaveBeenCalledWith(googleAuthUrl); - expect(service.getStatus().tabs).toEqual([]); - expect(collector.events.some((event) => ( - event.type === "error" && /system browser/i.test(event.message) - ))).toBe(true); + expect(fakes.openExternal).not.toHaveBeenCalled(); + expect(service.getStatus().tabs).toHaveLength(1); + expect(service.getStatus().url).toBe(googleAuthUrl); }); - it("blocks in-page Google sign-in navigations and opens them externally", async () => { + it("allows in-page Google sign-in navigations", async () => { const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); service.attachToWindow(fakeBrowserWindow() as unknown as Parameters[0]); await service.createTab({ url: "https://example.test", activate: true }); @@ -303,9 +335,75 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { const wc = fakes.webContentsInstances[0]; wc?.emit("will-navigate", event, googleSignInUrl); - expect(event.preventDefault).toHaveBeenCalled(); - expect(fakes.openExternal).toHaveBeenCalledWith(googleSignInUrl); - expect(service.getStatus().url).toBe("https://example.test/"); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(fakes.openExternal).not.toHaveBeenCalled(); + }); + + it("uses a desktop Chrome user agent for ADE browser requests", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await service.createTab({ url: "https://example.test", activate: true }); + + const wc = fakes.webContentsInstances[0]; + expect(wc?.userAgentCalls.at(-1)).toMatch(/ Chrome\/\d+\.\d+\.\d+\.\d+ /); + expect(wc?.userAgentCalls.at(-1)).not.toMatch(/Electron|ADE/i); + + const response = fakes.dispatchBeforeSendHeaders({ + "User-Agent": "Electron/30", + "user-agent": "ADE/Electron", + "Sec-CH-UA": "\"Electron\";v=\"30\"", + "sec-ch-ua": "\"ADE\";v=\"1\"", + }); + + expect(response?.requestHeaders["User-Agent"]).toMatch(/ Chrome\/\d+\.\d+\.\d+\.\d+ /); + expect(response?.requestHeaders["User-Agent"]).not.toMatch(/Electron|ADE/i); + expect(response?.requestHeaders["user-agent"]).toBeUndefined(); + expect(response?.requestHeaders["Sec-CH-UA"]).toContain("Google Chrome"); + expect(response?.requestHeaders["Sec-CH-UA"]).not.toContain("Electron"); + expect(response?.requestHeaders["sec-ch-ua"]).toBeUndefined(); + }); + + it("emits an open request so the Work sidebar can reveal the browser panel", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await service.createTab({ url: "https://example.test", activate: true, openPanel: true }); + + const openEvent = collector.events.find((event) => event.type === "open-request"); + expect(openEvent).toMatchObject({ + type: "open-request", + url: "https://example.test/", + tabId: service.getStatus().activeTabId, + }); + }); + + it("showPanel can navigate to a URL before opening the panel", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + + await service.showPanel({ url: "localhost:5173" }); + + expect(service.getStatus().tabs).toHaveLength(1); + expect(service.getStatus().url).toBe("http://localhost:5173/"); + const openEvent = collector.events.findLast((event) => event.type === "open-request"); + expect(openEvent).toMatchObject({ + type: "open-request", + url: "http://localhost:5173/", + tabId: service.getStatus().activeTabId, + }); + }); + + it("showPanel can switch to a requested tab before opening the panel", async () => { + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await service.createTab({ url: "https://first.test", activate: true }); + const firstTabId = service.getStatus().activeTabId; + await service.createTab({ url: "https://second.test", activate: true }); + expect(service.getStatus().activeTabId).not.toBe(firstTabId); + + await service.showPanel({ tabId: firstTabId }); + + expect(service.getStatus().activeTabId).toBe(firstTabId); + const openEvent = collector.events.findLast((event) => event.type === "open-request"); + expect(openEvent).toMatchObject({ + type: "open-request", + tabId: firstTabId, + }); }); }); @@ -317,6 +415,7 @@ describe("createBuiltInBrowserService — switchTab and navigate inspect/selecti fakes.resetSendCommand(); fakes.clearDebuggerInstances(); fakes.clearWebContentsInstances(); + fakes.clearBeforeSendHeadersHandlers(); fakes.openExternal.mockClear(); }); diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index 468f3c031..62f6c8ad0 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -1,4 +1,4 @@ -import { WebContentsView, nativeImage, session, shell, webContents as electronWebContents } from "electron"; +import { WebContentsView, nativeImage, session, webContents as electronWebContents } from "electron"; import type { BrowserWindow, WebContents } from "electron"; import { randomUUID } from "node:crypto"; import type { @@ -9,6 +9,7 @@ import type { BuiltInBrowserEventPayload, BuiltInBrowserFrame, BuiltInBrowserNavigateArgs, + BuiltInBrowserOpenPanelArgs, BuiltInBrowserScreenshot, BuiltInBrowserSelectPointArgs, BuiltInBrowserSelectResult, @@ -24,9 +25,10 @@ const SCREENSHOT_TIMEOUT_MS = 3_000; const ELEMENT_SCREENSHOT_TIMEOUT_MS = 2_000; const DEBUGGER_TIMEOUT_MS = 3_000; const MAX_BROWSER_TABS = 10; -const GOOGLE_SIGN_IN_EXTERNAL_NOTICE_DEDUPE_MS = 2_000; -const GOOGLE_SIGN_IN_EXTERNAL_MESSAGE = - "Google blocks account sign-in inside embedded app browsers. ADE opened the Google sign-in page in your system browser instead. ADE browser cookies are separate, so that system-browser login will not transfer back into ADE."; +const BROWSER_CHROME_VERSION = normalizeChromeVersion(process.versions.chrome); +const BROWSER_CHROME_MAJOR_VERSION = BROWSER_CHROME_VERSION.split(".")[0] || "120"; +const BROWSER_USER_AGENT = buildDesktopChromeUserAgent(BROWSER_CHROME_VERSION, process.platform); +const BROWSER_UA_PLATFORM = browserClientHintsPlatform(process.platform); type DebuggerMessageListener = ( event: Electron.Event, @@ -100,7 +102,6 @@ export function createBuiltInBrowserService(args: { let handlingInspectNode = false; let browserSessionConfigured = false; let lastEmittedStatusKey: string | null = null; - let lastGoogleSignInExternalNotice: { url: string; at: number } | null = null; const configuredWebContents = new WeakSet(); const logger = (): Logger | null => { @@ -140,32 +141,6 @@ export function createBuiltInBrowserService(args: { emit({ type: "error", message, occurredAt: new Date().toISOString() }); }; - const emitGoogleSignInExternalNotice = (url: string): void => { - const now = Date.now(); - if ( - lastGoogleSignInExternalNotice - && lastGoogleSignInExternalNotice.url === url - && now - lastGoogleSignInExternalNotice.at < GOOGLE_SIGN_IN_EXTERNAL_NOTICE_DEDUPE_MS - ) { - return; - } - lastGoogleSignInExternalNotice = { url, at: now }; - emit({ - type: "error", - message: GOOGLE_SIGN_IN_EXTERNAL_MESSAGE, - occurredAt: new Date().toISOString(), - }); - }; - - const openGoogleSignInExternallyIfNeeded = (url: string): boolean => { - if (!isGoogleEmbeddedSignInUrl(url)) return false; - void shell.openExternal(url).catch((error) => { - emitError(new Error(`Could not open Google sign-in in the system browser: ${error instanceof Error ? error.message : String(error)}`)); - }); - emitGoogleSignInExternalNotice(url); - return true; - }; - const stopInspectQuietly = async (logKey: string): Promise => { try { await stopInspect(); @@ -217,18 +192,18 @@ export function createBuiltInBrowserService(args: { const configureBrowserWebContents = (wc: WebContents): void => { if (configuredWebContents.has(wc)) return; configuredWebContents.add(wc); + try { + wc.setUserAgent(BROWSER_USER_AGENT); + } catch (error) { + logger()?.debug("built_in_browser.set_user_agent_failed", { + err: error instanceof Error ? error.message : String(error), + }); + } wc.setWindowOpenHandler(({ url }) => { - if (openGoogleSignInExternallyIfNeeded(url)) { - return { action: "deny" }; - } - void navigate({ url, newTab: true }).catch(emitError); + void navigate({ url, newTab: true, openPanel: true }).catch(emitError); return { action: "deny" }; }); wc.on("will-navigate", (event, url) => { - if (openGoogleSignInExternallyIfNeeded(url)) { - event.preventDefault(); - return; - } if (isAllowedNavigationUrl(url)) return; event.preventDefault(); emitError(new Error(`Blocked unsupported browser navigation protocol: ${url}`)); @@ -333,6 +308,9 @@ export function createBuiltInBrowserService(args: { const configureBrowserSession = (): void => { if (browserSessionConfigured) return; const browserSession = session.fromPartition(BROWSER_PARTITION); + browserSession.webRequest.onBeforeSendHeaders((details, callback) => { + callback({ requestHeaders: normalizeBrowserRequestHeaders(details.requestHeaders) }); + }); browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => { logger()?.debug("built_in_browser.permission_check_denied", { permission, @@ -397,6 +375,32 @@ export function createBuiltInBrowserService(args: { }; } + const requestOpenPanel = (input: BuiltInBrowserOpenPanelArgs = {}): BuiltInBrowserStatus => { + const status = getStatus(); + const tabId = stringOrNull(input.tabId) ?? status.activeTabId; + const url = stringOrNull(input.url) ?? status.url; + emit({ + type: "open-request", + status, + tabId, + url, + requestedAt: new Date().toISOString(), + }); + return status; + }; + + async function showPanel(input: BuiltInBrowserOpenPanelArgs = {}): Promise { + const tabId = stringOrNull(input.tabId); + const url = stringOrNull(input.url); + if (url) { + return navigate({ url, tabId, openPanel: true }); + } + if (tabId) { + return switchTab({ tabId, openPanel: true }); + } + return requestOpenPanel(input); + } + async function setBounds(nextBounds: BuiltInBrowserBoundsArgs): Promise { const normalized: BuiltInBrowserFrame = { x: normalizeDimension(nextBounds.x), @@ -480,9 +484,6 @@ export function createBuiltInBrowserService(args: { async function navigate(input: BuiltInBrowserNavigateArgs): Promise { const targetUrl = normalizeBrowserUrl(input.url); - if (openGoogleSignInExternallyIfNeeded(targetUrl)) { - return getStatus(); - } if (input.newTab && tabs.length >= MAX_BROWSER_TABS) { throw new Error(`ADE browser is limited to ${MAX_BROWSER_TABS} tabs. Close a tab before opening another.`); } @@ -511,6 +512,9 @@ export function createBuiltInBrowserService(args: { const wc = tab.webContents; attachViewsToCurrentWindow(); await wc.loadURL(targetUrl); + if (input.openPanel) { + requestOpenPanel({ url: targetUrl, tabId: tab.id }); + } emitStatus(); return getStatus(); } @@ -521,9 +525,6 @@ export function createBuiltInBrowserService(args: { } // Normalize URL up front so we don't leave an orphan tab on invalid input. const normalizedUrl = input.url ? normalizeBrowserUrl(input.url) : null; - if (normalizedUrl && openGoogleSignInExternallyIfNeeded(normalizedUrl)) { - return getStatus(); - } const willActivate = input.activate !== false || !activeTabId; if (willActivate) { await stopInspectQuietly("built_in_browser.create_tab_stop_inspect_failed"); @@ -536,6 +537,9 @@ export function createBuiltInBrowserService(args: { if (normalizedUrl) { await tab.webContents.loadURL(normalizedUrl); } + if (input.openPanel) { + requestOpenPanel({ url: normalizedUrl, tabId: tab.id }); + } emitStatus(); return getStatus(); } @@ -554,6 +558,9 @@ export function createBuiltInBrowserService(args: { clearSelectionInternal(); } attachViewsToCurrentWindow(); + if (input.openPanel) { + requestOpenPanel({ tabId: tab.id }); + } emitStatus(); return getStatus(); } @@ -1031,6 +1038,7 @@ export function createBuiltInBrowserService(args: { return { attachToWindow, getStatus, + showPanel, setBounds, attachWebview, navigate, @@ -1062,6 +1070,60 @@ function emptyToNull(value: string): string | null { return trimmed.length ? trimmed : null; } +function normalizeChromeVersion(version: string | undefined): string { + const match = (version ?? "").match(/^\d+(?:\.\d+){0,3}/); + const parts = (match?.[0] ?? "120.0.0.0").split("."); + while (parts.length < 4) parts.push("0"); + return parts.slice(0, 4).join("."); +} + +function buildDesktopChromeUserAgent(chromeVersion: string, platform: NodeJS.Platform): string { + let platformToken: string; + if (platform === "darwin") { + platformToken = "Macintosh; Intel Mac OS X 10_15_7"; + } else if (platform === "win32") { + platformToken = "Windows NT 10.0; Win64; x64"; + } else { + platformToken = "X11; Linux x86_64"; + } + return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`; +} + +function browserClientHintsPlatform(platform: NodeJS.Platform): string { + if (platform === "darwin") return "macOS"; + if (platform === "win32") return "Windows"; + return "Linux"; +} + +function browserClientHintsBrands(): string { + return `"Google Chrome";v="${BROWSER_CHROME_MAJOR_VERSION}", "Chromium";v="${BROWSER_CHROME_MAJOR_VERSION}", "Not:A-Brand";v="24"`; +} + +type BrowserRequestHeaders = Record; + +function setRequestHeader(headers: BrowserRequestHeaders, name: string, value: string): void { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === name.toLowerCase()) { + delete headers[key]; + } + } + headers[name] = value; +} + +function normalizeBrowserRequestHeaders( + headers: Record, +): BrowserRequestHeaders { + const next: BrowserRequestHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (value !== undefined) next[key] = value; + } + setRequestHeader(next, "User-Agent", BROWSER_USER_AGENT); + setRequestHeader(next, "Sec-CH-UA", browserClientHintsBrands()); + setRequestHeader(next, "Sec-CH-UA-Mobile", "?0"); + setRequestHeader(next, "Sec-CH-UA-Platform", `"${BROWSER_UA_PLATFORM}"`); + return next; +} + function tabStatus(tab: BrowserTabState): BuiltInBrowserTab { const wc = tab.webContents; return { @@ -1115,27 +1177,6 @@ function isAllowedNavigationUrl(rawUrl: string): boolean { } } -function isGoogleEmbeddedSignInUrl(rawUrl: string): boolean { - try { - const parsed = new URL(rawUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; - if (parsed.hostname.toLowerCase() !== "accounts.google.com") return false; - if (parsed.searchParams.get("error") === "disallowed_useragent") return true; - const path = parsed.pathname.toLowerCase(); - return ( - path === "/" - || path.startsWith("/accountchooser") - || path.startsWith("/interactivelogin") - || path.startsWith("/o/oauth") - || path.startsWith("/signin") - || path.startsWith("/servicelogin") - || path.startsWith("/v3/signin") - ); - } catch { - return false; - } -} - function toElectronRect(frame: BuiltInBrowserFrame): Electron.Rectangle { return { x: Math.max(0, Math.round(frame.x)), diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 89b615388..6bd0b7013 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1566,6 +1566,103 @@ describe("createAgentChatService", () => { }, { timeout: 2000, interval: 50 }); }); + it("uses the selected Claude handoff permission instead of the source interaction mode", async () => { + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-handoff", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Handoff received" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-handoff", + setPermissionMode, + } as any); + + const { service } = createService(); + const source = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + modelId: "anthropic/claude-sonnet-4-6", + interactionMode: "default", + claudePermissionMode: "default", + permissionMode: "default", + }); + + const result = await service.handoffSession({ + sourceSessionId: source.id, + targetModelId: "anthropic/claude-sonnet-4-6", + claudePermissionMode: "plan", + permissionMode: "plan", + }); + + expect(result.session.provider).toBe("claude"); + expect(result.session.interactionMode).toBe("plan"); + expect(result.session.permissionMode).toBe("plan"); + expect(setPermissionMode).toHaveBeenCalledWith("plan"); + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.stringContaining("This message was injected automatically by ADE during a chat handoff.")); + }); + }); + + it("does not carry a source interaction mode into non-Claude handoff targets", async () => { + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } }; + })(), + } as any); + + const { service } = createService(); + const source = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/openai/gpt-5.4", + }); + source.interactionMode = "plan"; + source.permissionMode = "plan"; + + const result = await service.handoffSession({ + sourceSessionId: source.id, + targetModelId: "opencode/openai/gpt-5.4-mini", + opencodePermissionMode: "full-auto", + permissionMode: "full-auto", + }); + + expect(result.session.provider).toBe("opencode"); + expect(result.session.interactionMode).toBeUndefined(); + expect(result.session.permissionMode).toBe("full-auto"); + expect(result.session.opencodePermissionMode).toBe("full-auto"); + }); + it("uses AI-generated handoff summaries when a summary model is available", async () => { vi.mocked(streamText).mockReturnValue({ fullStream: (async function* () { @@ -1893,182 +1990,6 @@ describe("createAgentChatService", () => { }), ); }); - - it.skip("executes identity-hosted opencode turns from the selected execution lane", async () => { - const streamCalls: Array> = []; - vi.mocked(streamText).mockImplementation((args: Record) => { - streamCalls.push(args); - return { - fullStream: (async function* () { - yield { type: "finish", usage: {} }; - })(), - } as any; - }); - vi.mocked(createUniversalToolSet).mockClear(); - vi.mocked(createWorkflowTools).mockClear(); - vi.mocked(buildCodingAgentSystemPrompt).mockClear(); - - const selectedLaneRootPath = path.join(tmpRoot, "lane-2"); - fs.mkdirSync(selectedLaneRootPath, { recursive: true }); - const selectedLaneRoot = fs.realpathSync(selectedLaneRootPath); - const { service } = createService(); - const session = await service.ensureIdentitySession({ - identityKey: "cto", - laneId: "lane-2", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Fix the lane launch bug without leaving this lane.", - }); - - expect(vi.mocked(createUniversalToolSet)).toHaveBeenCalledWith( - selectedLaneRoot, - expect.any(Object), - ); - expect(vi.mocked(createWorkflowTools)).toHaveBeenCalledWith( - expect.objectContaining({ laneId: "lane-2" }), - ); - expect(vi.mocked(buildCodingAgentSystemPrompt)).toHaveBeenCalledWith( - expect.objectContaining({ cwd: selectedLaneRoot }), - ); - const firstMessages = Array.isArray(streamCalls[0]?.messages) - ? (streamCalls[0]!.messages as Array<{ role: string; content: unknown }>) - : []; - const firstUserContent = String(firstMessages.at(-1)?.content ?? ""); - expect(firstUserContent).toContain("lane 'lane-2'"); - expect(firstUserContent).toContain(selectedLaneRoot); - }); - - it.skip("reinjects the lane binding when an identity session switches execution lanes", async () => { - const streamCalls: Array> = []; - vi.mocked(streamText).mockImplementation((args: Record) => { - streamCalls.push(args); - return { - fullStream: (async function* () { - yield { type: "finish", usage: {} }; - })(), - } as any; - }); - - const { service } = createService(); - const session = await service.ensureIdentitySession({ - identityKey: "cto", - laneId: "lane-2", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Handle the first selected lane task.", - }); - - await service.ensureIdentitySession({ - identityKey: "cto", - laneId: "lane-1", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Handle the second selected lane task.", - }); - - const firstMessages = Array.isArray(streamCalls[0]?.messages) - ? (streamCalls[0]!.messages as Array<{ role: string; content: unknown }>) - : []; - const secondMessages = Array.isArray(streamCalls[1]?.messages) - ? (streamCalls[1]!.messages as Array<{ role: string; content: unknown }>) - : []; - const firstUserContent = String(firstMessages.at(-1)?.content ?? ""); - const secondUserContent = String(secondMessages.at(-1)?.content ?? ""); - - expect(firstUserContent).toContain("lane 'lane-2'"); - expect(firstUserContent).toContain(path.join(tmpRoot, "lane-2")); - expect(secondUserContent).toContain("lane 'lane-1'"); - expect(secondUserContent).toContain(tmpRoot); - }); - - it.skip("rebinds queued opencode steers after an identity session switches execution lanes", async () => { - const streamCalls: Array> = []; - const firstTurnControl: { release?: () => void } = {}; - let streamCallCount = 0; - vi.mocked(streamText).mockImplementation((args: Record) => { - streamCalls.push(args); - streamCallCount += 1; - if (streamCallCount === 1) { - return { - fullStream: (async function* () { - await new Promise((resolve) => { - firstTurnControl.release = resolve; - }); - yield { type: "finish", usage: {} }; - })(), - } as any; - } - return { - fullStream: (async function* () { - yield { type: "finish", usage: {} }; - })(), - } as any; - }); - - const { service } = createService(); - const session = await service.ensureIdentitySession({ - identityKey: "cto", - laneId: "lane-2", - }); - - const firstTurn = service.runSessionTurn({ - sessionId: session.id, - text: "Handle the current lane task first.", - }); - await Promise.resolve(); - - await service.ensureIdentitySession({ - identityKey: "cto", - laneId: "lane-1", - }); - await service.steer({ - sessionId: session.id, - text: "Continue in the newly selected lane.", - }); - - expect(firstTurnControl.release).toBeTypeOf("function"); - firstTurnControl.release!(); - await firstTurn; - for (let attempt = 0; attempt < 20 && streamCalls.length < 2; attempt += 1) { - await Promise.resolve(); - } - expect(streamCalls).toHaveLength(2); - - const secondMessages = Array.isArray(streamCalls[1]?.messages) - ? (streamCalls[1]!.messages as Array<{ role: string; content: unknown }>) - : []; - const secondUserContent = String(secondMessages.at(-1)?.content ?? ""); - - expect(secondUserContent).toContain("lane 'lane-1'"); - expect(secondUserContent).toContain(tmpRoot); - }); - - it.skip("does not persist the lane directive key when a opencode turn fails before completion", async () => { - vi.mocked(streamText).mockImplementation(() => ({ - fullStream: (async function* () { - throw new Error("stream failed"); - })(), - }) as any); - - const { service } = createService(); - const session = await service.ensureIdentitySession({ - identityKey: "cto", - laneId: "lane-2", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Inspect the bug from the selected lane.", - }); - - expect(readPersistedChatState(session.id).lastLaneDirectiveKey).toBeUndefined(); - }); }); // -------------------------------------------------------------------------- @@ -4114,67 +4035,6 @@ describe("createAgentChatService", () => { expect(userMessage.event.attachments).toEqual([{ path: "note.txt", type: "file" }]); }); - it.skip("logs attachment read failures and keeps the fallback text generic", async () => { - const events: AgentChatEventEnvelope[] = []; - const { service, logger } = createService({ - onEvent: (event: AgentChatEventEnvelope) => { - events.push(event); - }, - }); - const attachmentDir = path.join(tmpRoot, "attachment-dir"); - fs.mkdirSync(attachmentDir, { recursive: true }); - let streamArgs: Record | null = null; - vi.mocked(streamText).mockImplementation((args: Record) => { - streamArgs = args; - return { - fullStream: (async function* () { - yield { type: "finish", usage: {} }; - })(), - } as any; - }); - - const session = await service.createSession({ - laneId: "lane-1", - provider: "opencode", - model: "", - modelId: "opencode/anthropic/claude-sonnet-4-6", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Check this attachment", - attachments: [{ path: "attachment-dir", type: "file" }], - }); - - const rawMessages = streamArgs && Array.isArray((streamArgs as { messages?: unknown }).messages) - ? (streamArgs as { messages: Array<{ role: string; content: unknown }> }).messages - : []; - const messages = rawMessages; - const currentUserMessageText = JSON.stringify(messages.at(-1)?.content); - const userMessageEvent = await waitForEvent( - events, - (event): event is AgentChatEventEnvelope & { - event: Extract; - } => event.event.type === "user_message", - ); - const rendererPayload = JSON.stringify(userMessageEvent.event); - - expect(currentUserMessageText).toContain("Attachment unavailable: attachment-dir"); - expect(currentUserMessageText).not.toContain("Path is not a regular file"); - expect(currentUserMessageText).not.toContain("EISDIR"); - expect(rendererPayload).not.toContain("Path is not a regular file"); - expect(rendererPayload).not.toContain("EISDIR"); - expect(rendererPayload).not.toContain(attachmentDir); - expect(rendererPayload).not.toContain(tmpRoot); - expect(logger.warn).toHaveBeenCalledWith( - "agent_chat.streaming_attachment_unavailable", - expect.objectContaining({ - attachmentPath: "attachment-dir", - error: expect.any(Error), - }), - ); - }); - it("prefers the canonical turn-scoped Codex text stream when item-scoped deltas also arrive", async () => { const textEvents: Array<{ text: string; itemId?: string; turnId?: string }> = []; const { service } = createService({ @@ -6008,37 +5868,187 @@ describe("createAgentChatService", () => { expect(collaborationMode?.settings?.developer_instructions).toBeNull(); }); - it("preserves Codex edit sessions as untrusted workspace-write", async () => { + it("sends fast service tier for supported Codex models when enabled", async () => { const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", provider: "codex", - model: "gpt-5.4", - codexApprovalPolicy: "untrusted", - codexSandbox: "workspace-write", - codexConfigSource: "flags", + model: "gpt-5.5", + codexFastMode: true, }); - expect(session.permissionMode).toBe("edit"); - expect(session.codexApprovalPolicy).toBe("untrusted"); - expect(session.codexSandbox).toBe("workspace-write"); + expect(session.codexFastMode).toBe(true); - const summary = await service.getSessionSummary(session.id); - expect(summary?.permissionMode).toBe("edit"); - }); + await service.sendMessage({ + sessionId: session.id, + text: "Use fast mode.", + }); - it("starts Codex full-auto sessions with danger-full-access and never approval", async () => { - vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { - if (mode === "full-auto") { - return { approvalPolicy: "never", sandbox: "danger-full-access" }; - } - if (mode === "edit") { - return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; - } - if (mode === "config-toml") { - return null; - } - return { approvalPolicy: "on-request", sandbox: "read-only" }; + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + expect((threadStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBe("fast"); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + expect((turnStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBe("fast"); + + expect((await service.getSessionSummary(session.id))?.codexFastMode).toBe(true); + expect(readPersistedChatState(session.id).codexFastMode).toBe(true); + }); + + it("explicitly clears Codex service tier when fast mode is off", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Use standard mode.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + expect((threadStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBeNull(); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + expect((turnStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBeNull(); + expect((await service.getSessionSummary(session.id))?.codexFastMode).toBe(false); + }); + + it("preserves fast mode selection on unsupported Codex models while sending standard tier", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-mini", + codexFastMode: true, + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Unsupported fast model should run standard.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + expect((threadStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBeNull(); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + expect((turnStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBeNull(); + expect((await service.getSessionSummary(session.id))?.codexFastMode).toBe(true); + }); + + it("clears fast mode when switching a session away from Codex", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + codexFastMode: true, + }); + + const updated = await service.updateSession({ + sessionId: session.id, + modelId: "anthropic/claude-sonnet-4-6", + }); + + expect(updated.provider).toBe("claude"); + expect(updated.codexFastMode).toBeUndefined(); + expect((await service.getSessionSummary(session.id))?.codexFastMode).toBe(false); + expect(readPersistedChatState(session.id).codexFastMode).toBeUndefined(); + }); + + it("re-resumes Codex threads when fast mode changes mid-session", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Initial standard turn.", + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: `turn-${mockState.codexTurnCounter}`, + status: "completed", + }, + }, + }); + await vi.waitFor(async () => { + expect((await service.getSessionSummary(session.id))?.status).toBe("idle"); + }); + + mockState.codexRequestPayloads = []; + const updated = await service.updateSession({ + sessionId: session.id, + codexFastMode: true, + }); + expect(updated.codexFastMode).toBe(true); + + await service.sendMessage({ + sessionId: session.id, + text: "Next turn should re-resume fast.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/resume")).toBe(true); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const resumeRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/resume"); + expect((resumeRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBe("fast"); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + expect((turnStartRequest?.params as { serviceTier?: unknown } | undefined)?.serviceTier).toBe("fast"); + }); + + it("preserves Codex edit sessions as untrusted workspace-write", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + codexApprovalPolicy: "untrusted", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + }); + + expect(session.permissionMode).toBe("edit"); + expect(session.codexApprovalPolicy).toBe("untrusted"); + expect(session.codexSandbox).toBe("workspace-write"); + + const summary = await service.getSessionSummary(session.id); + expect(summary?.permissionMode).toBe("edit"); + }); + + it("starts Codex full-auto sessions with danger-full-access and never approval", async () => { + vi.mocked(mapPermissionToCodex).mockImplementation((mode) => { + if (mode === "full-auto") { + return { approvalPolicy: "never", sandbox: "danger-full-access" }; + } + if (mode === "edit") { + return { approvalPolicy: "untrusted", sandbox: "workspace-write" }; + } + if (mode === "config-toml") { + return null; + } + return { approvalPolicy: "on-request", sandbox: "read-only" }; }); const { service } = createService(); @@ -8280,80 +8290,6 @@ describe("createAgentChatService", () => { ); }); - it.skip("exits opencode plan mode after a one-time plan approval", async () => { - const events: AgentChatEventEnvelope[] = []; - let requestApproval: - | ((args: { - category: "exitPlanMode"; - description: string; - detail?: Record; - }) => Promise<{ approved: boolean; decision?: string; reason: string }>) - | null = null; - - vi.mocked(createUniversalToolSet).mockImplementation((_cwd: string, options: any) => { - requestApproval = options.onApprovalRequest; - return {}; - }); - vi.mocked(streamText).mockImplementation(() => ({ - fullStream: (async function* () { - yield { type: "start-step", stepNumber: 0 }; - if (!requestApproval) { - throw new Error("OpenCode approval handler was not captured."); - } - const approvalPromise = requestApproval({ - category: "exitPlanMode", - description: "Plan ready for approval", - detail: { planContent: "1. Inspect\n2. Implement" }, - }); - yield { type: "tool-call", toolName: "ExitPlanMode", toolCallId: "tool-exit-plan" }; - const approvalResult = await approvalPromise; - yield { type: "tool-result", toolName: "ExitPlanMode", toolCallId: "tool-exit-plan", result: approvalResult }; - yield { type: "text-delta", textDelta: "Implementation complete." }; - yield { type: "finish", usage: {} }; - })(), - }) as any); - - const { service } = createService({ - onEvent: (event: AgentChatEventEnvelope) => events.push(event), - }); - - const session = await service.createSession({ - laneId: "lane-1", - provider: "opencode", - model: "opencode/openai/gpt-5.4", - modelId: "opencode/openai/gpt-5.4", - permissionMode: "plan", - }); - - const sendPromise = service.sendMessage({ - sessionId: session.id, - text: "Review the plan and implement it after approval.", - }); - - const approvalEvent = await waitForEvent( - events, - (event): event is AgentChatEventEnvelope & { - event: Extract; - } => { - if (event.event.type !== "approval_request") return false; - const detail = event.event.detail as { request?: { kind?: string } } | undefined; - return detail?.request?.kind === "plan_approval"; - }, - ); - - await service.approveToolUse({ - sessionId: session.id, - itemId: approvalEvent.event.itemId, - decision: "accept", - }); - - await sendPromise; - - const updated = await service.getSessionSummary(session.id); - expect(updated?.permissionMode).toBe("edit"); - expect(updated?.opencodePermissionMode).toBe("edit"); - }); - it("preserves original attachments across local auto-continuation retries", () => { const resolvedPath = path.join(tmpRoot, "note.txt"); fs.writeFileSync(resolvedPath, "remember this", "utf8"); @@ -8407,346 +8343,6 @@ describe("createAgentChatService", () => { content: "Continue from your last step.", }); }); - - it.skip("stops a local opencode turn immediately after the first stop_tools decision inside a step", async () => { - const events: AgentChatEventEnvelope[] = []; - const grepExecute = vi.fn(async () => ({ matches: [], matchCount: 0 })); - - replaceDynamicOpenCodeModelDescriptors([ - createDynamicOpenCodeModelDescriptor("lmstudio/google/gemma-4-26b-a4b", { - displayName: "google/gemma-4-26b-a4b", - capabilities: { tools: true, vision: false, reasoning: false, streaming: true }, - openCodeProviderId: "lmstudio", - openCodeModelId: "google/gemma-4-26b-a4b", - }), - ]); - vi.mocked(createUniversalToolSet).mockImplementation(() => ({ - grep: { description: "stub", parameters: { type: "object", properties: {} }, execute: grepExecute }, - }) as any); - vi.mocked(streamText).mockImplementation((args: Record) => ({ - fullStream: (async function* () { - const tools = (args.tools ?? {}) as Record Promise }>; - yield { type: "start-step", stepNumber: 0 }; - for (let index = 1; index <= 8; index += 1) { - const input = { pattern: "Tab", glob: "**/*.tsx", context: 0 }; - yield { type: "tool-call", toolName: "grep", toolCallId: `tool-${index}`, input }; - const output = await tools.grep!.execute!(input); - yield { type: "tool-result", toolName: "grep", toolCallId: `tool-${index}`, output }; - } - yield { type: "finish", usage: {} }; - })(), - }) as any); - - const { service } = createService({ - onEvent: (event: AgentChatEventEnvelope) => events.push(event), - }); - - const session = await service.createSession({ - laneId: "lane-1", - provider: "opencode", - model: "LM Studio (Auto)", - modelId: "lmstudio/auto", - permissionMode: "plan", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Add a new blank test tab to the website.", - }); - - expect(grepExecute).toHaveBeenCalledTimes(2); - - const grepToolCalls = events.filter((event) => - event.event.type === "tool_call" && event.event.tool === "grep" - ); - expect(grepToolCalls).toHaveLength(3); - - const stopNotices = events.filter((event) => - event.event.type === "system_notice" - && event.event.message === "ADE tool policy stop tools for grep." - ); - expect(stopNotices).toHaveLength(1); - - const combinedText = events - .filter((event): event is AgentChatEventEnvelope & { event: Extract } => event.event.type === "text") - .map((event) => event.event.text) - .join(""); - expect(combinedText).toContain("I am stopping tool use for this turn because the tool pattern became repetitive."); - }); - - it.skip("stops a local opencode turn when the model rereads the same file range over and over", async () => { - const events: AgentChatEventEnvelope[] = []; - const readFileExecute = vi.fn(async () => ({ - path: path.join(tmpRoot, "apps/web/src/app/pages/HomePage.tsx"), - displayPath: "apps/web/src/app/pages/HomePage.tsx", - content: " 1\timport { HomePage } from './HomePage';", - totalLines: 200, - startLine: 1, - endLine: 10, - })); - - replaceDynamicOpenCodeModelDescriptors([ - createDynamicOpenCodeModelDescriptor("lmstudio/qwen3.5-9b", { - displayName: "qwen3.5-9b", - capabilities: { tools: true, vision: false, reasoning: false, streaming: true }, - openCodeProviderId: "lmstudio", - openCodeModelId: "qwen3.5-9b", - }), - ]); - vi.mocked(createUniversalToolSet).mockImplementation(() => ({ - readFile: { description: "stub", parameters: { type: "object", properties: {} }, execute: readFileExecute }, - }) as any); - vi.mocked(streamText).mockImplementation((args: Record) => ({ - fullStream: (async function* () { - const tools = (args.tools ?? {}) as Record Promise }>; - yield { type: "start-step", stepNumber: 0 }; - for (let index = 1; index <= 8; index += 1) { - const input = { - file_path: path.join(tmpRoot, "apps/web/src/app/pages/HomePage.tsx"), - offset: 1, - limit: 10, - }; - yield { type: "tool-call", toolName: "readFile", toolCallId: `tool-${index}`, input }; - const output = await tools.readFile!.execute!(input); - yield { type: "tool-result", toolName: "readFile", toolCallId: `tool-${index}`, output }; - } - yield { type: "finish", usage: {} }; - })(), - }) as any); - - const { service } = createService({ - onEvent: (event: AgentChatEventEnvelope) => events.push(event), - }); - - const session = await service.createSession({ - laneId: "lane-1", - provider: "opencode", - model: "LM Studio (Auto)", - modelId: "lmstudio/auto", - permissionMode: "plan", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Add a new blank test tab to the website.", - }); - - expect(readFileExecute).toHaveBeenCalledTimes(2); - - const stopNotices = events.filter((event) => - event.event.type === "system_notice" - && event.event.message === "ADE tool policy stop tools for readFile." - ); - expect(stopNotices).toHaveLength(1); - - const combinedText = events - .filter((event): event is AgentChatEventEnvelope & { event: Extract } => event.event.type === "text") - .map((event) => event.event.text) - .join(""); - expect(combinedText).toContain("I am stopping tool use for this turn because the tool pattern became repetitive."); - expect(combinedText).not.toContain("DownloadPage.tsx"); - expect(combinedText).toContain("TodoWrite plan"); - }); - - it.skip("keeps plan mode focused on planning tools after a concrete inspection instead of forcing more broad discovery", async () => { - const observedPolicies: Array> = []; - - replaceDynamicOpenCodeModelDescriptors([ - createDynamicOpenCodeModelDescriptor("lmstudio/qwen3.5-9b", { - displayName: "qwen3.5-9b", - capabilities: { tools: true, vision: false, reasoning: false, streaming: true }, - openCodeProviderId: "lmstudio", - openCodeModelId: "qwen3.5-9b", - }), - ]); - vi.mocked(createUniversalToolSet).mockImplementation(() => ({ - readFile: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - summarizeFrontendStructure: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - TodoWrite: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - TodoRead: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - askUser: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - exitPlanMode: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - grep: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - }) as any); - vi.mocked(streamText).mockImplementation((args: Record) => ({ - fullStream: (async function* () { - if (typeof args.prepareStep === "function") { - observedPolicies.push(await args.prepareStep()); - } - if (typeof args.onStepFinish === "function") { - await args.onStepFinish({ - toolCalls: [{ toolName: "summarizeFrontendStructure", input: { path: tmpRoot, sampleSize: 5 } }], - toolResults: [{ - toolName: "summarizeFrontendStructure", - output: { - routingFiles: [{ path: path.join(tmpRoot, "apps/web/src/app/SiteRoutes.tsx") }], - pageComponents: [ - { path: path.join(tmpRoot, "apps/web/src/app/pages/HomePage.tsx") }, - { path: path.join(tmpRoot, "apps/web/src/app/pages/DownloadPage.tsx") }, - ], - entryPoints: [], - }, - }], - }); - } - if (typeof args.prepareStep === "function") { - observedPolicies.push(await args.prepareStep()); - } - if (typeof args.onStepFinish === "function") { - await args.onStepFinish({ - toolCalls: [{ - toolName: "readFile", - input: { file_path: path.join(tmpRoot, "apps/web/src/app/SiteRoutes.tsx") }, - }], - toolResults: [{ - toolName: "readFile", - output: { - path: path.join(tmpRoot, "apps/web/src/app/SiteRoutes.tsx"), - content: "export function SiteRoutes() {}", - }, - }], - }); - } - if (typeof args.prepareStep === "function") { - observedPolicies.push(await args.prepareStep()); - } - yield { type: "finish", usage: {} }; - })(), - }) as any); - - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "opencode", - model: "LM Studio (Auto)", - modelId: "lmstudio/auto", - permissionMode: "plan", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "Add a blank test tab to the website.", - }); - - expect(observedPolicies).toHaveLength(3); - expect(observedPolicies[1]?.activeTools).toEqual(expect.arrayContaining([ - "readFile", - "summarizeFrontendStructure", - "TodoWrite", - "exitPlanMode", - ])); - expect(observedPolicies[2]?.activeTools).toEqual(expect.arrayContaining([ - "readFile", - "TodoWrite", - "TodoRead", - "askUser", - "exitPlanMode", - ])); - expect(observedPolicies[2]?.activeTools).not.toContain("summarizeFrontendStructure"); - expect(observedPolicies[2]?.activeTools).not.toContain("findRoutingFiles"); - expect(observedPolicies[2]?.activeTools).not.toContain("findPageComponents"); - expect(observedPolicies[2]?.activeTools).not.toContain("findAppEntryPoints"); - }); - - it.skip("does not expose frontend repo tools for non-frontend prompts", async () => { - const observedToolSets: string[][] = []; - - replaceDynamicOpenCodeModelDescriptors([ - createDynamicOpenCodeModelDescriptor("lmstudio/qwen3.5-9b", { - displayName: "qwen3.5-9b", - capabilities: { tools: true, vision: false, reasoning: false, streaming: true }, - openCodeProviderId: "lmstudio", - openCodeModelId: "qwen3.5-9b", - }), - ]); - vi.mocked(createUniversalToolSet).mockImplementation(() => ({ - readFile: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - grep: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - summarizeFrontendStructure: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - findRoutingFiles: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - findPageComponents: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - findAppEntryPoints: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - }) as any); - vi.mocked(streamText).mockImplementation((args: Record) => { - observedToolSets.push(Object.keys((args.tools ?? {}) as Record)); - return { - fullStream: (async function* () { - yield { type: "finish", usage: {} }; - })(), - } as any; - }); - - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "opencode", - model: "LM Studio (Auto)", - modelId: "lmstudio/auto", - permissionMode: "plan", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "fix the sqlite transaction retry bug in the background sync worker", - }); - - expect(observedToolSets).toHaveLength(1); - expect(observedToolSets[0]).toContain("readFile"); - expect(observedToolSets[0]).toContain("grep"); - expect(observedToolSets[0]).not.toContain("summarizeFrontendStructure"); - expect(observedToolSets[0]).not.toContain("findRoutingFiles"); - expect(observedToolSets[0]).not.toContain("findPageComponents"); - expect(observedToolSets[0]).not.toContain("findAppEntryPoints"); - }); - - it.skip("keeps frontend repo tools exposed for clear website prompts", async () => { - const observedToolSets: string[][] = []; - - replaceDynamicOpenCodeModelDescriptors([ - createDynamicOpenCodeModelDescriptor("lmstudio/qwen3.5-9b", { - displayName: "qwen3.5-9b", - capabilities: { tools: true, vision: false, reasoning: false, streaming: true }, - openCodeProviderId: "lmstudio", - openCodeModelId: "qwen3.5-9b", - }), - ]); - vi.mocked(createUniversalToolSet).mockImplementation(() => ({ - readFile: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - grep: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - summarizeFrontendStructure: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - findRoutingFiles: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - findPageComponents: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - findAppEntryPoints: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, - }) as any); - vi.mocked(streamText).mockImplementation((args: Record) => { - observedToolSets.push(Object.keys((args.tools ?? {}) as Record)); - return { - fullStream: (async function* () { - yield { type: "finish", usage: {} }; - })(), - } as any; - }); - - const { service } = createService(); - const session = await service.createSession({ - laneId: "lane-1", - provider: "opencode", - model: "LM Studio (Auto)", - modelId: "lmstudio/auto", - permissionMode: "plan", - }); - - await service.runSessionTurn({ - sessionId: session.id, - text: "can you add a new tab to the website called test, leave it blank just a stub", - }); - - expect(observedToolSets).toHaveLength(1); - expect(observedToolSets[0]).toContain("summarizeFrontendStructure"); - expect(observedToolSets[0]).toContain("findRoutingFiles"); - expect(observedToolSets[0]).toContain("findPageComponents"); - expect(observedToolSets[0]).toContain("findAppEntryPoints"); - }); }); it("emits immediate startup activity before opencode stream output arrives", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ebeb6c7c7..05e62465e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -145,6 +145,7 @@ import { pickDefaultCursorDescriptorFromCliList, pickDefaultDroidDescriptorFromCliList, getRuntimeModelRefForDescriptor, + modelSupportsFastMode, resolveModelAlias, resolveModelDescriptorForProvider, resolveProviderGroupForModel, @@ -301,6 +302,7 @@ type PersistedChatState = { modelId?: string; sessionProfile?: "light" | "workflow"; reasoningEffort?: string | null; + codexFastMode?: boolean; executionMode?: AgentChatExecutionMode | null; interactionMode?: AgentChatInteractionMode | null; claudePermissionMode?: AgentChatClaudePermissionMode; @@ -1257,7 +1259,7 @@ const KNOWN_CLAUDE_EFFORTS = new Set(CLAUDE_REASONING_EFFORTS.map((e) => e.effor function codexModelInfoFromDescriptor( descriptor: ModelDescriptor, - overrides?: Partial>, + overrides?: Partial>, ): AgentChatModelInfo { return { id: descriptor.providerModelId, @@ -1267,6 +1269,11 @@ function codexModelInfoFromDescriptor( reasoningEfforts: overrides?.reasoningEfforts ?? (descriptor.reasoningTiers?.length ? CODEX_REASONING_EFFORTS.filter((effort) => descriptor.reasoningTiers?.includes(effort.effort)) : CODEX_REASONING_EFFORTS), + ...(overrides?.serviceTiers !== undefined + ? { serviceTiers: overrides.serviceTiers } + : descriptor.serviceTiers?.length + ? { serviceTiers: descriptor.serviceTiers } + : {}), modelId: descriptor.id, family: descriptor.family, supportsReasoning: descriptor.capabilities.reasoning, @@ -1301,6 +1308,12 @@ function normalizeReasoningEffort(value: unknown): string | null { return normalized.length > 0 ? normalized : null; } +type CodexServiceTier = "fast"; + +function normalizeCodexFastMode(value: unknown): boolean { + return value === true; +} + function catalogDescriptorInfoKey( group: ModelProviderGroup, providerKey: string, @@ -1368,6 +1381,35 @@ function sessionSupportsReasoning(session: AgentChatSession): boolean { return resolveSessionModelDescriptor(session)?.capabilities.reasoning ?? true; } +function sessionSupportsCodexFastMode(session: AgentChatSession): boolean { + return session.provider === "codex" && modelSupportsFastMode(resolveSessionModelDescriptor(session)); +} + +function codexServiceTierArgs(session: AgentChatSession): { serviceTier: CodexServiceTier | null } { + // JSON-RPC needs an explicit null to clear any app-server/config default. + const serviceTier = session.codexFastMode === true && sessionSupportsCodexFastMode(session) ? "fast" : null; + return { serviceTier }; +} + +function normalizeCodexServiceTier(value: unknown): string | null { + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return normalized.length ? normalized : null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + return normalizeCodexServiceTier(record.id ?? record.tier ?? record.serviceTier); +} + +function normalizeCodexServiceTierList(...values: unknown[]): string[] | undefined { + const tiers = values.flatMap((value) => Array.isArray(value) ? value : []); + const normalized = tiers + .map((entry) => normalizeCodexServiceTier(entry)) + .filter((entry): entry is string => Boolean(entry)); + const deduped = normalized.filter((tier, index, list) => list.indexOf(tier) === index); + return deduped.length ? deduped : undefined; +} + function initialTurnActivity(session: AgentChatSession): { activity: Extract["activity"]; detail: string; @@ -6087,6 +6129,7 @@ export function createAgentChatService(args: { ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), ...(managed.session.sessionProfile ? { sessionProfile: managed.session.sessionProfile } : {}), ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), + ...(managed.session.codexFastMode === true ? { codexFastMode: true } : {}), ...(managed.session.executionMode ? { executionMode: managed.session.executionMode } : {}), ...(managed.session.interactionMode ? { interactionMode: managed.session.interactionMode } : {}), ...(managed.session.claudePermissionMode ? { claudePermissionMode: managed.session.claudePermissionMode } : {}), @@ -6209,6 +6252,7 @@ export function createAgentChatService(args: { : resolveModelIdFromStoredValue(model, provider); const sessionProfile = normalizeSessionProfile(record.sessionProfile); const reasoningEffort = normalizeReasoningEffort(record.reasoningEffort); + const codexFastMode = normalizeCodexFastMode(record.codexFastMode); const executionMode = normalizePersistedExecutionMode(record.executionMode); const permissionMode = normalizePersistedPermissionMode(record.permissionMode); const claudePermissionMode = normalizePersistedClaudePermissionMode(record.claudePermissionMode); @@ -6301,6 +6345,7 @@ export function createAgentChatService(args: { ...(modelId ? { modelId } : {}), ...(sessionProfile ? { sessionProfile } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), + ...(codexFastMode ? { codexFastMode: true } : {}), ...(executionMode ? { executionMode } : {}), ...(interactionMode ? { interactionMode } : {}), ...(claudePermissionMode ? { claudePermissionMode } : {}), @@ -7215,6 +7260,7 @@ export function createAgentChatService(args: { ...(hydratedModelId ? { modelId: hydratedModelId } : {}), ...(persisted?.sessionProfile ? { sessionProfile: persisted.sessionProfile } : {}), reasoningEffort: persisted?.reasoningEffort ?? null, + codexFastMode: persisted?.codexFastMode === true, executionMode: persisted?.executionMode ?? null, interactionMode: persisted?.interactionMode ?? null, ...(persisted?.claudePermissionMode ? { claudePermissionMode: persisted.claudePermissionMode } : {}), @@ -7496,6 +7542,7 @@ export function createAgentChatService(args: { input, model: managed.session.model, ...(managed.session.reasoningEffort ? { effort: managed.session.reasoningEffort } : {}), + ...codexServiceTierArgs(managed.session), ...codexTurnPolicyArgs(codexPolicy), ...(collaborationMode ? { collaborationMode } : {}), }); @@ -10979,6 +11026,7 @@ export function createAgentChatService(args: { model: managed.session.model, cwd: managed.laneWorktreePath, reasoningEffort, + ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), experimentalRawEvents: false, persistExtendedHistory: true @@ -11775,13 +11823,18 @@ export function createAgentChatService(args: { : undefined; const normalizedEfforts = reasoningEfforts?.length ? reasoningEfforts : CODEX_REASONING_EFFORTS; + const serviceTiers = normalizeCodexServiceTierList( + row.additionalSpeedTiers, + row.serviceTiers, + ); return { id, displayName, ...(description ? { description } : {}), isDefault, - reasoningEfforts: normalizedEfforts + reasoningEfforts: normalizedEfforts, + ...(serviceTiers ? { serviceTiers } : {}) } satisfies AgentChatModelInfo; }) .filter((entry): entry is AgentChatModelInfo => entry != null); @@ -11808,6 +11861,7 @@ export function createAgentChatService(args: { reasoningEfforts: appServerEntry?.reasoningEfforts?.length ? appServerEntry.reasoningEfforts : undefined, + serviceTiers: appServerEntry?.serviceTiers, }); }); @@ -11892,6 +11946,7 @@ export function createAgentChatService(args: { title, sessionProfile, reasoningEffort, + codexFastMode: requestedCodexFastMode, interactionMode: requestedInteractionMode, claudePermissionMode: requestedClaudePermissionMode, codexApprovalPolicy: requestedCodexApprovalPolicy, @@ -12105,6 +12160,7 @@ export function createAgentChatService(args: { ...(resolvedModelId ? { modelId: resolvedModelId } : {}), sessionProfile: sessionProfile ?? "workflow", ...(normalizedReasoningEffort ? { reasoningEffort: normalizedReasoningEffort } : {}), + ...(effectiveProvider === "codex" && requestedCodexFastMode === true ? { codexFastMode: true } : {}), ...nativePermissionFields, ...(effectivePermissionMode ? { permissionMode: effectivePermissionMode } : {}), ...(identityKey ? { identityKey } : {}), @@ -12251,7 +12307,9 @@ export function createAgentChatService(args: { modelId: targetDescriptor.id, sessionProfile: managed.session.sessionProfile, reasoningEffort: targetReasoningEffort, - interactionMode: managed.session.interactionMode, + codexFastMode: targetProvider === "codex" + ? args.codexFastMode ?? managed.session.codexFastMode === true + : undefined, claudePermissionMode: args.claudePermissionMode ?? managed.session.claudePermissionMode, codexApprovalPolicy: args.codexApprovalPolicy ?? managed.session.codexApprovalPolicy, codexSandbox: args.codexSandbox ?? managed.session.codexSandbox, @@ -12268,7 +12326,6 @@ export function createAgentChatService(args: { const createdManaged = ensureManagedSession(created.id); createdManaged.session.executionMode = managed.session.executionMode ?? sourceSession.executionMode ?? null; - createdManaged.session.interactionMode = managed.session.interactionMode ?? sourceSession.interactionMode ?? null; const inheritedGoal = trimLine(sourceSession.goal) ?? trimLine(sourceSession.summary) ?? trimLine(sourceSession.title); @@ -15448,6 +15505,7 @@ export function createAgentChatService(args: { model: managed.session.model, cwd: managed.laneWorktreePath, reasoningEffort: resumeReasoningEffort, + ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); @@ -16304,6 +16362,7 @@ export function createAgentChatService(args: { model: managed.session.model, cwd: managed.laneWorktreePath, reasoningEffort: managed.session.reasoningEffort, + ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), persistExtendedHistory: true }); @@ -16461,6 +16520,7 @@ export function createAgentChatService(args: { title: row.title ?? null, goal: row.goal ?? null, reasoningEffort: liveSession?.reasoningEffort ?? persisted?.reasoningEffort ?? null, + codexFastMode: (liveSession?.codexFastMode ?? persisted?.codexFastMode) === true, executionMode: liveSession?.executionMode ?? persisted?.executionMode ?? null, interactionMode: liveSession?.interactionMode ?? persisted?.interactionMode ?? null, ...(liveSession?.claudePermissionMode || persisted?.claudePermissionMode @@ -17090,6 +17150,7 @@ export function createAgentChatService(args: { family: descriptor.family, supportsReasoning: descriptor.capabilities.reasoning, supportsTools: descriptor.capabilities.tools, + ...(descriptor.serviceTiers?.length ? { serviceTiers: descriptor.serviceTiers } : {}), color: descriptor.color, }); firstRow = false; @@ -17122,6 +17183,7 @@ export function createAgentChatService(args: { family: m.family, supportsReasoning: m.capabilities.reasoning, supportsTools: m.capabilities.tools, + ...(m.serviceTiers?.length ? { serviceTiers: m.serviceTiers } : {}), color: m.color, })); } @@ -17191,6 +17253,11 @@ export function createAgentChatService(args: { ...(typeof info.supportsTools === "boolean" ? { tools: info.supportsTools } : {}), }, ...(runtimeTiers?.length ? { reasoningTiers: runtimeTiers } : {}), + ...(info.serviceTiers !== undefined + ? { serviceTiers: info.serviceTiers } + : descriptor.serviceTiers?.length + ? { serviceTiers: descriptor.serviceTiers } + : {}), }; descriptors.push(patched); descriptorInfo.set(catalogDescriptorInfoKey(provider, patched.family, patched.id), { provider, info }); @@ -17240,6 +17307,11 @@ export function createAgentChatService(args: { family: descriptor.family, supportsReasoning: descriptor.capabilities.reasoning, supportsTools: descriptor.capabilities.tools, + ...(entry?.info.serviceTiers !== undefined + ? { serviceTiers: entry.info.serviceTiers } + : descriptor.serviceTiers?.length + ? { serviceTiers: descriptor.serviceTiers } + : {}), color: descriptor.color, isAvailable: Boolean(entry), }; @@ -17487,6 +17559,7 @@ export function createAgentChatService(args: { manuallyNamed, modelId, reasoningEffort, + codexFastMode, interactionMode, claudePermissionMode, codexApprovalPolicy, @@ -17506,6 +17579,7 @@ export function createAgentChatService(args: { const prevCodexApprovalPolicy = managed.session.codexApprovalPolicy; const prevCodexSandbox = managed.session.codexSandbox; const prevCodexConfigSource = managed.session.codexConfigSource; + const prevCodexFastMode = managed.session.codexFastMode === true; if (modelId !== undefined) { const nextModelId = String(modelId ?? "").trim(); @@ -17554,6 +17628,9 @@ export function createAgentChatService(args: { managed.session.provider = nextProvider; managed.session.modelId = descriptor.id; managed.session.model = nextModel; + if (nextProvider !== "codex") { + delete managed.session.codexFastMode; + } managed.session.capabilityMode = inferCapabilityMode(nextProvider); if (previousProvider !== nextProvider || previousProvider === "codex") { delete managed.session.threadId; @@ -17673,6 +17750,14 @@ export function createAgentChatService(args: { managed.session.codexConfigSource = codexConfigSource; } + if (codexFastMode !== undefined) { + if (managed.session.provider === "codex" && normalizeCodexFastMode(codexFastMode)) { + managed.session.codexFastMode = true; + } else { + delete managed.session.codexFastMode; + } + } + if (opencodePermissionMode !== undefined && !identityPinned) { managed.session.opencodePermissionMode = opencodePermissionMode; } @@ -17754,6 +17839,13 @@ export function createAgentChatService(args: { await ensureDroidSessionState(managed, managed.runtime); } } + if ( + codexFastMode !== undefined + && managed.runtime?.kind === "codex" + && (managed.session.codexFastMode === true) !== prevCodexFastMode + ) { + managed.runtime.threadResumed = false; + } if (title !== undefined) { const normalizedTitle = String(title ?? "").trim(); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 3a9154bc2..e6a6470c7 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -71,6 +71,7 @@ import { AUTOMATION_TRIGGER_TYPES, NO_DEFAULT_LANE_TEMPLATE } from "../../../sha import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import { isRecord, resolvePathWithinRoot } from "../shared/utils"; +import { ensureSharedAdeProjectScaffold, initializeOrRepairAdeProject } from "../projects/adeProjectService"; const TRUSTED_SHARED_HASH_KEY = "project_config:trusted_shared_hash"; const VERSION = 1; @@ -2073,6 +2074,28 @@ function hashContent(content: string): string { return createHash("sha256").update(content).digest("hex"); } +function hasSharedConfigContent(config: ProjectConfigFile): boolean { + return Boolean( + config.project + || (config.processes?.length ?? 0) > 0 + || (config.processGroups?.length ?? 0) > 0 + || (config.stackButtons?.length ?? 0) > 0 + || (config.testSuites?.length ?? 0) > 0 + || (config.laneOverlayPolicies?.length ?? 0) > 0 + || (config.automations?.length ?? 0) > 0 + || (config.environments?.length ?? 0) > 0 + || config.github + || config.git + || config.ai + || config.laneEnvInit + || (config.laneTemplates?.length ?? 0) > 0 + || config.defaultLaneTemplate + || (config.providers && Object.keys(config.providers).length > 0) + || config.linearSync + || config.notifications + ); +} + function createDefId(projectId: string, key: string): string { return `${projectId}:${key}`; } @@ -3195,13 +3218,23 @@ export function createProjectConfigService({ const sharedYaml = toCanonicalYaml(shared); const localYaml = toCanonicalYaml(local); + const shouldWriteShared = fs.existsSync(sharedPath) || hasSharedConfigContent(shared); + if (shouldWriteShared) { + ensureSharedAdeProjectScaffold(projectRoot, { logger }); + } else { + initializeOrRepairAdeProject(projectRoot, { logger }); + } fs.mkdirSync(path.dirname(sharedPath), { recursive: true }); - fs.writeFileSync(sharedPath, sharedYaml, "utf8"); + if (shouldWriteShared) { + fs.writeFileSync(sharedPath, sharedYaml, "utf8"); + } fs.writeFileSync(localPath, localYaml, "utf8"); - const sharedHash = hashContent(sharedYaml); - setTrustedSharedHash(sharedHash); + const sharedHash = hashContent(shouldWriteShared ? sharedYaml : ""); + if (shouldWriteShared) { + setTrustedSharedHash(sharedHash); + } logger.info("projectConfig.save", { sharedPath, diff --git a/apps/desktop/src/main/services/cto/ctoState.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts index a515819ff..aa961aa3c 100644 --- a/apps/desktop/src/main/services/cto/ctoState.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -58,10 +58,10 @@ describe("ctoStateService", () => { expect(fs.existsSync(path.join(fixture.adeDir, "cto", "MEMORY.md"))).toBe(true); expect(fs.existsSync(path.join(fixture.adeDir, "cto", "CURRENT.md"))).toBe(true); expect(fs.existsSync(path.join(fixture.adeDir, "cto", "sessions.jsonl"))).toBe(false); - expect(buildAdeGitignore()).not.toContain("cto/identity.yaml"); - expect(buildAdeGitignore()).toContain("cto/core-memory.json"); - expect(buildAdeGitignore()).toContain("cto/CURRENT.md"); - expect(buildAdeGitignore()).toContain("cto/openclaw-history.json"); + expect(buildAdeGitignore()).toContain("!cto/identity.yaml"); + expect(buildAdeGitignore()).not.toContain("cto/core-memory.json"); + expect(buildAdeGitignore()).not.toContain("cto/CURRENT.md"); + expect(buildAdeGitignore()).not.toContain("cto/openclaw-history.json"); fixture.db.close(); }); diff --git a/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts b/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts index 5e3bc5bad..95c4b6829 100644 --- a/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts +++ b/apps/desktop/src/main/services/cto/linearWorkflowFileService.ts @@ -13,6 +13,7 @@ import type { } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import { createDefaultLinearWorkflowConfig, createWorkflowPreset } from "../../../shared/linearWorkflowPresets"; +import { ensureSharedAdeProjectScaffold } from "../projects/adeProjectService"; import { isRecord } from "../shared/utils"; const WORKFLOW_VERSION = 1 as const; @@ -532,6 +533,7 @@ export function createLinearWorkflowFileService(args: { }; const save = (config: LinearWorkflowConfig): LinearWorkflowConfig => { + ensureSharedAdeProjectScaffold(args.projectRoot); fs.mkdirSync(workflowDir, { recursive: true }); fs.mkdirSync(cacheDir, { recursive: true }); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index d4510c659..ae51b91df 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -55,6 +55,7 @@ import type { BuiltInBrowserBoundsArgs, BuiltInBrowserCreateTabArgs, BuiltInBrowserNavigateArgs, + BuiltInBrowserOpenPanelArgs, BuiltInBrowserSelectPointArgs, BuiltInBrowserTabArgs, ReviewListRunsArgs, @@ -1810,6 +1811,9 @@ export function registerIpc({ [IPC.appControlDispatchKey]: new Set(["text", "unmodifiedText", "key", "code"]), [IPC.terminalWrite]: new Set(["data"]), [IPC.ptyWrite]: new Set(["data"]), + [IPC.builtInBrowserNavigate]: new Set(["url"]), + [IPC.builtInBrowserCreateTab]: new Set(["url"]), + [IPC.builtInBrowserShowPanel]: new Set(["url"]), }; const redactIpcArgsForChannel = (channel: string, args: unknown[]): unknown[] => { @@ -2290,7 +2294,8 @@ export function registerIpc({ } const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); const newTab = record.newTab === true ? true : undefined; - return { url, tabId, newTab }; + const openPanel = optionalBoolean(record.openPanel); + return { url, tabId, newTab, openPanel }; }; function optionalBuiltInBrowserString( @@ -2308,18 +2313,33 @@ export function registerIpc({ return trimmed; } + function optionalBoolean(value: unknown): boolean | undefined { + if (value === true) return true; + if (value === false) return false; + return undefined; + } + const parseBuiltInBrowserTabArgs = (value: unknown, channel: string): BuiltInBrowserTabArgs => { const record = builtInBrowserRecord(value, channel, true); const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); if (!tabId) return invalidBuiltInBrowserArg(channel, "tabId must be a non-empty string"); - return { tabId }; + const openPanel = optionalBoolean(record.openPanel); + return { tabId, openPanel }; }; const parseBuiltInBrowserCreateTabArgs = (value: unknown, channel: string): BuiltInBrowserCreateTabArgs => { const record = builtInBrowserRecord(value, channel, false); const url = optionalBuiltInBrowserString(record, "url", channel, 4096); const activate = record.activate === false ? false : undefined; - return { url, activate }; + const openPanel = optionalBoolean(record.openPanel); + return { url, activate, openPanel }; + }; + + const parseBuiltInBrowserOpenPanelArgs = (value: unknown, channel: string): BuiltInBrowserOpenPanelArgs => { + const record = builtInBrowserRecord(value, channel, false); + const url = optionalBuiltInBrowserString(record, "url", channel, 4096); + const tabId = optionalBuiltInBrowserString(record, "tabId", channel, 128); + return { url, tabId }; }; const parseBuiltInBrowserSelectPointArgs = (value: unknown, channel: string): BuiltInBrowserSelectPointArgs => { @@ -6542,6 +6562,11 @@ export function registerIpc({ return ensureBuiltInBrowser().getStatus(); }); + ipcMain.handle(IPC.builtInBrowserShowPanel, async (event, arg) => { + guardBuiltInBrowserIpc(event, IPC.builtInBrowserShowPanel, { windowMs: 10_000, max: 80 }); + return ensureBuiltInBrowser().showPanel(parseBuiltInBrowserOpenPanelArgs(arg, IPC.builtInBrowserShowPanel)); + }); + ipcMain.handle(IPC.builtInBrowserSetBounds, async (event, arg) => { guardBuiltInBrowserIpc(event, IPC.builtInBrowserSetBounds, { windowMs: 10_000, max: 900 }); return ensureBuiltInBrowser().setBounds(parseBuiltInBrowserBoundsArgs(arg, IPC.builtInBrowserSetBounds)); diff --git a/apps/desktop/src/main/services/projects/adeProjectService.ts b/apps/desktop/src/main/services/projects/adeProjectService.ts index 43127817c..8f89c4af3 100644 --- a/apps/desktop/src/main/services/projects/adeProjectService.ts +++ b/apps/desktop/src/main/services/projects/adeProjectService.ts @@ -15,6 +15,7 @@ import { createLogIntegrityService, type LogIntegrityService } from "./logIntegr type RepairOptions = { logger?: Logger | null; + mode?: "auto" | "local" | "shared"; }; type AdeProjectServiceArgs = { @@ -88,6 +89,30 @@ const TRACKED_PLACEHOLDER_PATHS = [ path.join("skills", ".gitkeep"), ]; +const SHARED_SCAFFOLD_FILES = [ + ".gitignore", + "ade.yaml", +]; + +const SHARED_SCAFFOLD_DIRS = [ + "templates", + "skills", + path.join("workflows", "linear"), + "project-icons", +]; + +function isAdeRootIgnoreRule(line: string): boolean { + const trimmed = line.trim(); + return trimmed === ".ade" + || trimmed === ".ade/" + || trimmed === ".ade/*" + || trimmed === ".ade/**" + || trimmed === "/.ade" + || trimmed === "/.ade/" + || trimmed === "/.ade/*" + || trimmed === "/.ade/**"; +} + function normalizeAdeRelativePath(value: string): string { return value.replace(/\\/g, "/").replace(/^\.?\//, ""); } @@ -99,7 +124,8 @@ function isTrackedAdeFile(relativePath: string): boolean { || normalized === "cto/identity.yaml" || normalized.startsWith("templates/") || normalized.startsWith("skills/") - || normalized.startsWith("workflows/"); + || normalized.startsWith("workflows/") + || normalized.startsWith("project-icons/"); } function walkFiles(rootPath: string): string[] { @@ -114,6 +140,44 @@ function walkFiles(rootPath: string): string[] { return out; } +function hasAnyFile(rootPath: string): boolean { + if (!fs.existsSync(rootPath)) return false; + const stat = fs.statSync(rootPath); + if (stat.isFile()) return true; + if (!stat.isDirectory()) return false; + for (const entry of fs.readdirSync(rootPath, { withFileTypes: true })) { + if (hasAnyFile(path.join(rootPath, entry.name))) return true; + } + return false; +} + +function hasSharedAdeScaffold(paths: AdeLayoutPaths): boolean { + for (const relativePath of SHARED_SCAFFOLD_FILES) { + if (fs.existsSync(path.join(paths.adeDir, relativePath))) return true; + } + for (const relativePath of SHARED_SCAFFOLD_DIRS) { + if (hasAnyFile(path.join(paths.adeDir, relativePath))) return true; + } + return false; +} + +function resolveGitDir(projectRoot: string): string | null { + const gitPath = path.join(projectRoot, ".git"); + if (!fs.existsSync(gitPath)) return null; + const stat = fs.statSync(gitPath); + if (stat.isDirectory()) return gitPath; + if (!stat.isFile()) return null; + const raw = fs.readFileSync(gitPath, "utf8"); + const match = raw.match(/^gitdir:\s*(.+)\s*$/im); + if (!match?.[1]) return null; + return path.resolve(projectRoot, match[1]); +} + +function resolveGitInfoExcludePath(projectRoot: string): string | null { + const gitDir = resolveGitDir(projectRoot); + return gitDir ? path.join(gitDir, "info", "exclude") : null; +} + function validateYamlDocument(filePath: string, requiredKeys: string[]): string | null { try { const parsed = YAML.parse(fs.readFileSync(filePath, "utf8")) as Record; @@ -128,16 +192,13 @@ function validateYamlDocument(filePath: string, requiredKeys: string[]): string } function scrubAdeExcludeRule(projectRoot: string): AdeSyncAction | null { - const gitDir = path.join(projectRoot, ".git"); - const excludePath = path.join(gitDir, "info", "exclude"); + const excludePath = resolveGitInfoExcludePath(projectRoot); + if (!excludePath) return null; if (!fs.existsSync(excludePath)) return null; const raw = fs.readFileSync(excludePath, "utf8"); const nextLines = raw .split(/\r?\n/) - .filter((line) => { - const trimmed = line.trim(); - return trimmed !== ".ade/" && trimmed !== ".ade"; - }); + .filter((line) => !isAdeRootIgnoreRule(line)); while (nextLines.length > 0 && nextLines[nextLines.length - 1]?.trim() === "") { nextLines.pop(); } @@ -147,16 +208,28 @@ function scrubAdeExcludeRule(projectRoot: string): AdeSyncAction | null { return { kind: "scrub_exclude", relativePath: ".git/info/exclude", detail: "Removed stale .ade ignore rule." }; } +function ensureAdeLocalExcludeRule(projectRoot: string): AdeSyncAction | null { + const excludePath = resolveGitInfoExcludePath(projectRoot); + if (!excludePath) return null; + const raw = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; + if (raw.split(/\r?\n/).some(isAdeRootIgnoreRule)) return null; + fs.mkdirSync(path.dirname(excludePath), { recursive: true }); + const separator = raw.length > 0 && !raw.endsWith("\n") ? "\n" : ""; + fs.writeFileSync(excludePath, `${raw}${separator}.ade/\n`, "utf8"); + return { + kind: "reconcile", + relativePath: ".git/info/exclude", + detail: "Ignored local-only ADE runtime state.", + }; +} + function scrubAdeRootGitignoreRule(projectRoot: string): AdeSyncAction | null { const gitignorePath = path.join(projectRoot, ".gitignore"); if (!fs.existsSync(gitignorePath)) return null; const raw = fs.readFileSync(gitignorePath, "utf8"); const nextLines = raw .split(/\r?\n/) - .filter((line) => { - const trimmed = line.trim(); - return trimmed !== ".ade" && trimmed !== ".ade/" && trimmed !== "/.ade" && trimmed !== "/.ade/"; - }); + .filter((line) => !isAdeRootIgnoreRule(line)); while (nextLines.length > 0 && nextLines[nextLines.length - 1]?.trim() === "") { nextLines.pop(); } @@ -256,24 +329,33 @@ export function initializeOrRepairAdeProject(projectRoot: string, options: Repai const paths = resolveAdeLayout(projectRoot); const actions: AdeSyncAction[] = []; fs.mkdirSync(paths.adeDir, { recursive: true }); - - const scrubAction = scrubAdeExcludeRule(projectRoot); - if (scrubAction) actions.push(scrubAction); - const scrubRootGitignoreAction = scrubAdeRootGitignoreRule(projectRoot); - if (scrubRootGitignoreAction) actions.push(scrubRootGitignoreAction); + const mode = options.mode ?? "auto"; + const shouldRepairSharedScaffold = mode === "shared" || (mode === "auto" && hasSharedAdeScaffold(paths)); + + if (shouldRepairSharedScaffold) { + const scrubAction = scrubAdeExcludeRule(projectRoot); + if (scrubAction) actions.push(scrubAction); + const scrubRootGitignoreAction = scrubAdeRootGitignoreRule(projectRoot); + if (scrubRootGitignoreAction) actions.push(scrubRootGitignoreAction); + } else { + const localExcludeAction = ensureAdeLocalExcludeRule(projectRoot); + if (localExcludeAction) actions.push(localExcludeAction); + } for (const entry of ADE_LAYOUT_DEFINITIONS) { const absolutePath = path.join(paths.adeDir, entry.relativePath); - if (entry.pathType === "directory") { + if (entry.pathType === "directory" && (shouldRepairSharedScaffold || entry.kind === "ignored")) { ensureDir(absolutePath, entry.relativePath, actions); } } - ensureFile(path.join(paths.adeDir, ".gitignore"), buildAdeGitignore(), ".gitignore", actions); - ensureFileIfMissing(paths.sharedConfigPath, DEFAULT_ADE_CONFIG, "ade.yaml", actions); - ensureFileIfMissing(path.join(paths.ctoDir, "identity.yaml"), DEFAULT_CTO_IDENTITY, "cto/identity.yaml", actions); - for (const relativePath of TRACKED_PLACEHOLDER_PATHS) { - ensureFileIfMissing(path.join(paths.adeDir, relativePath), "", relativePath, actions); + if (shouldRepairSharedScaffold) { + ensureFile(path.join(paths.adeDir, ".gitignore"), buildAdeGitignore(), ".gitignore", actions); + ensureFileIfMissing(paths.sharedConfigPath, DEFAULT_ADE_CONFIG, "ade.yaml", actions); + ensureFileIfMissing(path.join(paths.ctoDir, "identity.yaml"), DEFAULT_CTO_IDENTITY, "cto/identity.yaml", actions); + for (const relativePath of TRACKED_PLACEHOLDER_PATHS) { + ensureFileIfMissing(path.join(paths.adeDir, relativePath), "", relativePath, actions); + } } repairLegacyPaths(paths, actions); @@ -304,6 +386,10 @@ export function initializeOrRepairAdeProject(projectRoot: string, options: Repai }; } +export function ensureSharedAdeProjectScaffold(projectRoot: string, options: RepairOptions = {}): AdeCleanupResult { + return initializeOrRepairAdeProject(projectRoot, { ...options, mode: "shared" }).cleanup; +} + export function createAdeProjectService(args: AdeProjectServiceArgs) { const repair = initializeOrRepairAdeProject(args.projectRoot, { logger: args.logger }); const logIntegrityService: LogIntegrityService = createLogIntegrityService({ logger: args.logger }); @@ -334,7 +420,9 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { const validateIdentityFiles = (): AdeHealthIssue[] => { const issues: AdeHealthIssue[] = []; - const ctoIdentityError = validateYamlDocument(path.join(repair.paths.ctoDir, "identity.yaml"), ["name", "updatedAt"]); + const identityPath = path.join(repair.paths.ctoDir, "identity.yaml"); + if (!fs.existsSync(identityPath)) return issues; + const ctoIdentityError = validateYamlDocument(identityPath, ["name", "updatedAt"]); if (ctoIdentityError) { issues.push({ code: "cto-identity-invalid", @@ -438,7 +526,7 @@ export function createAdeProjectService(args: AdeProjectServiceArgs) { return { paths: repair.paths, getSnapshot, - initializeOrRepair: () => initializeOrRepairAdeProject(args.projectRoot, { logger: args.logger }).cleanup, + initializeOrRepair: () => initializeOrRepairAdeProject(args.projectRoot, { logger: args.logger, mode: "shared" }).cleanup, runIntegrityCheck, logIntegrityService, }; diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts index 85c60d67c..8ec00db32 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts @@ -11,6 +11,8 @@ import { setProjectIconOverrideFromSelection, } from "./projectIconResolver"; +const OVER_ICON_LIMIT_BYTES = 10 * 1024 * 1024 + 1; + function makeProjectRoot(): string { // Resolve through realpath so the assertions still hold on platforms // (macOS) where the system tmpdir is itself a symlink (e.g. `/var` -> @@ -81,7 +83,7 @@ describe("projectIconResolver", () => { images: [{ filename: "icon.png", idiom: "universal", size: "1024x1024" }], info: { author: "xcode", version: 1 }, })); - writeFile(root, "apps/ios/ADE/Assets.xcassets/AppIcon.appiconset/icon.png", Buffer.alloc(1024 * 1024 + 1)); + writeFile(root, "apps/ios/ADE/Assets.xcassets/AppIcon.appiconset/icon.png", Buffer.alloc(OVER_ICON_LIMIT_BYTES)); writeFile(root, "apps/ios/ADE/Assets.xcassets/BrandMark.imageset/Contents.json", JSON.stringify({ images: [{ filename: "logo.png", idiom: "universal", scale: "1x" }], info: { author: "xcode", version: 1 }, @@ -94,7 +96,7 @@ describe("projectIconResolver", () => { it("skips overlarge source-linked icons during auto-detection", () => { const root = makeProjectRoot(); writeFile(root, "index.html", ''); - writeFile(root, "public/brand/logo.png", Buffer.alloc(1024 * 1024 + 1)); + writeFile(root, "public/brand/logo.png", Buffer.alloc(OVER_ICON_LIMIT_BYTES)); expect(resolveProjectIconPath(root)).toBeNull(); }); @@ -132,9 +134,9 @@ describe("projectIconResolver", () => { it("rejects selected icons that are too large to render", () => { const root = makeProjectRoot(); - const iconPath = writeFile(root, "assets/icon.png", Buffer.alloc(1024 * 1024 + 1)); + const iconPath = writeFile(root, "assets/icon.png", Buffer.alloc(OVER_ICON_LIMIT_BYTES)); - expect(() => setProjectIconOverride(root, iconPath)).toThrow("Project icon must be 1 MB or smaller."); + expect(() => setProjectIconOverride(root, iconPath)).toThrow("Project icon must be 10 MB or smaller."); }); it("can explicitly disable automatic icon detection", () => { diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.ts b/apps/desktop/src/main/services/projects/projectIconResolver.ts index df362442d..36fe4a0cb 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.ts @@ -5,8 +5,10 @@ import YAML from "yaml"; import type { ProjectIcon } from "../../../shared/types"; import { isWithinDir, resolvePathWithinRoot } from "../shared/utils"; +import { ensureSharedAdeProjectScaffold } from "./adeProjectService"; -const ICON_MAX_BYTES = 1024 * 1024; +const ICON_MAX_BYTES = 10 * 1024 * 1024; +const ICON_MAX_LABEL = "10 MB"; const SUPPORTED_ICON_EXTENSIONS = new Set([".svg", ".ico", ".png", ".jpg", ".jpeg", ".webp"]); const IMPORTED_PROJECT_ICON_DIR = ".ade/project-icons"; @@ -583,6 +585,7 @@ function mimeTypeForIconPath(filePath: string): string | null { } function writeProjectIconPathOverride(projectRoot: string, iconPath: string | null): void { + ensureSharedAdeProjectScaffold(projectRoot); const sharedConfigPath = path.join(projectRoot, ".ade", "ade.yaml"); let config: Record = {}; try { @@ -636,7 +639,7 @@ function assertUsableProjectIconFile(iconPath: string): void { throw new Error("Project icon must be an ico, jpg, png, svg, or webp file."); } if (stat.size > ICON_MAX_BYTES) { - throw new Error("Project icon must be 1 MB or smaller."); + throw new Error(`Project icon must be ${ICON_MAX_LABEL} or smaller.`); } } @@ -666,7 +669,7 @@ export function setProjectIconOverrideFromSelection(projectRoot: string, iconPat const data = fs.readFileSync(selectedPath); // TOCTOU safety net: file may have grown between assertUsableProjectIconFile's stat and this read. if (data.length > ICON_MAX_BYTES) { - throw new Error("Project icon must be 1 MB or smaller."); + throw new Error(`Project icon must be ${ICON_MAX_LABEL} or smaller.`); } const relativeImportPath = importedProjectIconRelativePath(selectedPath, data); const importDir = resolvePathWithinRoot(root, IMPORTED_PROJECT_ICON_DIR, { allowMissing: true }); diff --git a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts index 6d33c75c4..2108c2f6a 100644 --- a/apps/desktop/src/main/services/projects/projectLifecycle.test.ts +++ b/apps/desktop/src/main/services/projects/projectLifecycle.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildAdeGitignore, resolveAdeLayout } from "../../../shared/adeLayout"; @@ -9,6 +10,7 @@ import { browseProjectDirectories } from "./projectBrowserService"; import { __internal, getProjectDetail } from "./projectDetailService"; import { inspectRecentProject, toRecentProjectSummary } from "./recentProjectSummary"; import { openKvDb } from "../state/kvDb"; +import { createProjectConfigService } from "../config/projectConfigService"; const { parseLastCommitLine, parseAheadBehind } = __internal; @@ -46,6 +48,21 @@ function createLogger() { } as any; } +function makeProjectConfigDb() { + const store = new Map(); + return { + getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), + setJson: vi.fn((key: string, value: unknown) => { + store.set(key, value); + }), + run: vi.fn(), + } as any; +} + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + function insertProject( db: Awaited>, projectId: string, @@ -133,22 +150,21 @@ describe("initializeOrRepairAdeProject", () => { return root; } - it("creates the canonical layout, scrubs stale git excludes, and rehomes legacy state", () => { + it("creates the canonical shared layout, scrubs stale git excludes, and rehomes legacy state when requested", () => { const root = createRepoFixture(); const layout = resolveAdeLayout(root); - const result = initializeOrRepairAdeProject(root); + const result = initializeOrRepairAdeProject(root, { mode: "shared" }); expect(result.cleanup.changed).toBe(true); expect(fs.readFileSync(path.join(root, ".gitignore"), "utf8")).not.toContain("/.ade"); expect(fs.readFileSync(path.join(root, ".git", "info", "exclude"), "utf8")).not.toContain(".ade/"); const adeGitignore = fs.readFileSync(path.join(layout.adeDir, ".gitignore"), "utf8"); expect(adeGitignore).toBe(buildAdeGitignore()); - expect(adeGitignore).toContain("cto/core-memory.json"); - expect(adeGitignore).toContain("context/"); - expect(adeGitignore).toContain("agents/"); - expect(adeGitignore).toContain("cto/openclaw-history.json"); - expect(adeGitignore).not.toContain("cto/identity.yaml"); + expect(adeGitignore).toContain("*"); + expect(adeGitignore).toContain("!cto/identity.yaml"); + expect(adeGitignore).toContain("!workflows/linear/**"); + expect(adeGitignore).toContain("!project-icons/**"); expect(fs.readFileSync(path.join(layout.adeDir, "ade.yaml"), "utf8")).toContain("version: 1"); expect(fs.readFileSync(path.join(layout.ctoDir, "identity.yaml"), "utf8")).toContain("name: CTO"); expect(fs.existsSync(path.join(layout.templatesDir, ".gitkeep"))).toBe(true); @@ -166,7 +182,7 @@ describe("initializeOrRepairAdeProject", () => { it("is idempotent once the canonical structure is in place", () => { const root = createRepoFixture(); - initializeOrRepairAdeProject(root); + initializeOrRepairAdeProject(root, { mode: "shared" }); const second = initializeOrRepairAdeProject(root); expect(second.cleanup.changed).toBe(false); @@ -179,10 +195,146 @@ describe("initializeOrRepairAdeProject", () => { fs.mkdirSync(layout.adeDir, { recursive: true }); fs.writeFileSync(path.join(layout.adeDir, "ade.yaml"), "version: 1\nprocesses:\n - id: keep-me\n", "utf8"); - initializeOrRepairAdeProject(root); + initializeOrRepairAdeProject(root, { mode: "shared" }); expect(fs.readFileSync(path.join(layout.adeDir, "ade.yaml"), "utf8")).toContain("keep-me"); }); + + it("keeps a non-ADE repo local-only on open so Git stays clean", () => { + const root = makeTempDir("ade-project-local-open-"); + git(root, ["init"]); + + const result = initializeOrRepairAdeProject(root); + const layout = resolveAdeLayout(root); + + expect(result.cleanup.changed).toBe(true); + expect(fs.existsSync(layout.sharedConfigPath)).toBe(false); + expect(fs.existsSync(path.join(layout.adeDir, ".gitignore"))).toBe(false); + expect(fs.readFileSync(path.join(root, ".git", "info", "exclude"), "utf8")).toContain(".ade/"); + expect(git(root, ["status", "--porcelain=v1", "--untracked-files=all"]).trim()).toBe(""); + }); + + it("does not promote generated local CTO files on a later open", () => { + const root = makeTempDir("ade-project-local-cto-open-"); + git(root, ["init"]); + const layout = resolveAdeLayout(root); + initializeOrRepairAdeProject(root); + fs.mkdirSync(layout.ctoDir, { recursive: true }); + fs.writeFileSync(path.join(layout.ctoDir, "identity.yaml"), "name: CTO\nupdatedAt: 2026-01-01T00:00:00.000Z\n", "utf8"); + + const second = initializeOrRepairAdeProject(root); + + expect(second.cleanup.actions.some((action) => action.kind === "scrub_exclude")).toBe(false); + expect(fs.existsSync(layout.sharedConfigPath)).toBe(false); + expect(fs.existsSync(path.join(layout.adeDir, ".gitignore"))).toBe(false); + expect(git(root, ["status", "--porcelain=v1", "--untracked-files=all"]).trim()).toBe(""); + }); + + it("repairs shared scaffold automatically when a clone already has ADE config", () => { + const root = makeTempDir("ade-project-shared-open-"); + git(root, ["init"]); + const layout = resolveAdeLayout(root); + fs.mkdirSync(layout.adeDir, { recursive: true }); + fs.writeFileSync(layout.sharedConfigPath, "version: 1\nprocesses: []\n", "utf8"); + + const result = initializeOrRepairAdeProject(root); + + expect(result.cleanup.actions.some((action) => action.relativePath === ".gitignore")).toBe(true); + expect(fs.readFileSync(path.join(layout.adeDir, ".gitignore"), "utf8")).toBe(buildAdeGitignore()); + expect(fs.readFileSync(layout.sharedConfigPath, "utf8")).toContain("version: 1"); + expect(fs.existsSync(path.join(layout.ctoDir, "identity.yaml"))).toBe(true); + }); + + it("promotes local-only ADE state to shared scaffold and removes local excludes", () => { + const root = makeTempDir("ade-project-promote-"); + git(root, ["init"]); + fs.writeFileSync(path.join(root, ".gitignore"), "node_modules/\n/.ade/**\n", "utf8"); + initializeOrRepairAdeProject(root); + fs.appendFileSync(path.join(root, ".git", "info", "exclude"), ".ade/*\n", "utf8"); + + const result = initializeOrRepairAdeProject(root, { mode: "shared" }); + const layout = resolveAdeLayout(root); + + expect(result.cleanup.actions.some((action) => action.kind === "scrub_exclude")).toBe(true); + expect(fs.readFileSync(path.join(root, ".git", "info", "exclude"), "utf8")).not.toContain(".ade"); + expect(fs.readFileSync(path.join(root, ".gitignore"), "utf8")).not.toContain("/.ade"); + expect(fs.existsSync(layout.sharedConfigPath)).toBe(true); + const status = git(root, ["status", "--porcelain=v1", "--untracked-files=all"]); + expect(status).toContain("?? .ade/.gitignore"); + expect(status).toContain("?? .ade/ade.yaml"); + expect(status).toContain("?? .ade/cto/identity.yaml"); + }); + + it("promotes local-only ADE state when shared project config is saved", () => { + const root = makeTempDir("ade-project-config-promote-"); + git(root, ["init"]); + const layout = resolveAdeLayout(root); + initializeOrRepairAdeProject(root); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir: layout.adeDir, + projectId: "project-config-promote", + db: makeProjectConfigDb(), + logger: createLogger(), + }); + + service.save({ + shared: { + version: 1, + processes: [{ id: "dev", name: "Dev", command: ["npm", "run", "dev"], cwd: "." }], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + local: {}, + }); + + expect(fs.readFileSync(path.join(root, ".git", "info", "exclude"), "utf8")).not.toContain(".ade/"); + const status = git(root, ["status", "--porcelain=v1", "--untracked-files=all"]); + expect(status).toContain("?? .ade/.gitignore"); + expect(status).toContain("?? .ade/ade.yaml"); + expect(status).not.toContain(".ade/local.yaml"); + }); + + it("keeps local-only project config saves out of Git", () => { + const root = makeTempDir("ade-project-local-config-"); + git(root, ["init"]); + const layout = resolveAdeLayout(root); + initializeOrRepairAdeProject(root); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir: layout.adeDir, + projectId: "project-local-config", + db: makeProjectConfigDb(), + logger: createLogger(), + }); + + service.save({ + shared: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + local: { + version: 1, + processes: [{ id: "dev", name: "Dev", command: ["npm", "run", "dev"], cwd: "." }], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + }); + + expect(fs.existsSync(layout.sharedConfigPath)).toBe(false); + expect(fs.existsSync(layout.localConfigPath)).toBe(true); + expect(git(root, ["status", "--porcelain=v1", "--untracked-files=all"]).trim()).toBe(""); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts index ebb289082..94c39cb5a 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts @@ -87,9 +87,12 @@ describe("createLocalProject", () => { expect(fs.readFileSync(path.join(result.rootPath, "README.md"), "utf8")).toBe("# my-project\n"); const gitignore = fs.readFileSync(path.join(result.rootPath, ".gitignore"), "utf8"); expect(gitignore).toContain("node_modules/"); - expect(gitignore).toContain(".ade/"); + expect(gitignore).not.toContain(".ade/"); expect(gitignore).toContain(".env"); expect(gitignore).toContain("*.log"); + expect(fs.existsSync(path.join(result.rootPath, ".ade", ".gitignore"))).toBe(true); + expect(fs.existsSync(path.join(result.rootPath, ".ade", "ade.yaml"))).toBe(true); + expect(fs.existsSync(path.join(result.rootPath, ".ade", "cto", "identity.yaml"))).toBe(true); const argsList = runGitMock.mock.calls.map((c) => c[0] as string[]); expect(argsList[0]).toEqual(["init", "--initial-branch=main"]); diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.ts index 50db2ccba..72fcc880a 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.ts @@ -14,6 +14,7 @@ import type { import { runGit } from "../git/git"; import type { Logger } from "../logging/logger"; import type { createGithubService } from "../github/githubService"; +import { initializeOrRepairAdeProject } from "./adeProjectService"; type GithubService = ReturnType; @@ -22,7 +23,6 @@ const GITIGNORE_CONTENT = [ "dist/", "build/", ".DS_Store", - ".ade/", ".env", ".env.*", "*.log", @@ -128,6 +128,7 @@ export function createProjectScaffoldService({ fs.writeFileSync(path.join(rootPath, "README.md"), `# ${name}\n`, "utf8"); fs.writeFileSync(path.join(rootPath, ".gitignore"), GITIGNORE_CONTENT, "utf8"); + initializeOrRepairAdeProject(rootPath, { logger, mode: "shared" }); const addRes = await runGit(["add", "."], { cwd: rootPath, timeoutMs: 15_000 }); if (addRes.exitCode !== 0) { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index a95ac4415..a55700dc3 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -434,6 +434,54 @@ describe("ptyService", () => { ); }); + it("exports ADE chat terminal context to spawned shells", async () => { + const { service, loadPty } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "Chat context", + cols: 80, + rows: 24, + chatSessionId: "chat-42", + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const spawnArgs = ptyLib.spawn.mock.calls.at(-1); + const opts = spawnArgs?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env).toEqual(expect.objectContaining({ + ADE_CHAT_SESSION_ID: "chat-42", + ADE_LANE_ID: "lane-1", + ADE_PROJECT_ROOT: "/tmp/test-project", + })); + }); + + it("does not leak an inherited ADE chat session into unlinked terminals", async () => { + const previous = process.env.ADE_CHAT_SESSION_ID; + process.env.ADE_CHAT_SESSION_ID = "outer-chat"; + try { + const { service, loadPty } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "Unlinked terminal", + cols: 80, + rows: 24, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const spawnArgs = ptyLib.spawn.mock.calls.at(-1); + const opts = spawnArgs?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(opts?.env?.ADE_CHAT_SESSION_ID).toBeUndefined(); + expect(opts?.env).toEqual(expect.objectContaining({ + ADE_LANE_ID: "lane-1", + ADE_PROJECT_ROOT: "/tmp/test-project", + })); + } finally { + if (previous === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previous; + } + }); + it("preserves explicit terminal color environment overrides", async () => { const { service, loadPty } = createHarness(); @@ -1132,7 +1180,13 @@ describe("ptyService", () => { ); }); - it("generates Claude CLI titles from the first submitted PTY write (user prompt) using the bound cwd", async () => { + it.each([ + ["claude", "Claude session"], + ["codex", "Codex session"], + ["cursor-cli", "Cursor Agent CLI"], + ["droid", "Factory Droid CLI"], + ["opencode", "OpenCode CLI"], + ] as const)("generates %s titles from the first submitted PTY write using the bound cwd", async (toolType, title) => { vi.useFakeTimers(); try { mocks.existsSyncResults.set("/tmp/test-worktree/subdir", true); @@ -1144,10 +1198,10 @@ describe("ptyService", () => { const { ptyId } = await service.create({ laneId: "lane-1", cwd: "/tmp/test-worktree/subdir", - title: "Claude session", + title, cols: 80, rows: 24, - toolType: "claude", + toolType, }); // Mark the metadata file as non-existent so readPersistedChatManuallyNamed returns false const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index ecb392570..6fa8ccad5 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; import type { IPty, IWindowsPtyForkOptions } from "node-pty"; import type * as ptyNs from "node-pty"; import type { Logger } from "../logging/logger"; @@ -45,8 +46,8 @@ import { export const PTY_AI_TITLE_DEBOUNCE_MS = 6000; export const PTY_AI_TITLE_TIMEOUT_MS = 60_000; -/** Claude/Codex TUIs often hide useful text in an alt-screen, so snippet-based titles fail; titles come from the first PTY write that ends with \\r (submitted prompt) instead. */ -const CLI_USER_TITLE_TOOL_TYPES = new Set(["claude", "codex"]); +/** Interactive agent TUIs often hide useful text in an alt-screen, so titles come from the first submitted user prompt instead of startup output. */ +const CLI_USER_TITLE_TOOL_TYPES = new Set(["claude", "codex", "cursor-cli", "droid", "opencode"]); function shouldScheduleOutputSnippetTitle(tool: TerminalToolType | null): boolean { if (!tool || tool === "shell" || tool === "run-shell") return false; @@ -79,6 +80,24 @@ function withInteractiveTerminalColorEnv(env: NodeJS.ProcessEnv): NodeJS.Process return next; } +function withAdeTerminalContextEnv(env: NodeJS.ProcessEnv, args: { + projectRoot: string; + laneId: string; + chatSessionId: string | null; +}): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { + ...env, + ADE_PROJECT_ROOT: args.projectRoot, + ADE_LANE_ID: args.laneId, + }; + if (args.chatSessionId) { + next.ADE_CHAT_SESSION_ID = args.chatSessionId; + } else { + delete next.ADE_CHAT_SESSION_ID; + } + return next; +} + function sanitizeCliUserTitleSeed(raw: string): string { const stripped = stripAnsi(raw) .replace(/\r\n/g, "\n") @@ -262,6 +281,9 @@ function normalizeToolType(raw: unknown): TerminalToolType | null { "run-shell", "claude", "codex", + "cursor-cli", + "droid", + "opencode", "claude-orchestrated", "codex-orchestrated", "opencode-orchestrated", @@ -290,13 +312,21 @@ function buildInitialResumeMetadata(args: { const parsedLaunch = parseTrackedCliLaunchConfig(args.startupCommand, args.toolType); const isClaude = args.toolType === "claude" || args.toolType === "claude-orchestrated"; const isCodex = args.toolType === "codex" || args.toolType === "codex-orchestrated"; + const isCursor = args.toolType === "cursor-cli"; + const isDroid = args.toolType === "droid"; + const isOpenCode = args.toolType === "opencode"; // Extract pre-assigned --session-id from Claude startup command const preAssignedId = isClaude ? extractClaudeSessionIdFromCommand(args.startupCommand) : null; if (parsedLaunch) { + let provider: TerminalResumeMetadata["provider"] = "claude"; + if (isCodex) provider = "codex"; + else if (isCursor) provider = "cursor"; + else if (isDroid) provider = "droid"; + else if (isOpenCode) provider = "opencode"; return { - provider: isCodex ? "codex" : "claude", + provider, targetKind: isCodex ? "thread" : "session", targetId: preAssignedId, launch: parsedLaunch, @@ -309,11 +339,26 @@ function buildInitialResumeMetadata(args: { if (isCodex) { return { provider: "codex", targetKind: "thread", targetId: null, launch: {} }; } + if (isCursor) { + return { provider: "cursor", targetKind: "session", targetId: null, launch: {} }; + } + if (isDroid) { + return { provider: "droid", targetKind: "session", targetId: null, launch: {} }; + } + if (isOpenCode) { + return { provider: "opencode", targetKind: "session", targetId: null, launch: {} }; + } return null; } -function isTrackedCliToolType(toolType: TerminalToolType | null): toolType is "claude" | "codex" | "claude-orchestrated" | "codex-orchestrated" { - return toolType === "claude" || toolType === "codex" || toolType === "claude-orchestrated" || toolType === "codex-orchestrated"; +function isTrackedCliToolType(toolType: TerminalToolType | null): toolType is "claude" | "codex" | "cursor-cli" | "droid" | "opencode" | "claude-orchestrated" | "codex-orchestrated" { + return toolType === "claude" + || toolType === "codex" + || toolType === "cursor-cli" + || toolType === "droid" + || toolType === "opencode" + || toolType === "claude-orchestrated" + || toolType === "codex-orchestrated"; } function inferSessionCwdFromTranscriptPath(transcriptPath: string | null | undefined): string | null { @@ -912,6 +957,107 @@ export function createPtyService({ } }; + const resolveDroidSessionIdFromStorage = (args: { + cwd: string; + startedAt?: string | null; + maxStartDeltaMs?: number; + }): string | null => { + try { + const escapedCwd = args.cwd.replace(/\//g, "-"); + const droidSessionsDir = path.join(os.homedir(), ".factory", "sessions"); + const expectedProjectDir = path.join(droidSessionsDir, escapedCwd); + if (!fs.existsSync(droidSessionsDir)) return null; + const projectDirs = fs.existsSync(expectedProjectDir) + ? [expectedProjectDir] + : fs.readdirSync(droidSessionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(droidSessionsDir, entry.name)); + const requestedStartedAtMs = Date.parse(args.startedAt ?? ""); + const hasStartedAt = Number.isFinite(requestedStartedAtMs); + let bestMatch: { id: string; score: number; mtimeMs: number } | null = null; + for (const projectDir of projectDirs) { + for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const filePath = path.join(projectDir, entry.name); + const stat = fs.statSync(filePath); + const firstLine = readJsonlFirstLine(filePath); + if (!firstLine) continue; + let parsed: unknown; + try { + parsed = JSON.parse(firstLine); + } catch { + continue; + } + const record = parsed && typeof parsed === "object" ? parsed as Record : null; + const id = typeof record?.id === "string" ? record.id.trim() : entry.name.replace(/\.jsonl$/, ""); + const cwd = typeof record?.cwd === "string" ? record.cwd.trim() : ""; + if (record?.type !== "session_start" || !id || cwd !== args.cwd) continue; + if (!hasStartedAt) return id; + const score = Math.abs(stat.mtimeMs - requestedStartedAtMs); + if (typeof args.maxStartDeltaMs === "number" && score > args.maxStartDeltaMs) continue; + if (!bestMatch || score < bestMatch.score || (score === bestMatch.score && stat.mtimeMs > bestMatch.mtimeMs)) { + bestMatch = { id, score, mtimeMs: stat.mtimeMs }; + } + } + } + return bestMatch?.id ?? null; + } catch { + return null; + } + }; + + const resolveOpenCodeSessionIdFromCli = (args: { + cwd: string; + startedAt?: string | null; + maxStartDeltaMs?: number; + }): string | null => { + try { + const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: "1" }; + delete env.FORCE_COLOR; + const result = spawnSync("opencode", ["session", "list", "--format", "json", "--max-count", "80"], { + cwd: args.cwd, + encoding: "utf8", + timeout: 4000, + maxBuffer: 1024 * 1024, + env, + }); + if (result.error || result.status !== 0) return null; + const stdout = String(result.stdout ?? ""); + const jsonStart = stdout.indexOf("["); + if (jsonStart < 0) return null; + const rows = JSON.parse(stdout.slice(jsonStart)) as unknown; + if (!Array.isArray(rows)) return null; + const requestedStartedAtMs = Date.parse(args.startedAt ?? ""); + const hasStartedAt = Number.isFinite(requestedStartedAtMs); + let bestMatch: { id: string; score: number; updatedMs: number } | null = null; + for (const row of rows) { + const record = row && typeof row === "object" ? row as Record : null; + const id = typeof record?.id === "string" ? record.id.trim() : ""; + const directory = typeof record?.directory === "string" ? record.directory.trim() : ""; + if (!id || directory !== args.cwd) continue; + const createdMs = Number(record?.created); + const updatedMs = Number(record?.updated); + let referenceMs: number; + if (Number.isFinite(createdMs)) { + referenceMs = createdMs; + } else if (Number.isFinite(updatedMs)) { + referenceMs = updatedMs; + } else { + referenceMs = Date.now(); + } + if (!hasStartedAt) return id; + const score = Math.abs(referenceMs - requestedStartedAtMs); + if (typeof args.maxStartDeltaMs === "number" && score > args.maxStartDeltaMs) continue; + if (!bestMatch || score < bestMatch.score || (score === bestMatch.score && referenceMs > bestMatch.updatedMs)) { + bestMatch = { id, score, updatedMs: referenceMs }; + } + } + return bestMatch?.id ?? null; + } catch { + return null; + } + }; + const tryBackfillResumeTarget = async ( sessionId: string, preferredToolType: TerminalToolType | null, @@ -976,6 +1122,36 @@ export function createPtyService({ } } + if (effectiveToolType === "droid" && cwd && reason !== "resume-launch") { + const droidSessionId = resolveDroidSessionIdFromStorage({ + cwd, + startedAt: session.startedAt, + maxStartDeltaMs: 10 * 60_000, + }); + if (droidSessionId) { + const resumeCmd = `droid --resume ${droidSessionId}`; + missingResumeTargetBackfillFailures.delete(sessionId); + sessionService.setResumeCommand(sessionId, resumeCmd); + logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "droid-storage", droidSessionId }); + return true; + } + } + + if (effectiveToolType === "opencode" && cwd && reason !== "resume-launch") { + const opencodeSessionId = resolveOpenCodeSessionIdFromCli({ + cwd, + startedAt: session.startedAt, + maxStartDeltaMs: 10 * 60_000, + }); + if (opencodeSessionId) { + const resumeCmd = `opencode --session ${opencodeSessionId}`; + missingResumeTargetBackfillFailures.delete(sessionId); + sessionService.setResumeCommand(sessionId, resumeCmd); + logger.info("pty.resume_target_backfilled", { sessionId, toolType: effectiveToolType, reason, source: "opencode-session-list", opencodeSessionId }); + return true; + } + } + if (reason === "session-list") { missingResumeTargetBackfillFailures.set(sessionId, { toolType: effectiveToolType, @@ -1583,7 +1759,12 @@ export function createPtyService({ ...((await getLaneRuntimeEnv?.(laneId)) ?? {}), ...(args.env ?? {}) }; - const launchEnv = withInteractiveTerminalColorEnv(getAdeCliAgentEnv?.(baseLaunchEnv) ?? baseLaunchEnv); + const contextLaunchEnv = withAdeTerminalContextEnv(baseLaunchEnv, { + projectRoot, + laneId, + chatSessionId, + }); + const launchEnv = withInteractiveTerminalColorEnv(getAdeCliAgentEnv?.(contextLaunchEnv) ?? contextLaunchEnv); const shouldBackfillResumeTarget = existingSession && isTrackedCliToolType(toolTypeHint) diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 530898055..143af9e03 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -216,6 +216,44 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + it("recovers launch permissions from a detected resume command when metadata is missing", async () => { + const projectRoot = makeProjectRoot("ade-session-service-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-opencode-legacy", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "OpenCode CLI", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/session-opencode-legacy.log", + toolType: "opencode", + }); + + service.setResumeCommand( + "session-opencode-legacy", + "OPENCODE_CONFIG_CONTENT='{\"permission\":\"allow\"}' opencode --session ses_legacy", + ); + + const resumed = service.get("session-opencode-legacy"); + expect(resumed?.resumeMetadata).toEqual({ + provider: "opencode", + targetKind: "session", + targetId: "ses_legacy", + permissionMode: "full-auto", + launch: { permissionMode: "full-auto" }, + }); + expect(resumed?.resumeCommand).toBe( + "OPENCODE_CONFIG_CONTENT='{\"permission\":\"allow\"}' opencode --session ses_legacy", + ); + + activeDisposers.push(async () => db.close()); + }); + it("hard deletes a stored session row", async () => { const projectRoot = makeProjectRoot("ade-session-service-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 1d1cc98d4..3376fb401 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -16,7 +16,9 @@ import { buildTrackedCliResumeCommand, defaultResumeCommandForTool, normalizeResumeCommand, + parseTrackedCliLaunchConfig, parseTrackedCliResumeCommand, + providerFromTool, } from "../../utils/terminalSessionSignals"; type SessionRow = { @@ -72,7 +74,7 @@ const SESSION_COLUMNS = ` `; function isResumeProvider(value: unknown): value is TerminalResumeProvider { - return value === "claude" || value === "codex"; + return value === "claude" || value === "codex" || value === "cursor" || value === "droid" || value === "opencode"; } function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { @@ -136,20 +138,12 @@ function parseLaunchMetadataFromCurrentSession( const currentMetadata = currentSession.resumeMetadata ?? null; if (currentMetadata) return currentMetadata; - const fallbackTool = currentSession.toolType; - let provider: "claude" | "codex" | null; - if (fallbackTool === "claude" || fallbackTool === "claude-orchestrated") { - provider = "claude"; - } else if (fallbackTool === "codex" || fallbackTool === "codex-orchestrated") { - provider = "codex"; - } else { - provider = null; - } + const provider = providerFromTool(currentSession.toolType); if (!provider) return null; return { provider, - targetKind: provider === "claude" ? "session" : "thread", + targetKind: provider === "codex" ? "thread" : "session", targetId: null, launch: {}, }; @@ -182,6 +176,9 @@ export function createSessionService({ db }: { db: AdeDb }) { "run-shell", "claude", "codex", + "cursor-cli", + "droid", + "opencode", "claude-orchestrated", "codex-orchestrated", "opencode-orchestrated", @@ -559,12 +556,18 @@ export function createSessionService({ db }: { db: AdeDb }) { const preferredToolType = currentSession?.toolType ?? null; const parsed = parseTrackedCliResumeCommand(resumeCommand, preferredToolType); const currentMetadata = currentSession?.resumeMetadata ?? null; + const launchFromResumeCommand = typeof resumeCommand === "string" + ? parseTrackedCliLaunchConfig(resumeCommand, preferredToolType) + : null; const nextMetadata = parsed ? { provider: parsed.provider, - targetKind: parsed.provider === "claude" ? "session" : "thread", + targetKind: parsed.provider === "codex" ? "thread" : "session", targetId: parsed.targetId ?? currentMetadata?.targetId ?? null, - launch: currentMetadata?.launch ?? parseLaunchMetadataFromCurrentSession(currentSession)?.launch ?? {}, + launch: currentMetadata?.launch + ?? launchFromResumeCommand + ?? parseLaunchMetadataFromCurrentSession(currentSession)?.launch + ?? {}, } satisfies TerminalResumeMetadata : currentMetadata; const next = nextMetadata diff --git a/apps/desktop/src/main/services/state/globalState.test.ts b/apps/desktop/src/main/services/state/globalState.test.ts new file mode 100644 index 000000000..f8e8f6d0d --- /dev/null +++ b/apps/desktop/src/main/services/state/globalState.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { upsertRecentProject, type GlobalState } from "./globalState"; + +describe("upsertRecentProject", () => { + it("keeps an existing project in place when preserving recent order", () => { + const state: GlobalState = { + lastProjectRoot: "/projects/a", + recentProjects: [ + { rootPath: "/projects/a", displayName: "A", lastOpenedAt: "2026-04-01T00:00:00.000Z" }, + { rootPath: "/projects/b", displayName: "B", lastOpenedAt: "2026-04-02T00:00:00.000Z" }, + { rootPath: "/projects/c", displayName: "C", lastOpenedAt: "2026-04-03T00:00:00.000Z" }, + ], + }; + + const next = upsertRecentProject( + state, + { rootPath: "/projects/b", displayName: "B renamed" }, + { preserveRecentOrder: true }, + ); + + expect(next.lastProjectRoot).toBe("/projects/b"); + expect(next.recentProjects?.map((entry) => entry.rootPath)).toEqual([ + "/projects/a", + "/projects/b", + "/projects/c", + ]); + expect(next.recentProjects?.[1]).toEqual({ + rootPath: "/projects/b", + displayName: "B renamed", + lastOpenedAt: expect.any(String), + }); + }); + + it("adds unknown projects to the front", () => { + const state: GlobalState = { + recentProjects: [ + { rootPath: "/projects/a", displayName: "A", lastOpenedAt: "2026-04-01T00:00:00.000Z" }, + ], + }; + + const next = upsertRecentProject( + state, + { rootPath: "/projects/b", displayName: "B" }, + { preserveRecentOrder: true }, + ); + + expect(next.recentProjects?.map((entry) => entry.rootPath)).toEqual([ + "/projects/b", + "/projects/a", + ]); + }); +}); diff --git a/apps/desktop/src/main/services/state/globalState.ts b/apps/desktop/src/main/services/state/globalState.ts index 61281c387..d883d4371 100644 --- a/apps/desktop/src/main/services/state/globalState.ts +++ b/apps/desktop/src/main/services/state/globalState.ts @@ -44,6 +44,7 @@ export function writeGlobalState(filePath: string, state: GlobalState): void { type UpsertRecentProjectOptions = { recordLastProject?: boolean; recordRecent?: boolean; + preserveRecentOrder?: boolean; }; export function upsertRecentProject( @@ -60,8 +61,18 @@ export function upsertRecentProject( return next; } const prev = next.recentProjects ?? []; + const nextEntry = { rootPath: proj.rootPath, displayName: proj.displayName, lastOpenedAt: now }; + if (options.preserveRecentOrder === true) { + const existingIndex = prev.findIndex((p) => p.rootPath === proj.rootPath); + if (existingIndex >= 0 && existingIndex < 12) { + const updated = prev.slice(0, 12); + updated[existingIndex] = nextEntry; + next.recentProjects = updated; + return next; + } + } const filtered = prev.filter((p) => p.rootPath !== proj.rootPath); - next.recentProjects = [{ rootPath: proj.rootPath, displayName: proj.displayName, lastOpenedAt: now }, ...filtered].slice(0, 12); + next.recentProjects = [nextEntry, ...filtered].slice(0, 12); return next; } diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 5867f10ed..caf1b0173 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -282,6 +282,7 @@ async function summarizeChatSessionForRemote( ...(session.modelId ? { modelId: session.modelId } : {}), ...(session.sessionProfile ? { sessionProfile: session.sessionProfile } : {}), reasoningEffort: session.reasoningEffort ?? null, + codexFastMode: session.codexFastMode === true, executionMode: session.executionMode ?? null, ...(session.permissionMode ? { permissionMode: session.permissionMode } : {}), ...(session.interactionMode !== undefined ? { interactionMode: session.interactionMode } : {}), @@ -556,6 +557,7 @@ function parseAgentChatCreateArgs(value: Record): AgentChatCrea if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatCreateArgs["codexApprovalPolicy"]; if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatCreateArgs["codexSandbox"]; if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatCreateArgs["codexConfigSource"]; + if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatCreateArgs["opencodePermissionMode"]; if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : (asTrimmedString(value.droidPermissionMode) ?? undefined) as AgentChatCreateArgs["droidPermissionMode"]; if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; @@ -681,6 +683,7 @@ function parseAgentChatUpdateSessionArgs(value: Record): AgentC if ("codexApprovalPolicy" in value) parsed.codexApprovalPolicy = value.codexApprovalPolicy == null ? undefined : asTrimmedString(value.codexApprovalPolicy) as AgentChatUpdateSessionArgs["codexApprovalPolicy"]; if ("codexSandbox" in value) parsed.codexSandbox = value.codexSandbox == null ? undefined : asTrimmedString(value.codexSandbox) as AgentChatUpdateSessionArgs["codexSandbox"]; if ("codexConfigSource" in value) parsed.codexConfigSource = value.codexConfigSource == null ? undefined : asTrimmedString(value.codexConfigSource) as AgentChatUpdateSessionArgs["codexConfigSource"]; + if ("codexFastMode" in value) parsed.codexFastMode = asOptionalBoolean(value.codexFastMode); if ("opencodePermissionMode" in value) parsed.opencodePermissionMode = value.opencodePermissionMode == null ? undefined : asTrimmedString(value.opencodePermissionMode) as AgentChatUpdateSessionArgs["opencodePermissionMode"]; if ("droidPermissionMode" in value) parsed.droidPermissionMode = value.droidPermissionMode == null ? undefined : asTrimmedString(value.droidPermissionMode) as AgentChatUpdateSessionArgs["droidPermissionMode"]; if ("cursorModeId" in value) parsed.cursorModeId = value.cursorModeId == null ? null : asTrimmedString(value.cursorModeId) ?? null; diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index f20aafa8e..e0502cd5f 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -46,6 +46,9 @@ describe("terminalSessionSignals", () => { it("returns default resume command for known tools", () => { expect(defaultResumeCommandForTool("claude")).toBe("claude --resume"); expect(defaultResumeCommandForTool("codex")).toBe("codex resume"); + expect(defaultResumeCommandForTool("cursor-cli")).toBe("cursor-agent --continue"); + expect(defaultResumeCommandForTool("droid")).toBe("droid --resume"); + expect(defaultResumeCommandForTool("opencode")).toBe("opencode --continue"); expect(defaultResumeCommandForTool("shell")).toBeNull(); }); @@ -60,6 +63,15 @@ describe("terminalSessionSignals", () => { codexSandbox: "workspace-write", codexConfigSource: "flags", }); + expect(parseTrackedCliLaunchConfig("cursor-agent --mode plan", "cursor-cli")).toEqual({ + permissionMode: "plan", + }); + expect(parseTrackedCliLaunchConfig("droid --settings /tmp/ade.json", "droid")).toEqual({ + permissionMode: "plan", + }); + expect(parseTrackedCliLaunchConfig("OPENCODE_CONFIG_CONTENT='{\"permission\":{\"*\":\"ask\",\"edit\":\"allow\"}}' opencode", "opencode")).toEqual({ + permissionMode: "edit", + }); }); it("builds permission-aware resume commands with or without a concrete target", () => { @@ -83,6 +95,20 @@ describe("terminalSessionSignals", () => { targetId: null, launch: { permissionMode: "full-auto" }, })).toBe("codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox resume"); + + expect(buildTrackedCliResumeCommand({ + provider: "cursor", + targetKind: "session", + targetId: "chat-1", + launch: { permissionMode: "edit" }, + })).toBe("cursor-agent --mode ask --resume chat-1"); + + expect(buildTrackedCliResumeCommand({ + provider: "opencode", + targetKind: "session", + targetId: "ses_1", + launch: { permissionMode: "full-auto" }, + })).toBe("OPENCODE_CONFIG_CONTENT='{\"permission\":\"allow\"}' opencode --session ses_1"); }); it("parses codex --full-auto as default permission mode", () => { @@ -145,5 +171,22 @@ describe("terminalSessionSignals", () => { provider: "codex", targetId: null, }); + expect(parseTrackedCliResumeCommand("cursor-agent --force --resume chat-abc", "cursor-cli")).toEqual({ + provider: "cursor", + targetId: "chat-abc", + }); + expect(parseTrackedCliResumeCommand("droid --resume 29f8d3bf-6620-4c89-a72e-5327670acc69", "droid")).toEqual({ + provider: "droid", + targetId: "29f8d3bf-6620-4c89-a72e-5327670acc69", + }); + expect(parseTrackedCliResumeCommand("OPENCODE_CONFIG_CONTENT='{\"permission\":\"allow\"}' opencode --session ses_abc", "opencode")).toEqual({ + provider: "opencode", + targetId: "ses_abc", + }); + }); + + it("extracts Cursor resume commands printed by ADE launch wrappers", () => { + const chunk = "[ADE] Resume with cursor-agent --resume chat-abc"; + expect(extractResumeCommandFromOutput(chunk, "cursor-cli")).toBe("cursor-agent --resume chat-abc"); }); }); diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index ad8d26996..5ff929f17 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -9,8 +9,18 @@ import type { } from "../../shared/types"; const OSC_133_REGEX = /\u001b\]133;([ABCD])(?:;[^\u0007\u001b]*)?(?:\u0007|\u001b\\)/g; -const RESUME_BACKTICK_REGEX = /`((?:claude|codex)\s+(?:(?:--resume|-r|resume)\b)[^`\r\n]*)`/gi; -const RESUME_PLAIN_REGEX = /\b((?:claude|codex)\s+(?:(?:--resume|-r|resume)\b)[^\r\n]*?(?=\s+(?:claude|codex)\s|$))/gi; +const RESUME_BACKTICK_REGEX = /`([^`\r\n]*(?:claude|codex|cursor-agent|droid|opencode)\s+[^`\r\n]*(?:--resume|-r|resume|--continue|-c|--session|-s)[^`\r\n]*)`/gi; +const RESUME_COMMAND_REGEX = /\b((?:claude|codex|cursor-agent|droid|opencode)\s+[^\r\n`]*?(?:--resume(?:=|\s+)?[^\s`\r\n]*|-r\s+[^\s`\r\n]+|resume(?:\s+[^\s`\r\n]+)?|--continue|-c(?:\s|$)|--session(?:=|\s+)[^\s`\r\n]+|-s\s+[^\s`\r\n]+)[^\r\n`]*?)(?=\s+(?:claude|codex|cursor-agent|droid|opencode)\s|$)/gi; + +function shellQuote(value: string): string { + if (!value.length) return "''"; + if (/^[a-zA-Z0-9_.:@%+=,/-]+$/.test(value)) return value; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function commandArrayToLine(parts: string[]): string { + return parts.map(shellQuote).join(" "); +} function normalizeCommand(raw: string): string { return raw @@ -20,16 +30,31 @@ function normalizeCommand(raw: string): string { .trim(); } +function stripLeadingEnvAssignments(command: string): string { + let next = command.trim(); + for (;;) { + const before = next; + next = next.replace(/^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+/, "").trim(); + if (next === before) return next; + } +} + function toolFromCommand(raw: string): TerminalToolType | null { - const normalized = raw.trim().toLowerCase(); + const normalized = stripLeadingEnvAssignments(raw).trim().toLowerCase(); if (normalized.startsWith("claude ")) return "claude"; if (normalized.startsWith("codex ")) return "codex"; + if (normalized.startsWith("cursor-agent ")) return "cursor-cli"; + if (normalized.startsWith("droid ")) return "droid"; + if (normalized.startsWith("opencode ")) return "opencode"; return null; } -function providerFromTool(toolType: TerminalToolType | null | undefined): TerminalResumeProvider | null { +export function providerFromTool(toolType: TerminalToolType | null | undefined): TerminalResumeProvider | null { if (toolType === "claude" || toolType === "claude-orchestrated" || toolType === "claude-chat") return "claude"; if (toolType === "codex" || toolType === "codex-orchestrated" || toolType === "codex-chat") return "codex"; + if (toolType === "cursor-cli") return "cursor"; + if (toolType === "droid") return "droid"; + if (toolType === "opencode") return "opencode"; return null; } @@ -48,6 +73,74 @@ function permissionModeToCodexFlags(permissionMode: AgentChatPermissionMode | nu return []; } +function permissionModeToCursorFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + if (permissionMode === "full-auto") return ["--force"]; + if (permissionMode === "plan") return ["--mode", "plan"]; + if (permissionMode === "edit") return ["--mode", "ask"]; + return []; +} + +function droidSettingsJson(permissionMode: AgentChatPermissionMode | null | undefined): string { + if (permissionMode === "full-auto") { + return JSON.stringify({ sessionDefaultSettings: { interactionMode: "auto", autonomyLevel: "high" } }); + } + if (permissionMode === "default") { + return JSON.stringify({ sessionDefaultSettings: { interactionMode: "auto", autonomyLevel: "medium" } }); + } + if (permissionMode === "edit") { + return JSON.stringify({ sessionDefaultSettings: { interactionMode: "auto", autonomyLevel: "low" } }); + } + return JSON.stringify({ sessionDefaultSettings: { interactionMode: "spec", autonomyLevel: "off" } }); +} + +function buildDroidCommandLine(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + resumeTarget?: string | null; +}): string { + const droidArgs = ["droid", "--settings", "$ADE_DROID_SETTINGS", "--resume"]; + if (args.resumeTarget) droidArgs.push(args.resumeTarget); + const droidCommand = commandArrayToLine(droidArgs).replace(shellQuote("$ADE_DROID_SETTINGS"), "\"$ADE_DROID_SETTINGS\""); + return [ + "ADE_DROID_SETTINGS=\"$(mktemp \"${TMPDIR:-/tmp}/ade-droid-settings.XXXXXX.json\")\"", + `printf %s ${shellQuote(droidSettingsJson(args.permissionMode))} > "$ADE_DROID_SETTINGS"`, + `${droidCommand}; ADE_DROID_STATUS=$?; rm -f "$ADE_DROID_SETTINGS"; exit $ADE_DROID_STATUS`, + ].join(" && "); +} + +const OPENCODE_INLINE_CONFIG_ENV = "OPENCODE_CONFIG_CONTENT"; + +function openCodePermissionValue(permissionMode: AgentChatPermissionMode | null | undefined): string | Record | null { + if (permissionMode === "config-toml") return null; + if (permissionMode === "full-auto") return "allow"; + if (permissionMode === "edit") return { "*": "ask", edit: "allow" }; + if (permissionMode === "plan") return { "*": "ask", edit: "deny", bash: "deny" }; + return { "*": "ask" }; +} + +function openCodeConfigEnv(permissionMode: AgentChatPermissionMode | null | undefined): string | null { + const permission = openCodePermissionValue(permissionMode); + return permission ? JSON.stringify({ permission }) : null; +} + +function permissionModeToOpenCodeArgs(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + return permissionMode === "plan" ? ["--agent", "plan"] : []; +} + +function buildOpenCodeResumeCommand(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + targetId: string | null; +}): string { + const commandArgs = ["opencode", ...permissionModeToOpenCodeArgs(args.permissionMode)]; + if (args.targetId) { + commandArgs.push("--session", args.targetId); + } else { + commandArgs.push("--continue"); + } + const config = openCodeConfigEnv(args.permissionMode); + const assignment = config ? `${OPENCODE_INLINE_CONFIG_ENV}=${shellQuote(config)} ` : ""; + return `${assignment}${commandArrayToLine(commandArgs)}`; +} + function extractTrackedCliPermissionMode(command: string, provider: TerminalResumeProvider): AgentChatPermissionMode | undefined { const normalized = command.trim().toLowerCase(); if (provider === "claude") { @@ -58,24 +151,58 @@ function extractTrackedCliPermissionMode(command: string, provider: TerminalResu return undefined; } - if (normalized.includes("--dangerously-bypass-approvals-and-sandbox") || normalized.includes("--yolo")) return "full-auto"; - if (normalized.includes("--full-auto")) return "default"; - if ( - (normalized.includes("--ask-for-approval untrusted") || normalized.includes("-a untrusted") || normalized.includes("approval_policy=untrusted")) - && (normalized.includes("--sandbox workspace-write") || normalized.includes("-s workspace-write") || normalized.includes("sandbox_mode=workspace-write")) - ) return "edit"; - if ( - (normalized.includes("--ask-for-approval on-request") || normalized.includes("-a on-request") || normalized.includes("approval_policy=on-request")) - && (normalized.includes("--sandbox read-only") || normalized.includes("-s read-only") || normalized.includes("sandbox_mode=read-only")) - ) return "plan"; - if ( - (normalized.includes("--ask-for-approval on-request") || normalized.includes("-a on-request") || normalized.includes("approval_policy=on-request")) - && (normalized.includes("--sandbox workspace-write") || normalized.includes("-s workspace-write") || normalized.includes("sandbox_mode=workspace-write")) - ) return "default"; - if (normalized.includes("approval_policy=on-failure") || normalized.includes("sandbox_mode=workspace-write")) return "edit"; - if (normalized.includes("approval_policy=untrusted") || normalized.includes("sandbox_mode=read-only")) return "plan"; - if (normalized.includes("approval_policy=") || normalized.includes("sandbox_mode=") || normalized.includes("--ask-for-approval") || normalized.includes("--sandbox")) return "plan"; - return "config-toml"; + if (provider === "codex") { + if (normalized.includes("--dangerously-bypass-approvals-and-sandbox") || normalized.includes("--yolo")) return "full-auto"; + if (normalized.includes("--full-auto")) return "default"; + if ( + (normalized.includes("--ask-for-approval untrusted") || normalized.includes("-a untrusted") || normalized.includes("approval_policy=untrusted")) + && (normalized.includes("--sandbox workspace-write") || normalized.includes("-s workspace-write") || normalized.includes("sandbox_mode=workspace-write")) + ) return "edit"; + if ( + (normalized.includes("--ask-for-approval on-request") || normalized.includes("-a on-request") || normalized.includes("approval_policy=on-request")) + && (normalized.includes("--sandbox read-only") || normalized.includes("-s read-only") || normalized.includes("sandbox_mode=read-only")) + ) return "plan"; + if ( + (normalized.includes("--ask-for-approval on-request") || normalized.includes("-a on-request") || normalized.includes("approval_policy=on-request")) + && (normalized.includes("--sandbox workspace-write") || normalized.includes("-s workspace-write") || normalized.includes("sandbox_mode=workspace-write")) + ) return "default"; + if (normalized.includes("approval_policy=on-failure") || normalized.includes("sandbox_mode=workspace-write")) return "edit"; + if (normalized.includes("approval_policy=untrusted") || normalized.includes("sandbox_mode=read-only")) return "plan"; + if (normalized.includes("approval_policy=") || normalized.includes("sandbox_mode=") || normalized.includes("--ask-for-approval") || normalized.includes("--sandbox")) return "plan"; + return "config-toml"; + } + + if (provider === "cursor") { + if (normalized.includes("--force") || normalized.includes("--yolo")) return "full-auto"; + if (normalized.includes("--mode plan") || normalized.includes("--plan")) return "plan"; + if (normalized.includes("--mode ask")) return "edit"; + return "default"; + } + + if (provider === "droid") { + if (normalized.includes("autonomylevel\":\"high") || normalized.includes("--auto high")) return "full-auto"; + if (normalized.includes("autonomylevel\":\"medium") || normalized.includes("--auto medium")) return "default"; + if (normalized.includes("autonomylevel\":\"low") || normalized.includes("--auto low")) return "edit"; + if (normalized.includes("--skip-permissions-unsafe")) return "full-auto"; + return "plan"; + } + + if (provider === "opencode") { + if ( + normalized.includes("opencode_config_content=") + && ( + normalized.includes("\"permission\":\"allow\"") + || normalized.includes("\\\"permission\\\":\\\"allow\\\"") + ) + ) return "full-auto"; + if (normalized.includes("opencode_permission='\"allow\"'") || normalized.includes("opencode_permission=\"\\\"allow\\\"\"")) return "full-auto"; + if (normalized.includes("--agent plan")) return "plan"; + if (normalized.includes("\"edit\":\"allow\"") || normalized.includes("\\\"edit\\\":\\\"allow\\\"")) return "edit"; + if (normalized.includes("opencode_config_content=") || normalized.includes("opencode_permission=")) return "default"; + return "config-toml"; + } + + return undefined; } export function parseTrackedCliLaunchConfig( @@ -105,46 +232,86 @@ export function parseTrackedCliLaunchConfig( }; } - if (permissionMode === "full-auto") { + if (provider === "codex") { + if (permissionMode === "full-auto") { + return { + permissionMode, + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + }; + } + + if (permissionMode === "edit") { + return { + permissionMode, + codexApprovalPolicy: "untrusted", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + }; + } + + if (permissionMode === "default") { + return { + permissionMode, + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + }; + } + + if (permissionMode === "plan") { + return { + permissionMode, + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + }; + } + return { - permissionMode, - codexApprovalPolicy: "never", - codexSandbox: "danger-full-access", - codexConfigSource: "flags", + permissionMode: "config-toml", + codexConfigSource: "config-toml", }; } - if (permissionMode === "edit") { - return { - permissionMode, - codexApprovalPolicy: "untrusted", - codexSandbox: "workspace-write", - codexConfigSource: "flags", - }; + return { + ...(permissionMode ? { permissionMode } : {}), + }; +} + +function extractWrappedProviderCommand(command: string, binary: string): string { + const escaped = binary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = command.match(new RegExp(String.raw`(?:^|&&\s+|;\s+)(${escaped}\b[^;&|]*)`, "i")); + return match?.[1] ?? command; +} + +function parseProviderResumeTarget(provider: TerminalResumeProvider, command: string): string | null | undefined { + if (provider === "claude") { + const match = command.match(/^claude(?:(?:\s+--[^\s]+)(?:\s+[^\s]+)?)*\s+(?:--resume|-r|resume)(?:\s+([^\s]+))?(?:\s|$)/i); + return match ? match[1] ?? null : undefined; } - if (permissionMode === "default") { - return { - permissionMode, - codexApprovalPolicy: "on-request", - codexSandbox: "workspace-write", - codexConfigSource: "flags", - }; + if (provider === "codex") { + const match = command.match(/^codex(?:(?:\s+--no-alt-screen)|(?:\s+--full-auto)|(?:\s+--dangerously-bypass-approvals-and-sandbox)|(?:\s+--yolo)|(?:\s+--sandbox\s+[^\s]+)|(?:\s+-s\s+[^\s]+)|(?:\s+--ask-for-approval\s+[^\s]+)|(?:\s+-a\s+[^\s]+)|(?:\s+-c\s+[^\s]+))*\s+resume(?:\s+([^\s]+))?(?:\s|$)/i); + return match ? match[1] ?? null : undefined; } - if (permissionMode === "plan") { - return { - permissionMode, - codexApprovalPolicy: "on-request", - codexSandbox: "read-only", - codexConfigSource: "flags", - }; + if (provider === "cursor") { + const match = command.match(/^cursor-agent\b.*?(?:--resume(?:=|\s+)([^\s]+)|--continue\b|\bresume\b)(?:\s|$)/i); + return match ? match[1] ?? null : undefined; } - return { - permissionMode: "config-toml", - codexConfigSource: "config-toml", - }; + if (provider === "droid") { + const droidCommand = extractWrappedProviderCommand(command, "droid"); + const match = droidCommand.match( + /^droid\b.*?(?:--resume(?:=|\s+)?([^;\s]+)?|-r\s+([^;\s]+)|\bexec\b.*?(?:--session-id|-s)\s+([^;\s]+))(?=\s*(?:[;&]|$))/i, + ); + return match ? match[1] ?? match[2] ?? match[3] ?? null : undefined; + } + + const match = command.match(/^opencode\b.*?(?:--session(?:=|\s+)([^\s]+)|-s\s+([^\s]+)|--continue\b|-c\b)(?:\s|$)/i); + return match ? match[1] ?? match[2] ?? null : undefined; } export function parseTrackedCliResumeCommand( @@ -154,21 +321,14 @@ export function parseTrackedCliResumeCommand( const normalized = normalizeCommand(raw ?? ""); if (!normalized) return null; - const cmdTool = toolFromCommand(normalized); - const provider = cmdTool === "claude" || cmdTool === "codex" - ? cmdTool - : providerFromTool(preferredTool); + const command = stripLeadingEnvAssignments(normalized); + const cmdTool = toolFromCommand(command); + const provider = cmdTool ? providerFromTool(cmdTool) : providerFromTool(preferredTool); if (!provider) return null; - if (provider === "claude") { - const match = normalized.match(/^claude(?:(?:\s+--[^\s]+)(?:\s+[^\s]+)?)*\s+(?:--resume|-r|resume)(?:\s+([^\s]+))?(?:\s|$)/i); - if (!match) return { provider, targetId: null }; - return { provider, targetId: match[1] ?? null }; - } - - const match = normalized.match(/^codex(?:(?:\s+--no-alt-screen)|(?:\s+--full-auto)|(?:\s+--dangerously-bypass-approvals-and-sandbox)|(?:\s+--yolo)|(?:\s+--sandbox\s+[^\s]+)|(?:\s+-s\s+[^\s]+)|(?:\s+--ask-for-approval\s+[^\s]+)|(?:\s+-a\s+[^\s]+)|(?:\s+-c\s+[^\s]+))*\s+resume(?:\s+([^\s]+))?(?:\s|$)/i); - if (!match) return { provider, targetId: null }; - return { provider, targetId: match[1] ?? null }; + const target = parseProviderResumeTarget(provider, command); + if (target === undefined) return null; + return { provider, targetId: target }; } export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata | null | undefined): string | null { @@ -181,13 +341,31 @@ export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata | const parts = ["claude", ...permissionModeToClaudeFlag(permissionMode)]; parts.push("--resume"); if (targetId.length) parts.push(targetId); - return parts.join(" "); + return commandArrayToLine(parts); + } + + if (provider === "codex") { + const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(permissionMode)]; + parts.push("resume"); + if (targetId.length) parts.push(targetId); + return commandArrayToLine(parts); + } + + if (provider === "cursor") { + const parts = ["cursor-agent", ...permissionModeToCursorFlags(permissionMode)]; + if (targetId.length) { + parts.push("--resume", targetId); + } else { + parts.push("--continue"); + } + return commandArrayToLine(parts); + } + + if (provider === "droid") { + return buildDroidCommandLine({ permissionMode, resumeTarget: targetId || null }); } - const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(permissionMode)]; - parts.push("resume"); - if (targetId.length) parts.push(targetId); - return parts.join(" "); + return buildOpenCodeResumeCommand({ permissionMode, targetId: targetId || null }); } function canonicalizePreferredTool(preferredTool: TerminalToolType | null | undefined): TerminalToolType | null | undefined { @@ -198,8 +376,9 @@ function canonicalizePreferredTool(preferredTool: TerminalToolType | null | unde function prefersTool(raw: string, preferredTool: TerminalToolType | null | undefined): boolean { const canonicalPreferredTool = canonicalizePreferredTool(preferredTool); - if (!canonicalPreferredTool || (canonicalPreferredTool !== "claude" && canonicalPreferredTool !== "codex")) return true; + if (!canonicalPreferredTool) return true; const cmdTool = toolFromCommand(raw); + if (!cmdTool) return true; return cmdTool === canonicalPreferredTool; } @@ -211,18 +390,21 @@ export function normalizeResumeCommand( if (!normalized) return null; if (!prefersTool(normalized, preferredTool)) return null; - if (/^claude\s+/i.test(normalized)) { - return normalized + const command = stripLeadingEnvAssignments(normalized); + if (/^claude\s+/i.test(command)) { + return command .replace(/^claude\s+resume\b/i, "claude --resume") .replace(/^claude\s+-r\b/i, "claude --resume"); } - return normalized; } export function defaultResumeCommandForTool(toolType: TerminalToolType | null | undefined): string | null { if (toolType === "claude" || toolType === "claude-orchestrated") return "claude --resume"; if (toolType === "codex" || toolType === "codex-orchestrated") return "codex resume"; + if (toolType === "cursor-cli") return "cursor-agent --continue"; + if (toolType === "droid") return "droid --resume"; + if (toolType === "opencode") return "opencode --continue"; return null; } @@ -237,18 +419,18 @@ export function extractResumeCommandFromOutput( ): string | null { if (!text.trim()) return null; - // Strip ANSI escape codes — TUI CLIs (claude/codex) embed escape codes that break regex matching const cleaned = stripAnsiCodes(text); - - const fromBackticks = Array.from(cleaned.matchAll(RESUME_BACKTICK_REGEX)) - .map((m) => normalizeResumeCommand(m[1] ?? "", preferredTool)) - .filter(Boolean); - if (fromBackticks[0]) return fromBackticks[0]; - - const fromPlain = Array.from(cleaned.matchAll(RESUME_PLAIN_REGEX)) - .map((m) => normalizeResumeCommand(m[1] ?? "", preferredTool)) - .filter(Boolean); - if (fromPlain[0]) return fromPlain[0]; + const candidates = [ + ...Array.from(cleaned.matchAll(RESUME_BACKTICK_REGEX)).map((m) => m[1] ?? ""), + ...Array.from(cleaned.matchAll(RESUME_COMMAND_REGEX)).map((m) => m[1] ?? ""), + ]; + + for (const candidate of candidates) { + const normalized = normalizeResumeCommand(candidate, preferredTool); + if (normalized && parseTrackedCliResumeCommand(normalized, preferredTool)) { + return normalized; + } + } return null; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 7c238797d..a2426c550 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -669,6 +669,7 @@ import type { BuiltInBrowserCreateTabArgs, BuiltInBrowserEventPayload, BuiltInBrowserNavigateArgs, + BuiltInBrowserOpenPanelArgs, BuiltInBrowserScreenshot, BuiltInBrowserSelectPointArgs, BuiltInBrowserSelectResult, @@ -1402,6 +1403,7 @@ declare global { }; builtInBrowser: { getStatus: () => Promise; + showPanel: (args?: BuiltInBrowserOpenPanelArgs) => Promise; setBounds: (args: BuiltInBrowserBoundsArgs) => Promise; attachWebview: (args: BuiltInBrowserAttachWebviewArgs) => Promise; navigate: (args: BuiltInBrowserNavigateArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 409f52a81..ae118291c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -674,6 +674,7 @@ import type { BuiltInBrowserCreateTabArgs, BuiltInBrowserEventPayload, BuiltInBrowserNavigateArgs, + BuiltInBrowserOpenPanelArgs, BuiltInBrowserScreenshot, BuiltInBrowserSelectPointArgs, BuiltInBrowserSelectResult, @@ -2476,6 +2477,8 @@ contextBridge.exposeInMainWorld("ade", { builtInBrowser: { getStatus: async (): Promise => builtInBrowserStatusCache.get(), + showPanel: async (args: BuiltInBrowserOpenPanelArgs = {}): Promise => + clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserShowPanel, args)), setBounds: async (args: BuiltInBrowserBoundsArgs): Promise => clearAround(() => builtInBrowserStatusCache.clear(), () => ipcRenderer.invoke(IPC.builtInBrowserSetBounds, args)), attachWebview: async (args: BuiltInBrowserAttachWebviewArgs): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index acac89435..fae7157f1 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -769,7 +769,7 @@ function isMockChatToolType(toolType: unknown): boolean { || normalized === "opencode-chat" || normalized === "cursor" || normalized === "droid" - || normalized.startsWith("droid") + || normalized === "droid-chat" || normalized.endsWith("-chat") ), ); @@ -847,6 +847,7 @@ function mockAgentChatSummaryFromSession(session: any): any | null { title: session.title ?? null, goal: session.goal ?? null, reasoningEffort: session.resumeMetadata?.reasoningEffort ?? null, + codexFastMode: session.resumeMetadata?.codexFastMode === true, executionMode: session.resumeMetadata?.executionMode ?? null, permissionMode: session.resumeMetadata?.permissionMode ?? null, interactionMode: session.resumeMetadata?.interactionMode ?? null, diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index da74cee10..c8ab4d8d9 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -141,6 +141,7 @@ describe("TopBar", () => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); if (originalAde === undefined) { delete (globalThis.window as any).ade; } else { @@ -210,7 +211,7 @@ describe("TopBar", () => { it("shows project icon replacement errors", async () => { globalThis.window.ade.project.chooseIcon = vi.fn(async () => { - throw new Error("Failed to set project icon: Project icon must be 1 MB or smaller."); + throw new Error("Failed to set project icon: Project icon must be 10 MB or smaller."); }) as any; render(); @@ -218,6 +219,31 @@ describe("TopBar", () => { fireEvent.click(await screen.findByLabelText("Project icon")); fireEvent.click(await screen.findByText("Replace")); - expect((await screen.findByRole("alert")).textContent).toContain("Project icon must be 1 MB or smaller."); + expect((await screen.findByRole("alert")).textContent).toContain("Project icon must be 10 MB or smaller."); + }); + + it("confirms before removing a project tab", async () => { + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + + await screen.findByText("ADE"); + fireEvent.click(screen.getByTitle("Remove project")); + + expect(confirm).toHaveBeenCalledWith(expect.stringContaining("Close \"ADE\" and remove it from project tabs?")); + expect(globalThis.window.ade.project.forgetRecent).not.toHaveBeenCalled(); + }); + + it("removes the project tab after confirmation", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + + await screen.findByText("ADE"); + fireEvent.click(screen.getByTitle("Remove project")); + + await waitFor(() => { + expect(globalThis.window.ade.project.forgetRecent).toHaveBeenCalledWith("/Users/arul/ADE"); + }); }); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 4bea93e07..64c5e4986 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -28,6 +28,8 @@ const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = ["starting", "ru // (current project + a few recents in the tab list) without unbounded growth. const PROJECT_ICON_CACHE_MAX = 24; const projectIconCache = new Map(); +const PROJECT_ICON_ACCENT_CACHE_MAX = 48; +const projectIconAccentCache = new Map(); function getProjectIconFromCache(rootPath: string): ProjectIcon | undefined { const cached = projectIconCache.get(rootPath); if (cached === undefined) return undefined; @@ -48,6 +50,94 @@ function setProjectIconCache(rootPath: string, icon: ProjectIcon): void { } projectIconCache.set(rootPath, icon); } +function setProjectIconAccentCache(cacheKey: string, color: string | null): void { + if (projectIconAccentCache.has(cacheKey)) { + projectIconAccentCache.delete(cacheKey); + } else if (projectIconAccentCache.size >= PROJECT_ICON_ACCENT_CACHE_MAX) { + const oldestKey = projectIconAccentCache.keys().next().value; + if (oldestKey !== undefined) projectIconAccentCache.delete(oldestKey); + } + projectIconAccentCache.set(cacheKey, color); +} + +function toHexByte(value: number): string { + return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0"); +} + +function balancedAccentColor(red: number, green: number, blue: number): string { + const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + let mixTarget = 0; + let mixAmount = 0; + if (luminance < 64) { + mixTarget = 255; + mixAmount = 0.34; + } else if (luminance > 214) { + mixTarget = 0; + mixAmount = 0.22; + } + const mix = (channel: number) => channel + (mixTarget - channel) * mixAmount; + return `#${toHexByte(mix(red))}${toHexByte(mix(green))}${toHexByte(mix(blue))}`; +} + +async function deriveIconAccentColor(dataUrl: string): Promise { + if (projectIconAccentCache.has(dataUrl)) return projectIconAccentCache.get(dataUrl) ?? null; + if (typeof document === "undefined" || typeof Image === "undefined") return null; + + const color = await new Promise((resolve) => { + const image = new Image(); + image.decoding = "async"; + image.onload = () => { + try { + const canvas = document.createElement("canvas"); + const width = Math.max(1, Math.min(24, image.naturalWidth || image.width || 24)); + const height = Math.max(1, Math.min(24, image.naturalHeight || image.height || 24)); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) { + resolve(null); + return; + } + ctx.clearRect(0, 0, width, height); + ctx.drawImage(image, 0, 0, width, height); + const pixels = ctx.getImageData(0, 0, width, height).data; + let redTotal = 0; + let greenTotal = 0; + let blueTotal = 0; + let weightTotal = 0; + for (let index = 0; index < pixels.length; index += 4) { + const alpha = pixels[index + 3] / 255; + if (alpha < 0.25) continue; + const red = pixels[index]; + const green = pixels[index + 1]; + const blue = pixels[index + 2]; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const saturation = max === 0 ? 0 : (max - min) / max; + const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + if (saturation < 0.08 && (luminance < 28 || luminance > 230)) continue; + const weight = alpha * (0.18 + saturation * 1.65); + redTotal += red * weight; + greenTotal += green * weight; + blueTotal += blue * weight; + weightTotal += weight; + } + if (weightTotal <= 0) { + resolve(null); + return; + } + resolve(balancedAccentColor(redTotal / weightTotal, greenTotal / weightTotal, blueTotal / weightTotal)); + } catch { + resolve(null); + } + }; + image.onerror = () => resolve(null); + image.src = dataUrl; + }); + + setProjectIconAccentCache(dataUrl, color); + return color; +} const PHONE_SYNC_FOCUSABLE_SELECTOR = [ "a[href]", "button:not([disabled])", @@ -81,6 +171,14 @@ function projectIconErrorMessage(error: unknown): string { return cleaned || "Failed to update project icon."; } +function confirmProjectTabRemoval(projectName: string, isCurrent: boolean, isMissing: boolean): boolean { + const label = projectName.trim() || "this project"; + const action = isCurrent && !isMissing + ? `Close "${label}" and remove it from project tabs?` + : `Remove "${label}" from project tabs?`; + return window.confirm(`${action}\n\nThis does not delete any files on disk.`); +} + function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { if (!snapshot) return null; if (snapshot.client.state === "error") return "Phone sync error"; @@ -107,11 +205,13 @@ function ProjectTabIcon({ isCurrent, animate, disabled, + onAccentColorChange, }: { rootPath: string; isCurrent: boolean; animate: boolean; disabled: boolean; + onAccentColorChange?: (rootPath: string, color: string | null) => void; }) { const [icon, setIcon] = useState(() => disabled ? null : getProjectIconFromCache(rootPath) ?? null @@ -154,9 +254,28 @@ function ProjectTabIcon({ }; }, [disabled, isCurrent, rootPath]); + useEffect(() => { + let cancelled = false; + const dataUrl = icon?.dataUrl; + if (!dataUrl || failed) { + onAccentColorChange?.(rootPath, null); + return () => { + cancelled = true; + }; + } + deriveIconAccentColor(dataUrl).then((color) => { + if (!cancelled) onAccentColorChange?.(rootPath, color); + }).catch(() => { + if (!cancelled) onAccentColorChange?.(rootPath, null); + }); + return () => { + cancelled = true; + }; + }, [failed, icon?.dataUrl, onAccentColorChange, rootPath]); + const fallbackIcon = ( event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} > - {choosing || removing ? : iconNode} + {choosing || removing ? : iconNode} @@ -338,6 +457,7 @@ export function TopBar() { const clearProjectTransitionError = useAppStore((s) => s.clearProjectTransitionError); const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); const [recentProjects, setRecentProjects] = useState([]); + const [projectAccentColors, setProjectAccentColors] = useState>({}); const [relocatingPath, setRelocatingPath] = useState(null); const [zoom, setZoom] = useState(getStoredZoomLevel); const [syncSnapshot, setSyncSnapshot] = useState(null); @@ -521,6 +641,15 @@ export function TopBar() { const handleRemoveTab = useCallback((rootPath: string) => { void (async () => { + const target = recentProjects.find((entry) => entry.rootPath === rootPath); + const fallbackName = rootPath.split(/[\\/]/).filter(Boolean).pop() ?? rootPath; + const confirmed = confirmProjectTabRemoval( + target?.displayName ?? fallbackName, + project?.rootPath === rootPath, + target?.exists === false, + ); + if (!confirmed) return; + const shouldClose = await checkForActiveWorkloads(rootPath); if (!shouldClose) return; @@ -538,7 +667,7 @@ export function TopBar() { } } })().catch(() => { }); - }, [checkForActiveWorkloads, project?.rootPath, closeProject, switchProjectToPath]); + }, [checkForActiveWorkloads, project?.rootPath, recentProjects, closeProject, switchProjectToPath]); const handleRelocate = useCallback((oldPath: string) => { setRelocatingPath(oldPath); @@ -587,6 +716,13 @@ export function TopBar() { setDropIdx(null); }, []); + const handleProjectAccentColorChange = useCallback((rootPath: string, color: string | null) => { + setProjectAccentColors((prev) => { + if ((prev[rootPath] ?? null) === color) return prev; + return { ...prev, [rootPath]: color }; + }); + }, []); + const handlePhoneSyncDialogKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); @@ -659,13 +795,13 @@ export function TopBar() { ) : ( @@ -680,6 +816,11 @@ export function TopBar() { projectTransition?.kind === "closing" && isCurrent; const isDragging = dragIdx === idx; const isDropTarget = dropIdx === idx && dragIdx !== idx; + const projectAccentColor = projectAccentColors[rp.rootPath] ?? null; + const projectTabStyle = { + WebkitAppRegion: "no-drag", + ...(projectAccentColor ? { "--project-tab-accent": projectAccentColor } : {}), + } as React.CSSProperties; let projectTabState: string | undefined; if (isRelocating) projectTabState = "open"; else if (isMissing) projectTabState = "missing"; @@ -701,7 +842,7 @@ export function TopBar() { onDrop={(e) => handleDrop(e, idx)} onDragEnd={handleDragEnd} className={cn( - "ade-shell-project-tab group inline-flex max-w-[180px] shrink-0 items-center gap-1.5 px-2.5 py-1", + "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] shrink-0 items-center gap-2 px-3 py-0.5", "transition-[background-color,color,border-color,box-shadow,opacity] duration-150", !isMissing && "cursor-pointer", isCurrent && "font-semibold", @@ -710,7 +851,7 @@ export function TopBar() { isDragging && "opacity-40", isDropTarget && "ring-1 ring-accent/50" )} - style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} + style={projectTabStyle} onClick={() => { if (!isMissing) handleSwitchProject(rp.rootPath); }} @@ -728,9 +869,10 @@ export function TopBar() { isCurrent={isCurrent} animate={isSwitchTarget || isClosingTarget} disabled={isMissing} + onAccentColorChange={handleProjectAccentColorChange} /> {isSwitchTarget || isClosingTarget ? ( - + ) : null} {isCurrent && indicator != null && indicator !== "none" ? ( ) : ( )} @@ -812,7 +954,7 @@ export function TopBar() { {isNewTabOpen && (
{projectTransition?.kind === "opening" ? ( - + ) : ( - + )} - + {projectTransition?.kind === "opening" ? "Opening…" : "New Tab"}
)} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 63cd4798a..9278d69be 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -248,6 +248,35 @@ describe("AgentChatComposer", () => { }); }); + it("toggles Codex fast mode for supported models", () => { + const onCodexFastModeChange = vi.fn(); + renderComposer({ + sessionProvider: "codex", + modelId: "openai/gpt-5.5", + availableModelIds: ["openai/gpt-5.5"], + codexFastMode: false, + onCodexFastModeChange, + }); + + const fastButton = screen.getByRole("button", { name: "Fast mode" }); + expect(fastButton.getAttribute("aria-pressed")).toBe("false"); + + fireEvent.click(fastButton); + + expect(onCodexFastModeChange).toHaveBeenCalledWith(true); + }); + + it("hides Codex fast mode for unsupported models", () => { + renderComposer({ + sessionProvider: "codex", + modelId: "openai/gpt-5.4-mini", + availableModelIds: ["openai/gpt-5.4-mini"], + codexFastMode: true, + }); + + expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull(); + }); + it("renders Droid autonomy controls without OpenCode permission labels", () => { const onDroidPermissionModeChange = vi.fn(); renderComposer({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index f1a289d5f..cb2027217 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -25,7 +25,7 @@ import { type IosElementContextItem, type PendingInputRequest, } from "../../../shared/types"; -import { getModelById } from "../../../shared/modelRegistry"; +import { getModelById, modelSupportsFastMode } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { ProviderModelSelector } from "../shared/ProviderModelSelector"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; @@ -533,6 +533,43 @@ function PendingSteerItem({ ); } +function CodexFastModeToggle({ + active, + disabled, + onToggle, +}: { + active: boolean; + disabled?: boolean; + onToggle?: (next: boolean) => void; +}) { + return ( + + + + ); +} + export function AgentChatComposer({ surfaceMode = "standard", layoutVariant = "standard", @@ -543,6 +580,7 @@ export function AgentChatComposer({ modelId, availableModelIds, reasoningEffort, + codexFastMode = false, draft, attachments, pendingInput, @@ -571,6 +609,7 @@ export function AgentChatComposer({ messagePlaceholder, onModelChange, onReasoningEffortChange, + onCodexFastModeChange, onDraftChange, onClearDraft, onSubmit, @@ -613,6 +652,7 @@ export function AgentChatComposer({ onParallelRemoveModel, onParallelSlotModelChange, onParallelSlotReasoningChange, + onParallelSlotCodexFastModeChange, parallelLaunchBusy = false, parallelLaunchStatus = null, parallelControlSlot = null, @@ -647,6 +687,7 @@ export function AgentChatComposer({ modelId: string; availableModelIds?: string[]; reasoningEffort: string | null; + codexFastMode?: boolean; draft: string; attachments: AgentChatFileRef[]; pendingInput: PendingInputRequest | null; @@ -675,6 +716,7 @@ export function AgentChatComposer({ messagePlaceholder?: string; onModelChange: (modelId: string) => void; onReasoningEffortChange: (reasoningEffort: string | null) => void; + onCodexFastModeChange?: (enabled: boolean) => void; onDraftChange: (value: string) => void; onClearDraft?: () => void; onSubmit: () => void; @@ -715,13 +757,14 @@ export function AgentChatComposer({ sessionId?: string | null; parallelChatMode?: boolean; onParallelChatModeChange?: (enabled: boolean) => void; - parallelModelSlots?: Array<{ modelId: string; reasoningEffort: string | null }>; + parallelModelSlots?: Array<{ modelId: string; reasoningEffort: string | null; codexFastMode?: boolean }>; parallelConfiguringIndex?: number | null; onParallelConfiguringIndexChange?: (index: number | null) => void; onParallelAddModel?: () => void; onParallelRemoveModel?: (index: number) => void; onParallelSlotModelChange?: (index: number, modelId: string) => void; onParallelSlotReasoningChange?: (index: number, effort: string | null) => void; + onParallelSlotCodexFastModeChange?: (index: number, enabled: boolean) => void; parallelLaunchBusy?: boolean; parallelLaunchStatus?: string | null; parallelControlSlot?: ParallelComposerControlSlot | null; @@ -1273,6 +1316,16 @@ export function AgentChatComposer({ const opmUse = slot?.opencodePermissionMode ?? opencodePermissionMode; const dpmUse = slot?.droidPermissionMode ?? droidPermissionMode ?? "auto-low"; const cmsUse = slot?.cursorModeSnapshot ?? cursorModeSnapshot; + const fastModeModelId = + parallelChatMode && parallelConfiguringIndex != null + ? (parallelModelSlots[parallelConfiguringIndex]?.modelId ?? "") + : (modelId ?? ""); + const fastModeSupported = sp === "codex" && modelSupportsFastMode(getModelById(fastModeModelId)); + const fastModeActive = + parallelChatMode && parallelConfiguringIndex != null + ? parallelModelSlots[parallelConfiguringIndex]?.codexFastMode === true + : codexFastMode === true; + const fastModeToggleDisabled = parallelChatMode ? parallelLaunchBusy : modelSelectionLocked; const claudeSelectionMode = cpmUse === "plan" || im === "plan" ? "plan" @@ -2711,7 +2764,15 @@ export function AgentChatComposer({ onOpenAiSettings={onOpenAiSettings} compactToolbar /> - ) : !parallelChatMode ? ( + ) : null} + {parallelChatMode && parallelConfiguringIndex != null && fastModeSupported ? ( + onParallelSlotCodexFastModeChange?.(parallelConfiguringIndex, next)} + /> + ) : null} + {!parallelChatMode ? ( ) : null} + {!parallelChatMode && fastModeSupported ? ( + + ) : null} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 6c24383f5..563f9f656 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -76,6 +76,8 @@ function renderMessageList( assistantLabel?: string; initialState?: Record; showStreamingIndicator?: boolean; + sessionId?: string | null; + onInsertDraft?: (text: string) => void; onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; }, ) { @@ -85,6 +87,8 @@ function renderMessageList( events={events} assistantLabel={options?.assistantLabel} showStreamingIndicator={options?.showStreamingIndicator} + sessionId={options?.sessionId} + onInsertDraft={options?.onInsertDraft} onApproval={options?.onApproval as any} /> @@ -110,6 +114,14 @@ beforeEach(() => { }, ]), }, + builtInBrowser: { + ...(originalAde?.builtInBrowser ?? {}), + navigate: vi.fn().mockResolvedValue({ tabs: [], activeTabId: null }), + }, + terminal: { + ...(originalAde?.terminal ?? {}), + activeForChat: vi.fn().mockResolvedValue(null), + }, } as any; }); @@ -188,6 +200,62 @@ describe("AgentChatMessageList operator navigation suggestions", () => { }); describe("AgentChatMessageList transcript rendering", () => { + it("opens detected localhost command URLs in the ADE browser", async () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "command", + command: "npm run dev", + cwd: "/repo", + output: "Local: http://localhost:5173/", + itemId: "command-1", + turnId: "turn-1", + status: "running", + }, + }, + ]); + + fireEvent.click(screen.getByRole("button", { name: "Open http://localhost:5173/ in ADE browser" })); + + await waitFor(() => { + expect(globalThis.window.ade.builtInBrowser.navigate).toHaveBeenCalledWith({ + url: "http://localhost:5173/", + newTab: true, + }); + }); + }); + + it("drafts an agent request to reopen localhost servers in the chat terminal", async () => { + const onInsertDraft = vi.fn(); + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "command", + command: "npm run dev", + cwd: "/repo", + output: "Local: http://localhost:5173/", + itemId: "command-1", + turnId: "turn-1", + status: "running", + }, + }, + ], { sessionId: "session-1", onInsertDraft }); + + fireEvent.click(screen.getByRole("button", { + name: "Open terminal logs or ask the agent to run this server in the chat terminal", + })); + + await waitFor(() => { + expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("ade --socket terminal read")); + }); + expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("http://localhost:5173/")); + expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("npm run dev")); + }); + it("renders queued user messages in-thread when not a steer placeholder", async () => { renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index cb93b84d3..2ac2f8018 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -3280,6 +3280,8 @@ type EventRowProps = { onOpenWorkspacePath?: (path: string | WorkspacePathLocation) => void; onNavigateSuggestion?: (suggestion: OperatorNavigationSuggestion) => void; onReviewChanges?: () => void; + onInsertDraft?: (text: string) => void; + onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; respondingApprovalIds?: Set; pendingApprovalIds?: Set; resolvedInputStates?: Map; @@ -3301,6 +3303,8 @@ const EventRow = React.memo(function EventRow({ onOpenWorkspacePath, onNavigateSuggestion, onReviewChanges, + onInsertDraft, + onRevealChatTerminal, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, @@ -3329,6 +3333,9 @@ const EventRow = React.memo(function EventRow({ summary={envelope.event.summary} onNavigateSuggestion={onNavigateSuggestion} onUndoChanges={onReviewChanges} + onInsertDraft={onInsertDraft} + onRevealChatTerminal={onRevealChatTerminal} + sessionId={sessionId} animate={workLogAnimate} /> @@ -3503,6 +3510,8 @@ export function AgentChatMessageList({ respondingApprovalIds, pendingApprovalIds, sessionId, + onInsertDraft, + onRevealChatTerminal, sessionEnded = false, }: { events: AgentChatEventEnvelope[]; @@ -3513,6 +3522,8 @@ export function AgentChatMessageList({ surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; onOpenWorkspacePath?: (path: string, laneId?: string | null) => void; + onInsertDraft?: (text: string) => void; + onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; respondingApprovalIds?: Set; pendingApprovalIds?: Set; sessionId?: string | null; @@ -3922,6 +3933,8 @@ export function AgentChatMessageList({ onOpenWorkspacePath={openWorkspacePath} onNavigateSuggestion={handleNavigateSuggestion} onReviewChanges={handleReviewChanges} + onInsertDraft={onInsertDraft} + onRevealChatTerminal={onRevealChatTerminal} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} resolvedInputStates={resolvedInputStates} @@ -3947,13 +3960,15 @@ export function AgentChatMessageList({ onOpenWorkspacePath={openWorkspacePath} onNavigateSuggestion={handleNavigateSuggestion} onReviewChanges={handleReviewChanges} + onInsertDraft={onInsertDraft} + onRevealChatTerminal={onRevealChatTerminal} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} resolvedInputStates={resolvedInputStates} sessionId={sessionId} /> ); - }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, sessionId, sessionEnded]); + }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, sessionId, sessionEnded]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index f09a9732e..820e4b577 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import type { AgentChatEventEnvelope, @@ -10,12 +10,22 @@ import type { AgentChatSession, AgentChatSessionSummary, PrSummary, + TerminalSessionChangedEvent, + TerminalSessionDetail, } from "../../../shared/types"; import { getModelById } from "../../../shared/modelRegistry"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { useAppStore } from "../../state/appStore"; import { AgentChatPane } from "./AgentChatPane"; +vi.mock("../terminals/TerminalView", () => { + const ReactMod = require("react") as typeof import("react"); + return { + TerminalView: (props: { sessionId: string; ptyId: string }) => + ReactMod.createElement("div", { "data-testid": "terminal-view" }, `${props.sessionId}:${props.ptyId}`), + }; +}); + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -145,6 +155,7 @@ function installAdeMocks(options?: { const parallelLaunchStateGet = vi.fn().mockResolvedValue(options?.parallelLaunchState ?? null); const parallelLaunchStateSet = vi.fn().mockResolvedValue(undefined); const chatEventListeners = new Set<(event: AgentChatEventEnvelope) => void>(); + const sessionChangeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); globalThis.window.ade = { projectConfig: { @@ -202,6 +213,12 @@ function installAdeMocks(options?: { get: vi.fn().mockResolvedValue({ toolType: "codex-chat" }), readTranscriptTail: vi.fn().mockResolvedValue(options?.transcript ?? ""), getDelta: vi.fn().mockResolvedValue(null), + onChanged: vi.fn().mockImplementation((listener: (event: TerminalSessionChangedEvent) => void) => { + sessionChangeListeners.add(listener); + return () => { + sessionChangeListeners.delete(listener); + }; + }), }, computerUse: { getOwnerSnapshot: vi.fn().mockResolvedValue(null), @@ -228,6 +245,7 @@ function installAdeMocks(options?: { getForLane: vi.fn().mockResolvedValue(options?.linkedPr ?? null), }, pty: { + create: vi.fn().mockResolvedValue({ ptyId: "pty-created", sessionId: "terminal-created", pid: 1234 }), onExit: vi.fn().mockImplementation(() => () => undefined), dispose: vi.fn().mockResolvedValue(undefined), resize: vi.fn().mockResolvedValue(undefined), @@ -257,6 +275,11 @@ function installAdeMocks(options?: { listener(event); } }, + emitSessionChanged: (event: TerminalSessionChangedEvent) => { + for (const listener of sessionChangeListeners) { + listener(event); + } + }, }; } @@ -304,6 +327,7 @@ function renderPane(session: AgentChatSessionSummary) { lockSessionId={session.sessionId} hideSessionTabs initialSessionSummary={session} + onSessionCreated={vi.fn()} /> , ); @@ -388,6 +412,107 @@ function expectSessionTabOrder(expectedTitles: string[]) { } describe("AgentChatPane submit recovery", () => { + it("opens the chat terminal drawer when a CLI-created terminal belongs to the active chat", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { emitSessionChanged } = installAdeMocks({ sessions: [session] }); + const terminalSession: TerminalSessionDetail = { + id: "terminal-1", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: "pty-1", + tracked: true, + pinned: false, + manuallyNamed: false, + goal: null, + title: "CLI run", + startedAt: "2026-03-24T05:57:45.700Z", + endedAt: null, + exitCode: null, + transcriptPath: "/tmp/terminal-1.log", + headShaStart: null, + headShaEnd: null, + status: "running", + lastOutputPreview: null, + summary: null, + toolType: "shell", + runtimeState: "running", + resumeCommand: null, + resumeMetadata: null, + archivedAt: null, + chatSessionId: session.sessionId, + }; + vi.mocked(window.ade.sessions.get).mockResolvedValue(terminalSession); + + renderPane(session); + + await screen.findByRole("textbox"); + act(() => { + emitSessionChanged({ sessionId: terminalSession.id, reason: "created" }); + }); + + expect(await screen.findByText("CLI run")).toBeTruthy(); + expect(screen.getByTestId("terminal-view").textContent).toBe("terminal-1:pty-1"); + expect(window.ade.pty.create).not.toHaveBeenCalled(); + }); + + it("reveals rapid CLI-created terminals independently", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { emitSessionChanged } = installAdeMocks({ sessions: [session] }); + const terminalSession = (id: string, ptyId: string, title: string): TerminalSessionDetail => ({ + id, + laneId: "lane-1", + laneName: "Lane 1", + ptyId, + tracked: true, + pinned: false, + manuallyNamed: false, + goal: null, + title, + startedAt: "2026-03-24T05:57:45.700Z", + endedAt: null, + exitCode: null, + transcriptPath: `/tmp/${id}.log`, + headShaStart: null, + headShaEnd: null, + status: "running", + lastOutputPreview: null, + summary: null, + toolType: "shell", + runtimeState: "running", + resumeCommand: null, + resumeMetadata: null, + archivedAt: null, + chatSessionId: session.sessionId, + }); + const sessionsById = new Map([ + ["terminal-1", terminalSession("terminal-1", "pty-1", "CLI run 1")], + ["terminal-2", terminalSession("terminal-2", "pty-2", "CLI run 2")], + ]); + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(123); + vi.mocked(window.ade.sessions.get).mockImplementation(async (sessionId: string) => sessionsById.get(sessionId) ?? null); + + try { + renderPane(session); + + await screen.findByRole("textbox"); + act(() => { + emitSessionChanged({ sessionId: "terminal-1", reason: "created" }); + }); + + expect(await screen.findByText("CLI run 1")).toBeTruthy(); + act(() => { + emitSessionChanged({ sessionId: "terminal-2", reason: "created" }); + }); + + expect(await screen.findByText("CLI run 2")).toBeTruthy(); + await waitFor(() => { + expect(screen.getByTestId("terminal-view").textContent).toBe("terminal-2:pty-2"); + }); + } finally { + dateNowSpy.mockRestore(); + } + }); + it("shows a green session indicator while the agent is working", async () => { const session = buildSession("session-1"); installAdeMocks({ @@ -714,6 +839,54 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("waits for Codex fast mode updates before sending the next turn", async () => { + const session = buildSession("session-1", { + status: "idle", + codexFastMode: false, + }); + const sessions = [session]; + const resolveUpdates: Array<() => void> = []; + const updateSession = vi.fn().mockImplementation((args: any) => new Promise((resolve) => { + resolveUpdates.push(() => { + sessions[0] = { + ...sessions[0]!, + codexFastMode: args.codexFastMode ?? sessions[0]!.codexFastMode, + }; + resolve(sessions[0]); + }); + })); + const { send } = installAdeMocks({ + sessions, + }); + window.ade.agentChat.updateSession = updateSession as any; + + renderPane(session); + + fireEvent.click(await screen.findByRole("button", { name: "Fast mode" })); + + await waitFor(() => { + expect(updateSession).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + codexFastMode: true, + })); + }); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Use the faster tier." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(send).not.toHaveBeenCalled(); + + resolveUpdates[0]?.(); + await waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + text: "Use the faster tier.", + })); + }); + }); + it("persists Codex reasoning effort changes on the selected session", async () => { const session = buildSession("session-1", { status: "idle", @@ -963,6 +1136,26 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("hides chat handoff when the pane cannot open the created work chat", async () => { + const session = buildSession("session-1"); + installAdeMocks(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole("button", { name: "Handoff" })).toBeNull(); + }); + }); + it("disables chat handoff while the current turn is still active", async () => { const session = buildSession("session-1"); installAdeMocks({ @@ -1002,6 +1195,7 @@ describe("AgentChatPane submit recovery", () => { const handoffBtn = await screen.findByRole("button", { name: "Handoff" }) as HTMLButtonElement; await waitFor(() => expect(handoffBtn.disabled).toBe(false)); fireEvent.click(handoffBtn); + expect(await screen.findByText("Create opens the new work chat and sends the handoff summary as its first message.")).toBeTruthy(); fireEvent.click(await screen.findByRole("button", { name: "Create handoff chat" })); await waitFor(() => { @@ -1023,6 +1217,50 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("sends the selected handoff model and permission mode", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { handoff } = installAdeMocks({ + includeClaudeModel: true, + handoffResult: { + session: buildCreatedSession("session-2", { + provider: "claude", + model: "sonnet", + modelId: "anthropic/claude-sonnet-4-6", + interactionMode: "plan", + permissionMode: "plan", + }), + usedFallbackSummary: false, + }, + }); + + renderPane(session); + + const handoffBtn = await screen.findByRole("button", { name: "Handoff" }) as HTMLButtonElement; + await waitFor(() => expect(handoffBtn.disabled).toBe(false)); + fireEvent.click(handoffBtn); + + const handoffMenu = (await screen.findByText("Start a sibling chat on another model")).closest("[data-chat-handoff-menu='true']"); + expect(handoffMenu).toBeTruthy(); + fireEvent.click(within(handoffMenu as HTMLElement).getByRole("button", { name: "Select model" })); + const claudeLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; + fireEvent.click(await screen.findByRole("button", { name: /^Claude$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(claudeLabel), "i")); + expect(screen.getByText("Create opens the new work chat and sends the handoff summary as its first message.")).toBeTruthy(); + + const permissionSelect = await screen.findByLabelText("Claude permission mode for handoff") as HTMLSelectElement; + fireEvent.change(permissionSelect, { target: { value: "plan" } }); + fireEvent.click(await screen.findByRole("button", { name: "Create handoff chat" })); + + await waitFor(() => { + expect(handoff).toHaveBeenCalledWith(expect.objectContaining({ + sourceSessionId: session.sessionId, + targetModelId: "anthropic/claude-sonnet-4-6", + claudePermissionMode: "plan", + permissionMode: "plan", + })); + }); + }); + it("does not wait for onSessionCreated before sending the first message in a new chat", async () => { const onSessionCreated = vi.fn().mockImplementation(() => new Promise(() => {})); const { send, create } = installAdeMocks({ sessions: [] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 87c7d8f1b..f5a375b15 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; -import { Cube, Desktop, DeviceMobile, Plus } from "@phosphor-icons/react"; +import { Cube, Desktop, DeviceMobile, Lightning, Plus } from "@phosphor-icons/react"; import { inferAttachmentType, PARALLEL_CHAT_MAX_ATTACHMENTS, @@ -33,6 +33,7 @@ import { type IosElementContextItem, type IosSimulatorDrawerMode, type AiSettingsStatus, + type TerminalSessionDetail, type TerminalToolType, } from "../../../shared/types"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; @@ -45,6 +46,7 @@ import { getLocalProviderDefaultEndpoint, getModelById, getModelDescriptorForPermissionMode, + modelSupportsFastMode, parseLocalProviderFromModelId, resolveProviderGroupForModel, resolveModelDescriptorForProvider, @@ -667,6 +669,7 @@ type NativeControlState = { type ParallelModelRowState = NativeControlState & { modelId: string; reasoningEffort: string | null; + codexFastMode: boolean; executionMode: AgentChatExecutionMode; }; @@ -712,7 +715,7 @@ function runtimeFacingModelId(desc: ModelDescriptor | null | undefined, registry } function nativeControlSliceFromParallelSlot(slot: ParallelModelRowState): NativeControlState { - const { modelId: _, reasoningEffort: _re, executionMode: _em, ...native } = slot; + const { modelId: _, reasoningEffort: _re, codexFastMode: _cfm, executionMode: _em, ...native } = slot; return native; } @@ -720,6 +723,7 @@ function cloneParallelSlotFromComposer(args: { native: NativeControlState; modelId: string; reasoningEffort: string | null; + codexFastMode: boolean; executionMode: AgentChatExecutionMode; }): ParallelModelRowState { return { @@ -727,6 +731,7 @@ function cloneParallelSlotFromComposer(args: { cursorConfigValues: { ...args.native.cursorConfigValues }, modelId: args.modelId, reasoningEffort: args.reasoningEffort, + codexFastMode: args.codexFastMode, executionMode: args.executionMode, }; } @@ -1556,6 +1561,7 @@ export function AgentChatPane({ const [pendingSteersBySession, setPendingSteersBySession] = useState>({}); const [modelId, setModelId] = useState(""); const [reasoningEffort, setReasoningEffort] = useState(null); + const [codexFastMode, setCodexFastMode] = useState(false); const [executionMode, setExecutionMode] = useState("focused"); const [interactionMode, setInteractionMode] = useState(initialNativeControls.interactionMode); // Seed availableModelIds, aiStatus, and providerConnections synchronously @@ -1649,6 +1655,7 @@ export function AgentChatPane({ label: string; nonce: number; } | null>(null); + const terminalRevealNonceRef = useRef(0); const [rightPaneSplit, setRightPaneSplit] = useState(() => { try { const raw = window.sessionStorage.getItem("ade.chat.rightPaneSplit"); @@ -1703,6 +1710,7 @@ export function AgentChatPane({ const [handoffBusy, setHandoffBusy] = useState(false); const [handoffModelId, setHandoffModelId] = useState(""); const [handoffReasoningEffort, setHandoffReasoningEffort] = useState(null); + const [handoffCodexFastMode, setHandoffCodexFastMode] = useState(false); const [handoffClaudePermissionMode, setHandoffClaudePermissionMode] = useState( initialNativeControls.claudePermissionMode, ); @@ -1748,6 +1756,8 @@ export function AgentChatPane({ } | null>(null); const nativeControlUpdateCounterRef = useRef(0); const reasoningEffortUpdateCounterRef = useRef(0); + const codexFastModeUpdateCounterRef = useRef(0); + const pendingCodexFastModeUpdateRef = useRef<{ sessionId: string; updateId: number; promise: Promise } | null>(null); const pendingEventQueueRef = useRef([]); const eventsBySessionRef = useRef>({}); const eventFlushTimerRef = useRef(null); @@ -2352,6 +2362,7 @@ export function AgentChatPane({ setDroidPermissionMode(initialNativeControls.droidPermissionMode); setCursorModeId(initialNativeControls.cursorModeId); setCursorConfigValues(initialNativeControls.cursorConfigValues); + setCodexFastMode(false); return; } const nextModelId = session.modelId ?? resolveRegistryModelId(session.model); @@ -2359,6 +2370,7 @@ export function AgentChatPane({ setModelId(nextModelId); } setReasoningEffort(session.reasoningEffort ?? null); + setCodexFastMode(session.codexFastMode === true); setExecutionMode(session.executionMode ?? "focused"); setInteractionMode(session.interactionMode ?? initialNativeControls.interactionMode); setClaudePermissionMode(session.claudePermissionMode ?? initialNativeControls.claudePermissionMode); @@ -2506,6 +2518,7 @@ export function AgentChatPane({ lockSessionId && selectedSessionId && selectedSession + && onSessionCreated && handoffAvailableModelIds.length > 0 && surfaceMode === "standard" && !isPersistentIdentitySurface @@ -3162,6 +3175,44 @@ export function AgentChatPane({ selectedSessionIdRef.current = selectedSessionId; }, [selectedSessionId]); + useEffect(() => { + const sessionsApi = window.ade?.sessions; + if (!sessionsApi?.onChanged || !sessionsApi.get || !showWorkspaceChrome || hideLaneToolDrawers || !laneId) return undefined; + + let disposed = false; + const revealCreatedTerminal = async (sessionId: string) => { + let session: TerminalSessionDetail | null = null; + try { + session = await sessionsApi.get(sessionId); + } catch { + return; + } + if (disposed || !session) return; + const selectedChatSessionId = selectedSessionIdRef.current; + if (!selectedChatSessionId || session.chatSessionId !== selectedChatSessionId) return; + if (session.laneId !== laneId) return; + const ptyId = typeof session.ptyId === "string" ? session.ptyId.trim() : ""; + if (!ptyId) return; + + setTerminalDrawerOpen(true); + setTerminalRevealRequest({ + terminalId: session.id, + ptyId, + label: session.title?.trim() || "Terminal", + nonce: ++terminalRevealNonceRef.current, + }); + }; + + const unsubscribe = sessionsApi.onChanged((event) => { + if (event.reason !== "created") return; + void revealCreatedTerminal(event.sessionId); + }); + return () => { + disposed = true; + unsubscribe(); + }; + }, [hideLaneToolDrawers, laneId, showWorkspaceChrome]); + useEffect(() => { const api = window.ade?.iosSimulator; if (!api?.onEvent || hideLaneToolDrawers) return undefined; @@ -3205,7 +3256,11 @@ export function AgentChatPane({ knownSessionIdsRef.current = next; }, [initialSessionId, lockSessionId, selectedSessionId, sessions]); - useClickOutside(handoffRef, () => setHandoffOpen(false), handoffOpen); + const shouldKeepHandoffOpenForPortalClick = useCallback((target: Node) => { + return Array.from(document.querySelectorAll("[data-model-picker-panel='true']")) + .some((panel) => panel.contains(target)); + }, []); + useClickOutside(handoffRef, () => setHandoffOpen(false), handoffOpen, shouldKeepHandoffOpenForPortalClick); useEffect(() => { if (!handoffOpen) return; @@ -3222,6 +3277,7 @@ export function AgentChatPane({ useEffect(() => { if (handoffOpen && !prevHandoffOpenRef.current) { setHandoffReasoningEffort(reasoningEffort ?? null); + setHandoffCodexFastMode(codexFastMode); setHandoffClaudePermissionMode(claudePermissionMode); setHandoffCodexApprovalPolicy(codexApprovalPolicy); setHandoffCodexSandbox(codexSandbox); @@ -3780,16 +3836,18 @@ export function AgentChatPane({ native: currentNativeControls, modelId, reasoningEffort, + codexFastMode, executionMode, }), cloneParallelSlotFromComposer({ native: currentNativeControls, modelId, reasoningEffort, + codexFastMode, executionMode, }), ]); - }, [parallelChatMode, parallelModelSlots.length, currentNativeControls, modelId, reasoningEffort, executionMode]); + }, [parallelChatMode, parallelModelSlots.length, currentNativeControls, modelId, reasoningEffort, codexFastMode, executionMode]); const buildNativeControlPayload = useCallback((provider: ChatRuntimeProviderKey) => { return { @@ -3876,6 +3934,7 @@ export function AgentChatPane({ modelId, sessionProfile, reasoningEffort, + ...(provider === "codex" ? { codexFastMode } : {}), ...nativeControlPayload, }); loadedHistoryRef.current.delete(created.id); @@ -3915,7 +3974,7 @@ export function AgentChatPane({ createSessionPromiseRef.current = null; } } - }, [buildNativeControlPayload, currentNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); + }, [buildNativeControlPayload, codexFastMode, currentNativeControls, iosSimulatorOpen, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); const handoffSession = useCallback(async () => { if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return; @@ -3927,6 +3986,7 @@ export function AgentChatPane({ sourceSessionId: selectedSessionId, targetModelId: handoffModelId, reasoningEffort: handoffReasoningEffort, + ...(handoffTargetProvider === "codex" ? { codexFastMode: handoffCodexFastMode } : {}), claudePermissionMode: handoffClaudePermissionMode, codexApprovalPolicy: handoffCodexApprovalPolicy, codexSandbox: handoffCodexSandbox, @@ -3951,6 +4011,7 @@ export function AgentChatPane({ handoffClaudePermissionMode, handoffCodexApprovalPolicy, handoffCodexConfigSource, + handoffCodexFastMode, handoffCodexSandbox, handoffCursorConfigValues, handoffCursorModeId, @@ -3959,6 +4020,7 @@ export function AgentChatPane({ handoffNativePermissionMode, handoffOpenCodePermissionMode, handoffReasoningEffort, + handoffTargetProvider, notifySessionCreated, refreshSessions, selectedSession?.permissionMode, @@ -4158,6 +4220,7 @@ export function AgentChatPane({ modelId: slot.modelId, sessionProfile: resolveChatSessionProfile(), reasoningEffort: slot.reasoningEffort, + ...(provider === "codex" ? { codexFastMode: slot.codexFastMode } : {}), ...buildNativeControlPayloadForSlot(slot, provider), }); sessionByLane.set(childLane.id, created.id); @@ -4302,6 +4365,14 @@ export function AgentChatPane({ return; } } + const pendingCodexFastModeUpdate = pendingCodexFastModeUpdateRef.current; + if (selectedSessionId && pendingCodexFastModeUpdate?.sessionId === selectedSessionId) { + try { + await pendingCodexFastModeUpdate.promise; + } catch { + return; + } + } const draftSnapshot = draft; const attachmentsSnapshot = attachments; const isLiteralSlashCommand = isProviderSlashCommandInput(text); @@ -4327,6 +4398,10 @@ export function AgentChatPane({ Boolean(selectedSessionId) && Boolean(selectedSessionModelId) && selectedSessionModelId !== modelId; + const selectedCodexFastModeChanged = + Boolean(selectedSessionId) + && selectedSession?.provider === "codex" + && (selectedSession.codexFastMode === true) !== codexFastMode; const selectedAttachments = isLiteralSlashCommand ? [] : attachmentsSnapshot; const optimisticEnvelope = (nextSessionId: string): AgentChatEventEnvelope => ({ sessionId: nextSessionId, @@ -4339,7 +4414,12 @@ export function AgentChatPane({ }, }); - if (sessionId && !turnActive && (selectedModelChanged || hasComputerUseSelectionChanged || shouldPromoteLightSession)) { + if (sessionId && !turnActive && ( + selectedModelChanged + || selectedCodexFastModeChanged + || hasComputerUseSelectionChanged + || shouldPromoteLightSession + )) { setOptimisticOutgoingMessage({ sessionId, envelope: optimisticEnvelope(sessionId) }); const desc = getModelById(modelId); const provider = resolveChatRuntimeProvider(desc); @@ -4347,6 +4427,7 @@ export function AgentChatPane({ sessionId, modelId, reasoningEffort, + ...(provider === "codex" ? { codexFastMode } : {}), ...buildNativeControlPayload(provider), }); void refreshSessions().catch(() => {}); @@ -4440,6 +4521,7 @@ export function AgentChatPane({ attachments, buildNativeControlPayload, busy, + codexFastMode, createSession, draft, executionMode, @@ -4448,12 +4530,13 @@ export function AgentChatPane({ laneId, launchModeEditable, modelId, + patchSessionSummary, reasoningEffort, pendingInputsBySession, refreshAvailableModels, refreshSessions, + selectedSession, selectedSessionId, - selectedSession?.awaitingInput, selectedSessionModelId, sessionProvider, cursorRuntime, @@ -4668,6 +4751,56 @@ export function AgentChatPane({ sessionMutationKind, ]); + const handleCodexFastModeChange = useCallback((enabled: boolean) => { + const previousFastMode = codexFastMode; + setCodexFastMode(enabled); + if (!selectedSessionId) return; + if (isPersistentIdentitySurface && sessionMutationKind) return; + + const updateId = ++codexFastModeUpdateCounterRef.current; + const targetSessionId = selectedSessionId; + patchSessionSummary(targetSessionId, { codexFastMode: enabled }); + const updatePromise = window.ade.agentChat.updateSession({ + sessionId: targetSessionId, + codexFastMode: enabled, + }).then((updatedSession) => { + if (updateId !== codexFastModeUpdateCounterRef.current) return; + const reconciled = updatedSession.codexFastMode === true; + patchSessionSummary(targetSessionId, { codexFastMode: reconciled }); + if (selectedSessionIdRef.current === targetSessionId) { + setCodexFastMode(reconciled); + } + void refreshSessions().catch(() => {}); + }).catch((err) => { + if (updateId === codexFastModeUpdateCounterRef.current + && selectedSessionIdRef.current === targetSessionId) { + setCodexFastMode(previousFastMode); + patchSessionSummary(targetSessionId, { codexFastMode: previousFastMode }); + } + void refreshSessions().catch(() => {}); + setError(err instanceof Error ? err.message : String(err)); + throw err; + }).finally(() => { + const pending = pendingCodexFastModeUpdateRef.current; + if (pending?.sessionId === targetSessionId && pending.updateId === updateId) { + pendingCodexFastModeUpdateRef.current = null; + } + }); + pendingCodexFastModeUpdateRef.current = { + sessionId: targetSessionId, + updateId, + promise: updatePromise, + }; + void updatePromise.catch(() => {}); + }, [ + codexFastMode, + isPersistentIdentitySurface, + patchSessionSummary, + refreshSessions, + selectedSessionId, + sessionMutationKind, + ]); + const handleComputerUsePolicyChange = useCallback(async (_nextPolicy: unknown) => { // Computer-use policy gating has been removed; this handler is a no-op retained for UI compat. }, []); @@ -4804,7 +4937,7 @@ export function AgentChatPane({ onInsertDraft={insertComposerDraft} onShowTerminal={(terminal) => { setTerminalDrawerOpen(true); - setTerminalRevealRequest({ ...terminal, nonce: Date.now() }); + setTerminalRevealRequest({ ...terminal, nonce: ++terminalRevealNonceRef.current }); }} onAddContext={addAppControlContext} /> @@ -4977,7 +5110,7 @@ export function AgentChatPane({ Handoff {handoffOpen ? ( -
+
Start a sibling chat on another model
@@ -5040,6 +5173,23 @@ export function AgentChatPane({ + {modelSupportsFastMode(handoffTargetDescriptor) ? ( + + ) : null} {handoffCodexPermissionPreset === "custom" ? (
Session uses a custom policy; select a standard preset to apply to the new chat.
) : null} @@ -5091,6 +5241,9 @@ export function AgentChatPane({ ) : null}
) : null} +
+ Create opens the new work chat and sends the handoff summary as its first message. +
+ +
+ ); +} + export function ChatWorkLogBlock({ entries, summary: _summary, className, onNavigateSuggestion, onUndoChanges, + onInsertDraft, + onRevealChatTerminal, + sessionId, animate: _animate = true, }: { entries: ChatWorkLogEntry[]; @@ -483,6 +637,9 @@ export function ChatWorkLogBlock({ className?: string; onNavigateSuggestion?: (suggestion: OperatorNavigationSuggestion) => void; onUndoChanges?: () => void; + onInsertDraft?: (text: string) => void; + onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; + sessionId?: string | null; animate?: boolean; }) { const { readOnlyEntries, codeChangeEntries } = useMemo(() => { @@ -506,6 +663,12 @@ export function ChatWorkLogBlock({ return (
+ {hasReadOnly ? ( { + it("extracts and normalizes localhost URLs from tool output text", () => { + expect( + extractLocalhostUrlsFromText("Local: http://localhost:5173/\nNetwork: http://0.0.0.0:5173/"), + ).toEqual([ + { + url: "http://localhost:5173/", + href: "http://localhost:5173/", + host: "localhost", + port: 5173, + }, + ]); + }); + it("keeps Claude reasoning blocks split across tool boundaries", () => { const grouped = groupEvents([ { @@ -322,6 +336,79 @@ describe("chatTranscriptRows", () => { expect(rows[1]!.event.entry.changedFiles?.[0]?.diff).toContain("+ const second = true;"); }); + it("carries detected localhost URLs through merged command output deltas", () => { + const rows = collapseChatTranscriptEvents([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "command", + command: "npm run dev", + cwd: "/Users/admin/project", + output: "starting vite\n", + itemId: "command-1", + turnId: "turn-1", + status: "running", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "command", + command: "npm run dev", + cwd: "/Users/admin/project", + output: "Local: http://127.0.0.1:5173/\n", + itemId: "command-1", + turnId: "turn-1", + status: "running", + }, + }, + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]!.event.type).toBe("work_log_entry"); + if (rows[0]!.event.type !== "work_log_entry") { + throw new Error("Expected a work log entry"); + } + expect(rows[0]!.event.entry.localUrls).toEqual([ + { + url: "http://127.0.0.1:5173/", + href: "http://localhost:5173/", + host: "127.0.0.1", + port: 5173, + }, + ]); + }); + + it("detects localhost URLs from structured tool results, not only command events", () => { + const rows = collapseChatTranscriptEvents([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "tool_result", + tool: "functions.exec_command", + result: { + stdout: "server ready at http://localhost:3000/", + }, + itemId: "tool-1", + turnId: "turn-1", + status: "completed", + }, + }, + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]!.event.type).toBe("work_log_entry"); + if (rows[0]!.event.type !== "work_log_entry") { + throw new Error("Expected a work log entry"); + } + expect(rows[0]!.event.entry.localUrls?.map((url) => url.href)).toEqual([ + "http://localhost:3000/", + ]); + }); + it("groups mixed tool activity into one shared work-log block", () => { const grouped = groupEvents([ { diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index d784a8855..3baaf6287 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -4,6 +4,13 @@ export type ChatWorkLogStatus = "running" | "completed" | "failed" | "interrupte export type ChatWorkLogEntryKind = "tool" | "command" | "file_change" | "web_search"; export type ChatWorkLogEntryTone = "tool" | "info" | "error"; +export type ChatLocalhostUrl = { + url: string; + href: string; + host: string; + port: number | null; +}; + export type ChatWorkLogFileChange = { path: string; kind: Extract["kind"]; @@ -26,6 +33,7 @@ export type ChatWorkLogEntry = { args?: unknown; result?: unknown; output?: string; + localUrls?: ReadonlyArray; cwd?: string; query?: string; action?: string; @@ -88,6 +96,92 @@ export function summarizeInlineText(value: string, maxChars = 120): string { return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text; } +const LOCALHOST_URL_PATTERN = + /https?:\/\/(?localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::(?\d{1,5}))?(?[^\s<>"']*)?/giu; + +function stripTrailingUrlPunctuation(value: string): string { + let next = value; + while (/[.,)\]}]$/u.test(next)) { + next = next.slice(0, -1); + } + return next; +} + +function normalizeLocalhostHref(url: string, host: string): string { + if (host === "localhost") return url; + return url.replace(`://${host}`, "://localhost"); +} + +export function extractLocalhostUrlsFromText(text: string): ChatLocalhostUrl[] { + const urls: ChatLocalhostUrl[] = []; + const seen = new Set(); + + for (const match of text.matchAll(LOCALHOST_URL_PATTERN)) { + const host = match.groups?.host; + const portText = match.groups?.port; + if (!host) continue; + const port = portText ? Number.parseInt(portText, 10) : null; + if (port !== null && (!Number.isInteger(port) || port < 1 || port > 65_535)) continue; + + const url = stripTrailingUrlPunctuation(match[0]); + const href = normalizeLocalhostHref(url, host); + if (seen.has(href)) continue; + seen.add(href); + urls.push({ url, href, host, port }); + } + + return urls; +} + +function collectTextValues(value: unknown, depth = 0, out: string[] = []): string[] { + if (depth > 5 || out.length >= 80 || value == null) return out; + if (typeof value === "string") { + if (value.trim().length) out.push(value); + return out; + } + if (Array.isArray(value)) { + for (const entry of value) collectTextValues(entry, depth + 1, out); + return out; + } + if (typeof value === "object") { + for (const entry of Object.values(value as Record)) { + collectTextValues(entry, depth + 1, out); + } + } + return out; +} + +function appendLocalhostUrls( + target: ChatLocalhostUrl[], + seen: Set, + text: string | undefined, +): void { + if (!text) return; + for (const url of extractLocalhostUrlsFromText(text)) { + if (seen.has(url.href)) continue; + seen.add(url.href); + target.push(url); + } +} + +export function deriveLocalhostUrlsForWorkLogEntry(entry: ChatWorkLogEntry): ChatLocalhostUrl[] { + const urls: ChatLocalhostUrl[] = []; + const seen = new Set(); + appendLocalhostUrls(urls, seen, entry.command); + appendLocalhostUrls(urls, seen, entry.output); + appendLocalhostUrls(urls, seen, entry.detail); + appendLocalhostUrls(urls, seen, entry.label); + for (const value of collectTextValues(entry.args)) appendLocalhostUrls(urls, seen, value); + for (const value of collectTextValues(entry.result)) appendLocalhostUrls(urls, seen, value); + return urls; +} + +function withLocalhostUrls(entry: ChatWorkLogEntry): ChatWorkLogEntry { + const localUrls = deriveLocalhostUrlsForWorkLogEntry(entry); + const { localUrls: _previousLocalUrls, ...rest } = entry; + return localUrls.length > 0 ? { ...rest, localUrls } : rest; +} + function mergeStreamingText(existing: string, incoming: string): string { if (!existing.length) return incoming; if (!incoming.length) return existing; @@ -234,7 +328,7 @@ function buildToolWorkLogEvent( return { type: "work_log_entry", collapseKey, - entry: { + entry: withLocalhostUrls({ id: buildWorkLogEntryId(collapseKey, event), createdAt: timestamp, label: resolvedToolName, @@ -248,7 +342,7 @@ function buildToolWorkLogEvent( ...(event.itemId ? { itemId: event.itemId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.parentItemId ? { parentItemId: event.parentItemId } : {}), - }, + }), }; } @@ -260,7 +354,7 @@ function buildCommandWorkLogEvent( return { type: "work_log_entry", collapseKey, - entry: { + entry: withLocalhostUrls({ id: buildWorkLogEntryId(collapseKey, event), createdAt: timestamp, label: "Shell", @@ -272,7 +366,7 @@ function buildCommandWorkLogEvent( entryKind: "command", ...(event.itemId ? { itemId: event.itemId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), - }, + }), }; } @@ -380,7 +474,7 @@ function mergeWorkLogEntries(previous: ChatWorkLogEntry, next: ChatWorkLogEntry) ? previous.label : (next.label || previous.label); - return { + return withLocalhostUrls({ ...previous, ...next, createdAt: previous.createdAt, @@ -392,7 +486,7 @@ function mergeWorkLogEntries(previous: ChatWorkLogEntry, next: ChatWorkLogEntry) ...(changedFiles.length > 0 ? { changedFiles } : {}), ...(mergedOutput ? { output: mergedOutput } : {}), ...(result !== undefined ? { result } : {}), - }; + }); } function findMatchingWorkLogEntryIndex( diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index ba53060b8..e595f5c9f 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -5,7 +5,12 @@ import { listSessionsCached, invalidateSessionListCache } from "../../lib/sessio import { sessionStatusBucket } from "../../lib/terminalAttention"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; import { buildOptimisticChatSessionSummary, isRunOwnedSession } from "../../lib/sessions"; -import { resolveLaunchFields } from "../terminals/cliLaunch"; +import { + LAUNCH_PROFILE_TITLE, + LAUNCH_PROFILE_TOOL_TYPE, + resolveLaunchFields, + type LaunchProfile, +} from "../terminals/cliLaunch"; const EMPTY_WORK_STATE: WorkProjectViewState = { openItemIds: [], @@ -441,14 +446,14 @@ export function useLaneWorkSessions(laneId: string | null) { const launchPtySession = useCallback( async (args: { laneId: string; - profile: "claude" | "codex" | "shell"; + profile: LaunchProfile; tracked?: boolean; title?: string; startupCommand?: string; command?: string; args?: string[]; + env?: Record; }) => { - const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" } as const; // resolveLaunchFields treats the caller's launch overrides as atomic: // if any of startupCommand/command/args is supplied we don't mix in // defaults from the other fields (which used to override the caller's @@ -459,14 +464,15 @@ export function useLaneWorkSessions(laneId: string | null) { ...(args.startupCommand !== undefined ? { startupCommand: args.startupCommand } : {}), ...(args.command !== undefined ? { command: args.command } : {}), ...(args.args !== undefined ? { args: args.args } : {}), + ...(args.env !== undefined ? { env: args.env } : {}), }); const result = await window.ade.pty.create({ laneId: args.laneId, cols: 100, rows: 30, - title: args.title ?? titleMap[args.profile], + title: args.title ?? LAUNCH_PROFILE_TITLE[args.profile], tracked: args.tracked ?? true, - toolType: args.profile, + toolType: LAUNCH_PROFILE_TOOL_TYPE[args.profile], ...launchFields, }); selectLane(args.laneId); diff --git a/apps/desktop/src/renderer/components/shared/permissionOptions.test.ts b/apps/desktop/src/renderer/components/shared/permissionOptions.test.ts index d4311fe55..cd03edfde 100644 --- a/apps/desktop/src/renderer/components/shared/permissionOptions.test.ts +++ b/apps/desktop/src/renderer/components/shared/permissionOptions.test.ts @@ -85,6 +85,23 @@ describe("getPermissionOptions", () => { expect(options.map((o) => o.value)).toEqual(["plan", "edit", "default", "full-auto"]); }); + it("returns Cursor Agent options for CLI-wrapped cursor", () => { + const options = getPermissionOptions({ family: "cursor", isCliWrapped: true }); + expect(options).toHaveLength(4); + expect(options.map((o) => o.value)).toEqual(["default", "plan", "edit", "full-auto"]); + const force = options.find((o) => o.value === "full-auto")!; + expect(force.label).toBe("Force"); + expect(force.safety).toBe("danger"); + }); + + it("returns OpenCode CLI options for CLI-wrapped opencode (incl. config-toml)", () => { + const options = getPermissionOptions({ family: "opencode", isCliWrapped: true }); + expect(options).toHaveLength(5); + expect(options.map((o) => o.value)).toEqual(["default", "plan", "edit", "full-auto", "config-toml"]); + const configToml = options.find((o) => o.value === "config-toml")!; + expect(configToml.safety).toBe("custom"); + }); + it("returns API/local options for CLI-wrapped codex family (family normalization only applies to guarded mode)", () => { // 'codex' as a family string does not match the openai CLI branch (which checks opts.family === "openai") const options = getPermissionOptions({ family: "codex", isCliWrapped: true }); @@ -165,6 +182,11 @@ describe("familyToPermissionKey", () => { expect(familyToPermissionKey("factory", true)).toBe("droid"); }); + it("maps CLI-wrapped cursor to 'cursor', falls back to 'opencode' off-CLI", () => { + expect(familyToPermissionKey("cursor", true)).toBe("cursor"); + expect(familyToPermissionKey("cursor", false)).toBe("opencode"); + }); + it("maps everything else to 'opencode'", () => { expect(familyToPermissionKey("anthropic", false)).toBe("opencode"); expect(familyToPermissionKey("openai", false)).toBe("opencode"); @@ -177,6 +199,7 @@ describe("permissionFamilyLabel", () => { it("returns human-readable labels for all keys", () => { expect(permissionFamilyLabel("claude")).toBe("Claude Code workers"); expect(permissionFamilyLabel("codex")).toBe("Codex workers"); + expect(permissionFamilyLabel("cursor")).toBe("Cursor workers"); expect(permissionFamilyLabel("droid")).toBe("Droid workers"); expect(permissionFamilyLabel("opencode")).toBe("OpenCode workers"); }); diff --git a/apps/desktop/src/renderer/components/shared/permissionOptions.ts b/apps/desktop/src/renderer/components/shared/permissionOptions.ts index c1357dd67..6d2f4a1ec 100644 --- a/apps/desktop/src/renderer/components/shared/permissionOptions.ts +++ b/apps/desktop/src/renderer/components/shared/permissionOptions.ts @@ -209,6 +209,98 @@ export function getPermissionOptions(opts: { ]; } + // Cursor Agent CLI + if (opts.isCliWrapped && opts.family === "cursor") { + return [ + { + value: "default", + label: "Agent", + shortDesc: "Cursor Agent's normal approval flow", + detail: "Starts Cursor Agent in its default coding mode. The CLI asks before terminal commands and follows Cursor's configured workspace policy.", + allows: ["File reads", "Edits through Cursor Agent", "Command proposals with approval"], + gates: ["Terminal commands unless allowed by Cursor policy"], + safety: "semi-auto", + }, + { + value: "plan", + label: "Plan", + shortDesc: "Read-only planning mode", + detail: "Starts Cursor Agent with --mode plan for analysis and planning without edits.", + allows: ["Code search", "File reads", "Plan generation"], + blocks: ["File edits", "Command execution"], + safety: "safe", + }, + { + value: "edit", + label: "Ask", + shortDesc: "Read-only Q&A mode", + detail: "Starts Cursor Agent with --mode ask for explanations and questions without code changes.", + allows: ["Questions", "Explanations", "Read-only context use"], + blocks: ["File edits", "Command execution"], + safety: "safe", + }, + { + value: "full-auto", + label: "Force", + shortDesc: "Cursor --force / yolo mode", + detail: "Starts Cursor Agent with --force so commands are allowed unless explicitly denied by Cursor policy.", + allows: ["Edits and commands allowed by Cursor policy"], + warning: "Use only when you trust the lane and Cursor workspace policy.", + safety: "danger", + }, + ]; + } + + // OpenCode CLI + if (opts.isCliWrapped && opts.family === "opencode") { + return [ + { + value: "default", + label: "Ask", + shortDesc: "Ask before tool actions", + detail: "Starts OpenCode with an inline permission policy that asks before tool actions.", + allows: ["Reads and tool calls after approval"], + gates: ["Bash, edits, and other tools"], + safety: "safe", + }, + { + value: "plan", + label: "Plan", + shortDesc: "OpenCode plan agent", + detail: "Starts OpenCode in its plan agent, which disables write, edit, patch, and bash tools by default.", + allows: ["Read-only analysis", "Plan generation"], + blocks: ["File writes", "Edits", "Patch application", "Shell commands"], + safety: "safe", + }, + { + value: "edit", + label: "Edit", + shortDesc: "Allow edits; ask for the rest", + detail: "Starts OpenCode with edit permission allowed while other tool actions still ask.", + allows: ["File edits"], + gates: ["Bash and other tools"], + safety: "semi-auto", + }, + { + value: "full-auto", + label: "Allow", + shortDesc: "Allow configured OpenCode tools", + detail: "Starts OpenCode with inline permission set to allow. OpenCode still respects explicit denies in agent or project configuration.", + allows: ["Configured OpenCode tools without prompts"], + warning: "Only use in trusted or isolated lanes.", + safety: "danger", + }, + { + value: "config-toml", + label: "Config", + shortDesc: "Use OpenCode config", + detail: "No inline permission environment is passed. OpenCode uses opencode.json and your global configuration.", + allows: ["Determined by OpenCode config"], + safety: "custom", + }, + ]; + } + // API and local models return [ { diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 1c9b6f30e..de37e53d5 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -437,7 +437,13 @@ export function TerminalsPage() { setWorkSidebarTab("browser"); }; window.addEventListener(ADE_OPEN_BUILT_IN_BROWSER_EVENT, openBrowserSidebar); - return () => window.removeEventListener(ADE_OPEN_BUILT_IN_BROWSER_EVENT, openBrowserSidebar); + const unsubscribeBrowserEvents = window.ade?.builtInBrowser?.onEvent?.((event) => { + if (event.type === "open-request") openBrowserSidebar(); + }) ?? null; + return () => { + window.removeEventListener(ADE_OPEN_BUILT_IN_BROWSER_EVENT, openBrowserSidebar); + unsubscribeBrowserEvents?.(); + }; }, [setViewMode, setWorkSidebarTab]); const expandSessionsPane = useCallback(() => { diff --git a/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx b/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx index f9d08e306..d38104eed 100644 --- a/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx +++ b/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx @@ -50,7 +50,10 @@ const LOGO_MAP: Partial>> = { "codex-chat": CodexLogo, "codex-orchestrated": CodexLogo, cursor: CursorAgentLogo, + "cursor-cli": CursorAgentLogo, + droid: DroidLogo, "droid-chat": DroidLogo, + opencode: OpenCodeLogo, "opencode-chat": OpenCodeLogo, "opencode-orchestrated": OpenCodeLogo, shell: ShellLogo, diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index 48cd4c471..9eeb876dc 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Terminal, ArrowRight, @@ -9,11 +9,11 @@ import { useAppStore } from "../../state/appStore"; import { AgentChatPane } from "../chat/AgentChatPane"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { LaneCombobox } from "./LaneCombobox"; -import { COLORS } from "../lanes/laneDesignTokens"; -import { buildTrackedCliLaunchCommand, type CliProvider } from "./cliLaunch"; -import { ClaudeLogo, CodexLogo } from "./ToolLogos"; +import { buildTrackedCliLaunchCommand, type CliProvider, type LaunchProfile } from "./cliLaunch"; +import { ClaudeLogo, CodexLogo, CursorAgentLogo, OpenCodeLogo } from "./ToolLogos"; import { SmartTooltip } from "../ui/SmartTooltip"; import { cn } from "../ui/cn"; +import { DroidLogo } from "../shared/ProviderLogos"; type WorkStartSurfaceProps = { draftKind: WorkDraftKind; @@ -21,15 +21,41 @@ type WorkStartSurfaceProps = { onOpenChatSession: (session: AgentChatSession) => void | Promise; onLaunchPtySession: (args: { laneId: string; - profile: "claude" | "codex" | "shell"; + profile: LaunchProfile; title?: string; startupCommand?: string; command?: string; args?: string[]; + env?: Record; tracked?: boolean; }) => Promise; }; +type CliProviderOption = { + id: CliProvider; + label: string; + shortLabel: string; + family: string; + defaultPermission: AgentChatPermissionMode; + Logo: React.FC<{ size?: number; className?: string }>; +}; + +const CLI_PROVIDER_OPTIONS: Record = { + claude: { id: "claude", label: "Claude Code", shortLabel: "Claude", family: "anthropic", defaultPermission: "default", Logo: ClaudeLogo }, + codex: { id: "codex", label: "Codex CLI", shortLabel: "Codex", family: "openai", defaultPermission: "default", Logo: CodexLogo }, + cursor: { id: "cursor", label: "Cursor Agent CLI", shortLabel: "Cursor", family: "cursor", defaultPermission: "default", Logo: CursorAgentLogo }, + droid: { id: "droid", label: "Factory Droid CLI", shortLabel: "Droid", family: "factory", defaultPermission: "edit", Logo: DroidLogo }, + opencode: { id: "opencode", label: "OpenCode CLI", shortLabel: "OpenCode", family: "opencode", defaultPermission: "edit", Logo: OpenCodeLogo }, +}; + +const CLI_PROVIDER_LIST: CliProviderOption[] = [ + CLI_PROVIDER_OPTIONS.claude, + CLI_PROVIDER_OPTIONS.codex, + CLI_PROVIDER_OPTIONS.cursor, + CLI_PROVIDER_OPTIONS.droid, + CLI_PROVIDER_OPTIONS.opencode, +]; + export function WorkStartSurface({ draftKind, lanes, @@ -74,20 +100,27 @@ export function WorkStartSurface({ }, [globallySelectedLaneId, lanes, selectedLaneId, selectLaneGlobal]); const cliPermissionOptions = useMemo( - () => - getPermissionOptions({ - family: cliProvider === "claude" ? "anthropic" : "openai", - isCliWrapped: true, - }), + () => getPermissionOptions({ + family: CLI_PROVIDER_OPTIONS[cliProvider].family, + isCliWrapped: true, + }), [cliProvider], ); useEffect(() => { - const defaultPermission = "default"; if (!cliPermissionOptions.some((option) => option.value === cliPermissionMode)) { - setCliPermissionMode(defaultPermission); + const providerDefault = CLI_PROVIDER_OPTIONS[cliProvider].defaultPermission; + const fallback = cliPermissionOptions.some((option) => option.value === providerDefault) + ? providerDefault + : cliPermissionOptions[0]?.value ?? "default"; + setCliPermissionMode(fallback); } - }, [cliPermissionMode, cliPermissionOptions, cliProvider]); + }, [cliProvider, cliPermissionMode, cliPermissionOptions]); + + const selectCliProvider = useCallback((provider: CliProvider) => { + setCliProvider(provider); + setCliPermissionMode(CLI_PROVIDER_OPTIONS[provider].defaultPermission); + }, []); useEffect(() => { if (draftKind !== "chat") { @@ -114,10 +147,11 @@ export function WorkStartSurface({ await onLaunchPtySession({ laneId: selectedLaneId, profile: cliProvider, - title: cliProvider === "claude" ? "Claude CLI" : "Codex CLI", + title: CLI_PROVIDER_OPTIONS[cliProvider].label, startupCommand: launch.startupCommand, - command: launch.command, + ...(launch.command !== undefined ? { command: launch.command } : {}), args: launch.args, + ...(launch.env ? { env: launch.env } : {}), }); } finally { setLaunchBusy(false); @@ -197,23 +231,20 @@ export function WorkStartSurface({
{/* Provider toggle */} -
- {([ - { id: "claude" as const, label: "Claude Code", Logo: ClaudeLogo }, - { id: "codex" as const, label: "Codex CLI", Logo: CodexLogo }, - ] as const).map((opt) => { +
+ {CLI_PROVIDER_LIST.map((opt) => { const active = cliProvider === opt.id; return ( ); @@ -266,7 +297,7 @@ export function WorkStartSurface({ disabled={!selectedLaneId || launchBusy} onClick={() => void launchCli()} > - Open {cliProvider === "claude" ? "Claude Code" : "Codex CLI"} + Open {CLI_PROVIDER_OPTIONS[cliProvider].label} diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index a564f99ee..a625bfc50 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -32,7 +32,7 @@ import { SmartTooltip } from "../ui/SmartTooltip"; import { useFloatingPaneEmbeddedChrome, type FloatingPaneEmbeddedChrome } from "../ui/FloatingPane"; import { PaneTilingLayout, type PaneConfig } from "../ui/PaneTilingLayout"; import { cn } from "../ui/cn"; -import { resolveTrackedCliResumeCommand } from "./cliLaunch"; +import { resolveTrackedCliResumeCommand, type LaunchProfile } from "./cliLaunch"; import { buildWorkSessionTilingTree, type TilingPreset } from "./workSessionTiling"; import { laneSurfaceTint } from "../lanes/laneDesignTokens"; @@ -224,7 +224,7 @@ const MODE_OPTIONS: Array<{ Icon: typeof Chats; }> = [ { kind: "chat", label: "Chat", description: "Compose a new ADE chat in this lane.", Icon: Chats }, - { kind: "cli", label: "CLI", description: "Start a tracked Claude Code or Codex CLI session.", Icon: Code }, + { kind: "cli", label: "CLI", description: "Start a tracked agent CLI session.", Icon: Code }, { kind: "shell", label: "Shell", description: "Open a plain terminal shell in this lane's worktree.", Icon: Terminal }, ]; @@ -452,11 +452,12 @@ export function WorkViewArea({ onOpenChatSession: (session: AgentChatSession) => void | Promise; onLaunchPtySession: (args: { laneId: string; - profile: "claude" | "codex" | "shell"; + profile: LaunchProfile; title?: string; startupCommand?: string; command?: string; args?: string[]; + env?: Record; tracked?: boolean; }) => Promise; onShowDraftKind: (kind: WorkDraftKind) => void; diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 5de546082..6115eb14f 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -47,6 +47,12 @@ describe("defaultTrackedCliStartupCommand", () => { it("returns 'codex --no-alt-screen' for codex provider", () => { expect(defaultTrackedCliStartupCommand("codex")).toBe("codex --no-alt-screen"); }); + + it("returns launch binaries for the other tracked CLI providers", () => { + expect(defaultTrackedCliStartupCommand("cursor")).toBe("cursor-agent"); + expect(defaultTrackedCliStartupCommand("droid")).toBe("droid"); + expect(defaultTrackedCliStartupCommand("opencode")).toBe("opencode"); + }); }); describe("buildTrackedCliStartupCommand", () => { @@ -147,13 +153,44 @@ describe("buildTrackedCliStartupCommand", () => { }); }); + describe("additional CLI providers", () => { + it("launches Cursor through a pre-created resumable chat", () => { + const launch = buildTrackedCliLaunchCommand({ provider: "cursor", permissionMode: "plan" }); + expect(launch.command).toBeUndefined(); + expect(launch.startupCommand).toContain("cursor-agent create-chat"); + expect(launch.startupCommand).toContain("cursor-agent --mode plan --resume \"$ADE_CURSOR_CHAT_ID\""); + expect(launch.startupCommand).toContain("Resume with cursor-agent --resume"); + }); + + it("launches Droid with process-local autonomy settings", () => { + const launch = buildTrackedCliLaunchCommand({ provider: "droid", permissionMode: "edit" }); + expect(launch.command).toBeUndefined(); + expect(launch.startupCommand).toContain("ade-droid-settings"); + expect(launch.startupCommand).toContain("\\\"autonomyLevel\\\":\\\"low\\\""); + expect(launch.startupCommand).toContain("droid --settings \"$ADE_DROID_SETTINGS\""); + }); + + it("launches OpenCode with inline permission config", () => { + const launch = buildTrackedCliLaunchCommand({ provider: "opencode", permissionMode: "full-auto" }); + expect(launch.command).toBe("opencode"); + expect(launch.env?.OPENCODE_CONFIG_CONTENT).toBe("{\"permission\":\"allow\"}"); + expect(launch.startupCommand).toContain("OPENCODE_CONFIG_CONTENT=\"{\\\"permission\\\":\\\"allow\\\"}\" opencode"); + }); + }); + it("covers all AgentChatPermissionMode values for both providers", () => { const modes = ["default", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; for (const mode of modes) { const claude = buildTrackedCliStartupCommand({ provider: "claude", permissionMode: mode }); const codex = buildTrackedCliStartupCommand({ provider: "codex", permissionMode: mode }); + const cursor = buildTrackedCliStartupCommand({ provider: "cursor", permissionMode: mode }); + const droid = buildTrackedCliStartupCommand({ provider: "droid", permissionMode: mode }); + const opencode = buildTrackedCliStartupCommand({ provider: "opencode", permissionMode: mode }); expect(claude.length).toBeGreaterThan(0); expect(codex.length).toBeGreaterThan(0); + expect(cursor.length).toBeGreaterThan(0); + expect(droid.length).toBeGreaterThan(0); + expect(opencode.length).toBeGreaterThan(0); } }); }); @@ -173,6 +210,20 @@ describe("tracked CLI resume helpers", () => { targetId: "thread-99", launch: { permissionMode: "edit" }, })).toBe("codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted resume thread-99"); + + expect(buildTrackedCliResumeCommand({ + provider: "cursor", + targetKind: "session", + targetId: "chat-99", + launch: { permissionMode: "full-auto" }, + })).toBe("cursor-agent --force --resume chat-99"); + + expect(buildTrackedCliResumeCommand({ + provider: "opencode", + targetKind: "session", + targetId: "ses_99", + launch: { permissionMode: "plan" }, + })).toContain("opencode --agent plan --session ses_99"); }); it("falls back to the provider resume picker when the concrete target is missing", () => { @@ -189,6 +240,20 @@ describe("tracked CLI resume helpers", () => { targetId: null, launch: { permissionMode: "full-auto" }, })).toBe("codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox resume"); + + expect(buildTrackedCliResumeCommand({ + provider: "cursor", + targetKind: "session", + targetId: null, + launch: { permissionMode: "plan" }, + })).toBe("cursor-agent --mode plan --continue"); + + expect(buildTrackedCliResumeCommand({ + provider: "droid", + targetKind: "session", + targetId: null, + launch: { permissionMode: "default" }, + })).toContain("droid --settings \"$ADE_DROID_SETTINGS\" --resume"); }); it("prefers structured metadata over the legacy resume command string", () => { diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.ts index 5ad6ad609..ff5926e24 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.ts @@ -2,15 +2,38 @@ import type { AgentChatPermissionMode, TerminalResumeMetadata, TerminalSessionSummary, + TerminalToolType, } from "../../../shared/types"; import { ADE_CLI_AGENT_GUIDANCE, ADE_CLI_INLINE_GUIDANCE } from "../../../shared/adeCliGuidance"; -import { commandArrayToLine } from "../../lib/shell"; +import { commandArrayToLine, quoteShellArg } from "../../lib/shell"; -export type CliProvider = "claude" | "codex"; +export type CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; +export type LaunchProfile = CliProvider | "shell"; export type TrackedCliLaunchCommand = { - command: CliProvider; + command?: string; args: string[]; startupCommand: string; + env?: Record; +}; + +/** Maps a `launchPtySession` profile to the `TerminalToolType` recorded on the session. */ +export const LAUNCH_PROFILE_TOOL_TYPE: Record = { + claude: "claude", + codex: "codex", + cursor: "cursor-cli", + droid: "droid", + opencode: "opencode", + shell: "shell", +}; + +/** Default human-readable tab title for a launch profile. */ +export const LAUNCH_PROFILE_TITLE: Record = { + claude: "Claude Code", + codex: "Codex", + cursor: "Cursor Agent CLI", + droid: "Factory Droid CLI", + opencode: "OpenCode CLI", + shell: "Shell", }; export function withCodexNoAltScreen(command: string): string { @@ -23,10 +46,14 @@ export function withCodexNoAltScreen(command: string): string { } export function defaultTrackedCliStartupCommand(provider: CliProvider): string { - return provider === "codex" ? withCodexNoAltScreen("codex") : "claude"; + if (provider === "codex") return withCodexNoAltScreen("codex"); + if (provider === "cursor") return "cursor-agent"; + if (provider === "droid") return "droid"; + if (provider === "opencode") return "opencode"; + return "claude"; } -function workTabCodexPreamblePrompt(): string { +function workTabCliPreamblePrompt(): string { return [ "ADE session guidance. Treat this as operating guidance for the CLI session, keep it in mind for future user messages, and wait for the user's next instruction before taking action.", "", @@ -51,7 +78,7 @@ export function buildTrackedCliLaunchCommand(args: { }): TrackedCliLaunchCommand { if (args.provider === "claude") { const commandArgs: string[] = []; - // Inject --session-id so we know the Claude session ID upfront for resume + // Inject --session-id so we know the Claude session ID upfront for resume. if (args.sessionId) { commandArgs.push("--session-id", args.sessionId); } @@ -64,15 +91,49 @@ export function buildTrackedCliLaunchCommand(args: { }; } - const commandArgs: string[] = [ - "--no-alt-screen", - ...permissionModeToCodexFlags(args.permissionMode), - workTabCodexPreamblePrompt(), - ]; + if (args.provider === "codex") { + const commandArgs: string[] = [ + "--no-alt-screen", + ...permissionModeToCodexFlags(args.permissionMode), + workTabCliPreamblePrompt(), + ]; + return { + command: "codex", + args: commandArgs, + startupCommand: commandArrayToLine(["codex", ...commandArgs]), + }; + } + + if (args.provider === "cursor") { + const prompt = workTabCliPreamblePrompt(); + const commandArgs = [...permissionModeToCursorFlags(args.permissionMode), prompt]; + const startupCommand = buildCursorPrecreatedChatCommand({ + permissionMode: args.permissionMode, + prompt, + }); + return { + args: commandArgs, + startupCommand, + }; + } + + if (args.provider === "droid") { + const prompt = workTabCliPreamblePrompt(); + return { + args: [...permissionModeToDroidExecFlags(args.permissionMode), prompt], + startupCommand: buildDroidCommandLine({ permissionMode: args.permissionMode, prompt }), + }; + } + + const opencode = buildOpenCodeCommandParts({ + permissionMode: args.permissionMode, + prompt: workTabCliPreamblePrompt(), + }); return { - command: "codex", - args: commandArgs, - startupCommand: commandArrayToLine(["codex", ...commandArgs]), + command: "opencode", + args: opencode.args, + startupCommand: opencode.startupCommand, + ...(opencode.env ? { env: opencode.env } : {}), }; } @@ -91,19 +152,156 @@ function permissionModeToCodexFlags(permissionMode: AgentChatPermissionMode | nu return []; } +function permissionModeToCursorFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + if (permissionMode === "full-auto") return ["--force"]; + if (permissionMode === "plan") return ["--mode", "plan"]; + if (permissionMode === "edit") return ["--mode", "ask"]; + return []; +} + +function permissionModeToDroidExecFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + if (permissionMode === "full-auto") return ["--auto", "high"]; + if (permissionMode === "default") return ["--auto", "medium"]; + if (permissionMode === "edit") return ["--auto", "low"]; + return []; +} + +function droidSettingsJson(permissionMode: AgentChatPermissionMode | null | undefined): string { + const sessionDefaultSettings = (() => { + if (permissionMode === "full-auto") return { interactionMode: "auto", autonomyLevel: "high" }; + if (permissionMode === "default") return { interactionMode: "auto", autonomyLevel: "medium" }; + if (permissionMode === "edit") return { interactionMode: "auto", autonomyLevel: "low" }; + return { interactionMode: "spec", autonomyLevel: "off" }; + })(); + return JSON.stringify({ sessionDefaultSettings }); +} + +function buildDroidCommandLine(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + prompt?: string; + resumeTarget?: string | null; +}): string { + const settingsJson = droidSettingsJson(args.permissionMode); + const droidArgs = ["droid", "--settings", "$ADE_DROID_SETTINGS"]; + if (args.resumeTarget !== undefined) { + droidArgs.push("--resume"); + if (args.resumeTarget) droidArgs.push(args.resumeTarget); + } + if (args.prompt) droidArgs.push(args.prompt); + const droidCommand = commandArrayToLine(droidArgs) + .replace(quoteShellArg("$ADE_DROID_SETTINGS"), "\"$ADE_DROID_SETTINGS\""); + return [ + "ADE_DROID_SETTINGS=\"$(mktemp \"${TMPDIR:-/tmp}/ade-droid-settings.XXXXXX.json\")\"", + `printf %s ${quoteShellArg(settingsJson)} > "$ADE_DROID_SETTINGS"`, + `${droidCommand}; ADE_DROID_STATUS=$?; rm -f "$ADE_DROID_SETTINGS"; exit $ADE_DROID_STATUS`, + ].join(" && "); +} + +function buildCursorPrecreatedChatCommand(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + prompt: string; +}): string { + const commandArgs = [ + "cursor-agent", + ...permissionModeToCursorFlags(args.permissionMode), + "--resume", + "$ADE_CURSOR_CHAT_ID", + args.prompt, + ]; + const command = commandArrayToLine(commandArgs) + .replace(quoteShellArg("$ADE_CURSOR_CHAT_ID"), "\"$ADE_CURSOR_CHAT_ID\""); + return [ + "ADE_CURSOR_CHAT_ID=\"$(cursor-agent create-chat)\"", + "[ -n \"$ADE_CURSOR_CHAT_ID\" ] || { echo \"[ADE] cursor-agent create-chat returned no chat id\" >&2; exit 1; }", + "printf %s\\\\n \"[ADE] Resume with cursor-agent --resume ${ADE_CURSOR_CHAT_ID}\"", + command, + ].join(" && "); +} + +const OPENCODE_INLINE_CONFIG_ENV = "OPENCODE_CONFIG_CONTENT"; + +function openCodePermissionValue(permissionMode: AgentChatPermissionMode | null | undefined): string | Record | null { + if (permissionMode === "config-toml") return null; + if (permissionMode === "full-auto") return "allow"; + if (permissionMode === "edit") return { "*": "ask", edit: "allow" }; + if (permissionMode === "plan") return { "*": "ask", edit: "deny", bash: "deny" }; + return { "*": "ask" }; +} + +function openCodeConfigEnv(permissionMode: AgentChatPermissionMode | null | undefined): string | null { + const permission = openCodePermissionValue(permissionMode); + return permission ? JSON.stringify({ permission }) : null; +} + +function openCodeEnvAssignment(permissionMode: AgentChatPermissionMode | null | undefined): string { + const config = openCodeConfigEnv(permissionMode); + return config ? `${OPENCODE_INLINE_CONFIG_ENV}=${quoteShellArg(config)} ` : ""; +} + +function permissionModeToOpenCodeArgs(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + return permissionMode === "plan" ? ["--agent", "plan"] : []; +} + +function buildOpenCodeCommandParts(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + prompt?: string; + resumeTarget?: string | null; + continueLast?: boolean; +}): { args: string[]; startupCommand: string; env?: Record } { + const commandArgs = [...permissionModeToOpenCodeArgs(args.permissionMode)]; + if (args.resumeTarget) { + commandArgs.push("--session", args.resumeTarget); + } else if (args.continueLast) { + commandArgs.push("--continue"); + } + if (args.prompt) commandArgs.push("--prompt", args.prompt); + const config = openCodeConfigEnv(args.permissionMode); + return { + args: commandArgs, + startupCommand: `${openCodeEnvAssignment(args.permissionMode)}${commandArrayToLine(["opencode", ...commandArgs])}`, + ...(config ? { env: { [OPENCODE_INLINE_CONFIG_ENV]: config } } : {}), + }; +} + export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata): string { const targetId = metadata.targetId?.trim() ?? ""; if (metadata.provider === "claude") { const parts = ["claude", ...permissionModeToClaudeFlag(metadata.launch.permissionMode)]; parts.push("--resume"); if (targetId) parts.push(targetId); - return parts.join(" "); + return commandArrayToLine(parts); + } + + if (metadata.provider === "codex") { + const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(metadata.launch.permissionMode)]; + parts.push("resume"); + if (targetId) parts.push(targetId); + return commandArrayToLine(parts); + } + + if (metadata.provider === "cursor") { + const parts = ["cursor-agent", ...permissionModeToCursorFlags(metadata.launch.permissionMode)]; + if (targetId) { + parts.push("--resume", targetId); + } else { + parts.push("--continue"); + } + return commandArrayToLine(parts); + } + + if (metadata.provider === "droid") { + return buildDroidCommandLine({ + permissionMode: metadata.launch.permissionMode, + resumeTarget: targetId || null, + }); } - const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(metadata.launch.permissionMode)]; - parts.push("resume"); - if (targetId) parts.push(targetId); - return parts.join(" "); + const opencode = buildOpenCodeCommandParts({ + permissionMode: metadata.launch.permissionMode, + resumeTarget: targetId || null, + continueLast: !targetId, + }); + return opencode.startupCommand; } export function resolveTrackedCliResumeCommand(session: Pick): string | null { @@ -122,23 +320,26 @@ export function resolveTrackedCliResumeCommand(session: Pick(args: { +export function resolveLaunchFields

(args: { profile: P; permissionMode?: AgentChatPermissionMode; startupCommand?: string; command?: string; args?: string[]; -}): { startupCommand?: string; command?: string; args?: string[] } { + env?: Record; +}): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { const callerHasOverride = args.startupCommand !== undefined || args.command !== undefined - || args.args !== undefined; + || args.args !== undefined + || args.env !== undefined; if (callerHasOverride) { return { ...(args.startupCommand !== undefined ? { startupCommand: args.startupCommand } : {}), ...(args.command !== undefined ? { command: args.command } : {}), ...(args.args !== undefined ? { args: args.args } : {}), + ...(args.env !== undefined ? { env: args.env } : {}), }; } @@ -150,7 +351,8 @@ export function resolveLaunchFields

(args }); return { startupCommand: defaultLaunch.startupCommand, - command: defaultLaunch.command, + ...(defaultLaunch.command !== undefined ? { command: defaultLaunch.command } : {}), args: defaultLaunch.args, + ...(defaultLaunch.env ? { env: defaultLaunch.env } : {}), }; } diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 3145947ea..5575b7ce6 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -18,6 +18,9 @@ import { resolveLaunchFields, resolveTrackedCliResumeCommand, withCodexNoAltScreen, + LAUNCH_PROFILE_TITLE, + LAUNCH_PROFILE_TOOL_TYPE, + type LaunchProfile, } from "./cliLaunch"; import { sortLanesForTabs } from "../lanes/laneUtils"; @@ -234,9 +237,12 @@ export function buildWorkTabGroupModel(args: { } function inferToolFromResumeCommand(command: string): string | null { - const n = command.trim().toLowerCase(); + const n = command.trim().toLowerCase().replace(/^(?:[a-z_][a-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)+/, ""); if (n.startsWith("claude ")) return "claude"; if (n.startsWith("codex ")) return "codex"; + if (n.startsWith("cursor-agent ")) return "cursor-cli"; + if (n.startsWith("droid ")) return "droid"; + if (n.startsWith("opencode ")) return "opencode"; return null; } @@ -1105,19 +1111,14 @@ export function useWorkSessions() { const launchPtySession = useCallback( async (args: { laneId: string; - profile: "claude" | "codex" | "shell"; + profile: LaunchProfile; tracked?: boolean; title?: string; startupCommand?: string; command?: string; args?: string[]; + env?: Record; }) => { - const toolTypeMap = { - claude: "claude" as const, - codex: "codex" as const, - shell: "shell" as const, - }; - const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" }; // resolveLaunchFields preserves caller intent: any caller-supplied // startupCommand/command/args is used as-is, never mixed with defaults // from the other fields. Only when the caller passes none of them do @@ -1127,14 +1128,15 @@ export function useWorkSessions() { ...(args.startupCommand !== undefined ? { startupCommand: args.startupCommand } : {}), ...(args.command !== undefined ? { command: args.command } : {}), ...(args.args !== undefined ? { args: args.args } : {}), + ...(args.env !== undefined ? { env: args.env } : {}), }); const result = await window.ade.pty.create({ laneId: args.laneId, cols: 100, rows: 30, - title: args.title ?? titleMap[args.profile], + title: args.title ?? LAUNCH_PROFILE_TITLE[args.profile], tracked: args.tracked ?? true, - toolType: toolTypeMap[args.profile], + toolType: LAUNCH_PROFILE_TOOL_TYPE[args.profile], ...launchFields, }); selectLane(args.laneId); diff --git a/apps/desktop/src/renderer/hooks/useClickOutside.ts b/apps/desktop/src/renderer/hooks/useClickOutside.ts index 96b996361..2549d14b9 100644 --- a/apps/desktop/src/renderer/hooks/useClickOutside.ts +++ b/apps/desktop/src/renderer/hooks/useClickOutside.ts @@ -9,15 +9,19 @@ export function useClickOutside( ref: RefObject, onClose: () => void, active: boolean, + shouldIgnore?: (target: Node) => boolean, ): void { useEffect(() => { if (!active) return; const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { + const target = e.target; + if (!(target instanceof Node)) return; + if (shouldIgnore?.(target)) return; + if (ref.current && !ref.current.contains(target)) { onClose(); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); - }, [active, ref, onClose]); + }, [active, ref, onClose, shouldIgnore]); } diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index f22b87053..d2bf2097c 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -132,21 +132,21 @@ --shell-project-tab-hover-bg: rgba(255, 255, 255, 0.06); --shell-project-tab-hover-fg: rgba(255, 255, 255, 0.85); --shell-project-tab-hover-border: rgba(255, 255, 255, 0.08); - --shell-project-tab-active-bg: rgba(167, 139, 250, 0.15); - --shell-project-tab-active-fg: #e0d4ff; - --shell-project-tab-active-border: rgba(167, 139, 250, 0.25); + --shell-project-tab-active-bg: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 22%, rgba(255, 255, 255, 0.03)); + --shell-project-tab-active-fg: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 24%, #ffffff); + --shell-project-tab-active-border: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 44%, transparent); --shell-project-tab-missing-bg: rgba(239, 68, 68, 0.08); --shell-project-tab-missing-fg: color-mix(in srgb, var(--color-error) 82%, var(--color-fg) 18%); --shell-project-tab-missing-border: rgba(239, 68, 68, 0.2); - --shell-project-tab-open-bg: rgba(167, 139, 250, 0.1); + --shell-project-tab-open-bg: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 14%, transparent); --shell-project-tab-open-fg: rgba(255, 255, 255, 0.85); - --shell-project-tab-open-border: rgba(167, 139, 250, 0.2); - --shell-project-tab-focus-bg: rgba(167, 139, 250, 0.08); + --shell-project-tab-open-border: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 32%, transparent); + --shell-project-tab-focus-bg: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 12%, transparent); --shell-project-tab-focus-fg: rgba(255, 255, 255, 0.85); - --shell-project-tab-focus-border: rgba(167, 139, 250, 0.2); - --shell-project-tab-focus-ring: rgba(167, 139, 250, 0.15); + --shell-project-tab-focus-border: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 32%, transparent); + --shell-project-tab-focus-ring: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 24%, transparent); --shell-project-tab-font-family: var(--font-sans); - --shell-project-tab-font-size: 11px; + --shell-project-tab-font-size: 12px; --shell-project-tab-letter-spacing: 0; --shell-control-bg: rgba(255, 255, 255, 0.03); @@ -264,19 +264,19 @@ --shell-project-tab-hover-bg: color-mix(in srgb, var(--color-muted) 86%, transparent); --shell-project-tab-hover-fg: var(--color-fg); --shell-project-tab-hover-border: color-mix(in srgb, var(--color-accent) 24%, var(--color-border)); - --shell-project-tab-active-bg: var(--color-accent); - --shell-project-tab-active-fg: var(--color-accent-fg); - --shell-project-tab-active-border: color-mix(in srgb, var(--color-accent) 52%, transparent); + --shell-project-tab-active-bg: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 18%, var(--color-surface-raised)); + --shell-project-tab-active-fg: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 34%, var(--color-fg)); + --shell-project-tab-active-border: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 48%, transparent); --shell-project-tab-missing-bg: color-mix(in srgb, var(--color-error) 9%, transparent); --shell-project-tab-missing-fg: color-mix(in srgb, var(--color-error) 86%, var(--color-fg) 14%); --shell-project-tab-missing-border: color-mix(in srgb, var(--color-error) 38%, transparent); - --shell-project-tab-open-bg: color-mix(in srgb, var(--color-accent) 12%, transparent); + --shell-project-tab-open-bg: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 12%, transparent); --shell-project-tab-open-fg: var(--color-fg); - --shell-project-tab-open-border: color-mix(in srgb, var(--color-accent) 44%, var(--color-border)); - --shell-project-tab-focus-bg: color-mix(in srgb, var(--color-accent) 10%, transparent); + --shell-project-tab-open-border: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 42%, var(--color-border)); + --shell-project-tab-focus-bg: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 10%, transparent); --shell-project-tab-focus-fg: var(--color-fg); - --shell-project-tab-focus-border: color-mix(in srgb, var(--color-accent) 36%, var(--color-border)); - --shell-project-tab-focus-ring: color-mix(in srgb, var(--color-accent) 26%, transparent); + --shell-project-tab-focus-border: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 36%, var(--color-border)); + --shell-project-tab-focus-ring: color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 26%, transparent); --shell-control-bg: var(--color-surface-recessed); --shell-control-fg: color-mix(in srgb, var(--color-muted-fg) 86%, var(--color-fg) 14%); @@ -424,6 +424,7 @@ h6 { } .ade-shell-project-tab { + --project-tab-accent: var(--color-accent); font-family: var(--shell-project-tab-font-family); font-size: var(--shell-project-tab-font-size); letter-spacing: var(--shell-project-tab-letter-spacing); @@ -432,7 +433,7 @@ h6 { border: 1px solid transparent; border-radius: 8px; outline: none; - transition: color 120ms ease, background 120ms ease, border-color 120ms ease; + transition: color 120ms ease, background 120ms ease, border-color 120ms ease, box-shadow 120ms ease; } .ade-shell-project-tab:hover { @@ -445,6 +446,7 @@ h6 { color: var(--shell-project-tab-active-fg); background: var(--shell-project-tab-active-bg); border-color: var(--shell-project-tab-active-border); + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 55%, transparent); } .ade-shell-project-tab[data-state="missing"] { @@ -466,6 +468,12 @@ h6 { box-shadow: inset 0 0 0 1px var(--shell-project-tab-focus-ring); } +.ade-shell-project-tab[data-state="active"]:focus-visible { + box-shadow: + inset 0 0 0 1px var(--shell-project-tab-focus-ring), + inset 0 -1px 0 color-mix(in srgb, var(--project-tab-accent, var(--color-accent)) 55%, transparent); +} + .ade-shell-control { color: var(--shell-control-fg); background: var(--shell-control-bg); diff --git a/apps/desktop/src/renderer/lib/sessions.test.ts b/apps/desktop/src/renderer/lib/sessions.test.ts index 7348f655b..db5c3b1fc 100644 --- a/apps/desktop/src/renderer/lib/sessions.test.ts +++ b/apps/desktop/src/renderer/lib/sessions.test.ts @@ -35,6 +35,9 @@ describe("isChatToolType", () => { expect(isChatToolType("run-shell")).toBe(false); expect(isChatToolType("claude")).toBe(false); expect(isChatToolType("codex")).toBe(false); + expect(isChatToolType("cursor-cli")).toBe(false); + expect(isChatToolType("droid")).toBe(false); + expect(isChatToolType("opencode")).toBe(false); }); }); @@ -69,6 +72,8 @@ describe("shortToolTypeLabel", () => { it("returns exact labels for known single-name tools", () => { expect(shortToolTypeLabel("cursor")).toBe("Cursor"); + expect(shortToolTypeLabel("cursor-cli")).toBe("Cursor"); + expect(shortToolTypeLabel("droid")).toBe("Droid"); expect(shortToolTypeLabel("aider")).toBe("Aider"); expect(shortToolTypeLabel("continue")).toBe("Continue"); }); diff --git a/apps/desktop/src/renderer/lib/sessions.ts b/apps/desktop/src/renderer/lib/sessions.ts index 273c942f7..2c5d9b664 100644 --- a/apps/desktop/src/renderer/lib/sessions.ts +++ b/apps/desktop/src/renderer/lib/sessions.ts @@ -60,6 +60,9 @@ export function defaultSessionLabel(toolType: string | null | undefined): string if (toolType === "codex-chat") return "Codex chat"; if (toolType === "opencode-chat") return "OpenCode chat"; if (toolType === "cursor") return "Cursor chat"; + if (toolType === "cursor-cli") return "Cursor CLI session"; + if (toolType === "droid") return "Droid CLI session"; + if (toolType === "opencode") return "OpenCode CLI session"; if (toolType === "droid-chat") return "Droid chat"; if (toolType === "claude") return "Claude session"; if (toolType === "codex") return "Codex session"; @@ -103,6 +106,9 @@ const SHORT_TOOL_TYPE_LABELS: Record = { shell: "Shell", "run-shell": "Run", cursor: "Cursor", + "cursor-cli": "Cursor", + droid: "Droid", + opencode: "OpenCode", aider: "Aider", continue: "Continue", }; @@ -138,6 +144,9 @@ export function formatToolTypeLabel(toolType: string | null | undefined): string if (toolType === "codex-chat") return "Codex chat"; if (toolType === "opencode-chat") return "OpenCode chat"; if (toolType === "cursor") return "Cursor chat"; + if (toolType === "cursor-cli") return "Cursor CLI session"; + if (toolType === "droid") return "Droid CLI session"; + if (toolType === "opencode") return "OpenCode CLI session"; if (toolType === "droid-chat") return "Droid chat"; if (toolType === "claude") return "Claude session"; if (toolType === "codex") return "Codex session"; diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index c6a500cea..dc3120988 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -42,6 +42,9 @@ const NEEDS_INPUT_PATTERNS: RegExp[] = [ const IDLE_ATTENTION_TOOL_TYPES = new Set([ "claude", "codex", + "cursor-cli", + "droid", + "opencode", "claude-orchestrated", "codex-orchestrated", "opencode-orchestrated", diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 13dcdfa7b..fe547c8e6 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -18,7 +18,7 @@ export const ADE_CLI_AGENT_GUIDANCE = [ "- For App Control terminal/log questions, check `ade --socket app-control status --text`, then prefer `ade --socket app-control logs --text --max-bytes 8388608`, `ade --socket app-control terminal write --data \"y\\n\"`, or `ade --socket app-control terminal signal --signal SIGINT`. Only fall back to `ade --socket terminal list --text` / `terminal read` when no App Control terminal is active.", "- ADE sets `ADE_APP_CONTROL_CDP_PORT` and `ADE_APP_CONTROL_DEBUG_FLAGS` for App Control launches. Custom Electron launchers should forward one of those values to `--remote-debugging-port`.", "- After App Control launch/connect, use `ade --socket app-control snapshot --text` or `elements --text`; use Control mode or `click`/`type` for input; use Inspect mode or `select --x --y ` to attach screenshot-backed DOM/selector/source context to chat.", - "- For ADE browser work, prefer socket mode so CLI calls and the Work sidebar share the same global browser tabs: `ade help browser`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `ade --socket browser switch --tab --text`, `ade --socket browser screenshot --text`, and `ade --socket browser inspect-start --text` / `ade --socket browser select-current --text` / `ade --socket browser clear-selection --text`. The ADE browser is global rather than lane-scoped; links from chat output and localhost URLs in chat terminals should open there by default.", + "- For ADE browser work, prefer socket mode so CLI calls and the Work sidebar share the same global browser tabs: `ade help browser`, `ade --socket browser panel --text`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `ade --socket browser switch --tab --text`, `ade --socket browser screenshot --text`, and `ade --socket browser inspect-start --text` / `ade --socket browser select-current --text` / `ade --socket browser clear-selection --text`. The ADE browser is global rather than lane-scoped; links from chat output and localhost URLs in chat terminals should open there by default.", "", "### iOS Simulator and Preview Lab", "- For iOS work inside an ADE chat, start with `ade help ios-sim`, `ade --socket ios-sim status --text`, `ade --socket ios-sim devices --text`, and `ade --socket ios-sim apps --device --text`, then launch with `ade --socket ios-sim launch --target --text`.", @@ -49,6 +49,6 @@ export const ADE_CLI_INLINE_GUIDANCE = [ "- Use `--socket` when the CLI and ADE desktop drawer need the same live state: App Control, iOS Simulator, Preview Lab, terminal logs, selections/context, and proof drawer updates.", "- iOS Simulator and Preview Lab control is only supported from ADE desktop chats. For iOS work use `ade help ios-sim`, `ade --socket ios-sim status --text`, `ade --socket ios-sim snapshot --text`, `ade --socket ios-sim select --x --y `, `ade --socket ios-sim stream-status --text`, and Preview Lab commands such as `ade --socket ios-sim preview-status --text`, `ade --socket ios-sim previews --source --text`, `ade --socket ios-sim preview-render --source --index --text`.", "- For App Control on Electron apps use `ade help app-control`, `ade --socket app-control status --text`, `ade --socket app-control launch --command ... --text`, `ade --socket app-control logs --text --max-bytes 8388608`, `ade --socket app-control snapshot --text`, `ade --socket app-control select --x --y `, `ade --socket app-control click`, and `ade --socket app-control type`.", - "- For ADE browser use `ade help browser`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `ade --socket browser switch --tab --text`, `ade --socket browser screenshot --text`, and `ade --socket browser inspect-start --text` / `ade --socket browser select-current --text`. The ADE browser is global, not lane-scoped.", + "- For ADE browser use `ade help browser`, `ade --socket browser panel --text`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `ade --socket browser switch --tab --text`, `ade --socket browser screenshot --text`, and `ade --socket browser inspect-start --text` / `ade --socket browser select-current --text`. The ADE browser is global, not lane-scoped.", "- If a live app/simulator/session is missing, report the exact blocker instead of guessing. When asked for proof, register artifacts with `ade proof ...` so they appear in the ADE proof drawer, and clean up old, stale, or finished processes before leaving the task.", ].join("\n"); diff --git a/apps/desktop/src/shared/adeLayout.ts b/apps/desktop/src/shared/adeLayout.ts index 7f5cdf520..848cc933c 100644 --- a/apps/desktop/src/shared/adeLayout.ts +++ b/apps/desktop/src/shared/adeLayout.ts @@ -59,6 +59,7 @@ export const ADE_LAYOUT_DEFINITIONS: AdePathEntryDefinition[] = [ { relativePath: "skills", kind: "tracked", pathType: "directory" }, { relativePath: "workflows", kind: "tracked", pathType: "directory", notes: ["Repo-backed workflow/config files live here when present."] }, { relativePath: "workflows/linear", kind: "tracked", pathType: "directory", notes: ["Stable Linear workflow definitions are tracked when authored."] }, + { relativePath: "project-icons", kind: "tracked", pathType: "directory", notes: ["Imported project icons are tracked when a shared icon override points at them."] }, { relativePath: "agents", kind: "ignored", pathType: "directory" }, { relativePath: "context", kind: "ignored", pathType: "directory" }, { relativePath: "memory", kind: "ignored", pathType: "directory" }, @@ -138,38 +139,25 @@ export function resolveAdeLayout(projectRoot: string): AdeLayoutPaths { export function buildAdeGitignore(): string { return [ - "# Machine-local ADE state", - "local.yaml", - "local.secret.yaml", - "ade.db", - "ade.db-*", - "ade.db-wal", - "embeddings.db", - "ade.sock", - "artifacts/", - "transcripts/", - "cache/", - "worktrees/", - "secrets/", + "# ADE ignores local runtime state by default.", + "*", "", - "# Local-only generated runtime docs/state", - "agents/", - "cto/CURRENT.md", - "cto/MEMORY.md", - "cto/core-memory.json", - "cto/daily/", - "cto/sessions.jsonl", - "cto/subordinate-activity.jsonl", - "cto/openclaw-history.json", - "cto/openclaw-idempotency.json", - "cto/openclaw-outbox.json", - "cto/openclaw-routes.json", - "cto/openclaw-device.json", - "context/", - "memory/", - "history/", - "reflections/", - "context/*.ade.md", + "# Shared ADE project config", + "!.gitignore", + "!ade.yaml", + "!cto/", + "!cto/identity.yaml", + "", + "# Shared user-authored ADE assets", + "!templates/", + "!templates/**", + "!skills/", + "!skills/**", + "!workflows/", + "!workflows/linear/", + "!workflows/linear/**", + "!project-icons/", + "!project-icons/**", "", ].join("\n"); } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index b458d0dfc..9229c1f70 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -219,6 +219,7 @@ export const IPC = { appControlAttachToTarget: "ade.appControl.attachToTarget", appControlEvent: "ade.appControl.event", builtInBrowserGetStatus: "ade.builtInBrowser.getStatus", + builtInBrowserShowPanel: "ade.builtInBrowser.showPanel", builtInBrowserSetBounds: "ade.builtInBrowser.setBounds", builtInBrowserAttachWebview: "ade.builtInBrowser.attachWebview", builtInBrowserNavigate: "ade.builtInBrowser.navigate", diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index e1b46b3f2..636bd3094 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -42,6 +42,7 @@ export type ModelDescriptor = { maxOutputTokens: number; capabilities: ModelCapabilities; reasoningTiers?: string[]; + serviceTiers?: string[]; color: string; providerRoute: string; providerModelId: string; @@ -84,6 +85,19 @@ export function isModelProviderGroup(value: string | null | undefined): value is return value === "claude" || value === "codex" || value === "opencode" || value === "cursor" || value === "droid"; } +export function modelSupportsServiceTier( + descriptor: ModelDescriptor | null | undefined, + tier: string, +): boolean { + const normalizedTier = tier.trim().toLowerCase(); + if (!normalizedTier) return false; + return Boolean(descriptor?.serviceTiers?.some((entry) => entry.trim().toLowerCase() === normalizedTier)); +} + +export function modelSupportsFastMode(descriptor: ModelDescriptor | null | undefined): boolean { + return modelSupportsServiceTier(descriptor, "fast"); +} + // --------------------------------------------------------------------------- // Registry data // --------------------------------------------------------------------------- @@ -208,6 +222,7 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ maxOutputTokens: 128_000, capabilities: ALL_CAPS, reasoningTiers: ["low", "medium", "high", "xhigh"], + serviceTiers: ["fast"], color: "#10A37F", providerRoute: "codex-cli", providerModelId: "gpt-5.5", @@ -226,6 +241,7 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ maxOutputTokens: 128_000, capabilities: ALL_CAPS, reasoningTiers: ["low", "medium", "high", "xhigh"], + serviceTiers: ["fast"], color: "#10A37F", providerRoute: "codex-cli", providerModelId: "gpt-5.4", diff --git a/apps/desktop/src/shared/types/builtInBrowser.ts b/apps/desktop/src/shared/types/builtInBrowser.ts index 655194161..5880a287a 100644 --- a/apps/desktop/src/shared/types/builtInBrowser.ts +++ b/apps/desktop/src/shared/types/builtInBrowser.ts @@ -20,6 +20,7 @@ export type BuiltInBrowserNavigateArgs = { url: string; tabId?: string | null; newTab?: boolean; + openPanel?: boolean; }; export type BuiltInBrowserTab = { @@ -49,11 +50,18 @@ export type BuiltInBrowserStatus = { export type BuiltInBrowserTabArgs = { tabId: string; + openPanel?: boolean; }; export type BuiltInBrowserCreateTabArgs = { url?: string | null; activate?: boolean; + openPanel?: boolean; +}; + +export type BuiltInBrowserOpenPanelArgs = { + url?: string | null; + tabId?: string | null; }; export type BuiltInBrowserSelectPointArgs = { @@ -91,6 +99,13 @@ export type BuiltInBrowserSelectResult = { export type BuiltInBrowserEventPayload = | { type: "status"; status: BuiltInBrowserStatus } + | { + type: "open-request"; + status: BuiltInBrowserStatus; + url: string | null; + tabId: string | null; + requestedAt: string; + } | { type: "selection"; item: BuiltInBrowserContextItem } | { type: "selection-cleared"; item: null; clearedAt: string } | { type: "error"; message: string; occurredAt: string }; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 8d262e6f8..b64945ddb 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -523,6 +523,7 @@ export type AgentChatSession = { modelId?: ModelId; sessionProfile?: AgentChatSessionProfile; reasoningEffort?: string | null; + codexFastMode?: boolean; executionMode?: AgentChatExecutionMode | null; permissionMode?: AgentChatPermissionMode; interactionMode?: AgentChatInteractionMode | null; @@ -567,6 +568,7 @@ export type AgentChatSessionSummary = { title?: string | null; goal?: string | null; reasoningEffort?: string | null; + codexFastMode?: boolean; executionMode?: AgentChatExecutionMode | null; permissionMode?: AgentChatPermissionMode; interactionMode?: AgentChatInteractionMode | null; @@ -645,6 +647,7 @@ export type AgentChatModelInfo = { description?: string | null; isDefault: boolean; reasoningEfforts?: Array<{ effort: string; description: string }>; + serviceTiers?: string[]; maxThinkingTokens?: number | null; // OpenCode-backed model metadata modelId?: ModelId; @@ -698,6 +701,7 @@ export type AgentChatCreateArgs = { title?: string | null; sessionProfile?: AgentChatSessionProfile; reasoningEffort?: string | null; + codexFastMode?: boolean; permissionMode?: AgentChatPermissionMode; interactionMode?: AgentChatInteractionMode | null; claudePermissionMode?: AgentChatClaudePermissionMode; @@ -725,6 +729,7 @@ export type AgentChatHandoffArgs = { * session the same way as a legacy handoff. */ reasoningEffort?: string | null; + codexFastMode?: boolean; claudePermissionMode?: AgentChatClaudePermissionMode; codexApprovalPolicy?: AgentChatCodexApprovalPolicy; codexSandbox?: AgentChatCodexSandbox; @@ -895,6 +900,7 @@ export type AgentChatUpdateSessionArgs = { manuallyNamed?: boolean; modelId?: ModelId; reasoningEffort?: string | null; + codexFastMode?: boolean; permissionMode?: AgentChatPermissionMode; interactionMode?: AgentChatInteractionMode | null; claudePermissionMode?: AgentChatClaudePermissionMode; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index eecc3e1e4..5ae533fd2 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -17,6 +17,9 @@ export type TerminalToolType = | "run-shell" | "claude" | "codex" + | "cursor-cli" + | "droid" + | "opencode" | "claude-orchestrated" | "codex-orchestrated" | "opencode-orchestrated" @@ -31,7 +34,7 @@ export type TerminalToolType = export type TerminalRuntimeState = "running" | "waiting-input" | "idle" | "exited" | "killed"; -export type TerminalResumeProvider = "claude" | "codex"; +export type TerminalResumeProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; export type TerminalResumeTargetKind = "session" | "thread"; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 40cb53f82..87abff793 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -552,6 +552,7 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var title: String? var goal: String? var reasoningEffort: String? + var codexFastMode: Bool? var executionMode: String? var permissionMode: String? var interactionMode: String? @@ -1071,6 +1072,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { var modelId: String? var sessionProfile: String? var reasoningEffort: String? + var codexFastMode: Bool? var executionMode: String? var permissionMode: String? var interactionMode: String? @@ -1107,6 +1109,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { case modelId case sessionProfile case reasoningEffort + case codexFastMode case executionMode case permissionMode case interactionMode @@ -1145,6 +1148,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { modelId = try container.decodeIfPresent(String.self, forKey: .modelId) sessionProfile = try container.decodeIfPresent(String.self, forKey: .sessionProfile) reasoningEffort = try container.decodeIfPresent(String.self, forKey: .reasoningEffort) + codexFastMode = try container.decodeIfPresent(Bool.self, forKey: .codexFastMode) executionMode = try container.decodeIfPresent(String.self, forKey: .executionMode) permissionMode = try container.decodeIfPresent(String.self, forKey: .permissionMode) interactionMode = try container.decodeIfPresent(String.self, forKey: .interactionMode) @@ -1182,6 +1186,7 @@ struct AgentChatSession: Codable, Identifiable, Equatable { try container.encodeIfPresent(modelId, forKey: .modelId) try container.encodeIfPresent(sessionProfile, forKey: .sessionProfile) try container.encodeIfPresent(reasoningEffort, forKey: .reasoningEffort) + try container.encodeIfPresent(codexFastMode, forKey: .codexFastMode) try container.encodeIfPresent(executionMode, forKey: .executionMode) try container.encodeIfPresent(permissionMode, forKey: .permissionMode) try container.encodeIfPresent(interactionMode, forKey: .interactionMode) @@ -1821,6 +1826,7 @@ struct AgentChatUpdateSessionRequest: Codable, Equatable { var title: String? var modelId: String? var reasoningEffort: String? + var codexFastMode: Bool? var permissionMode: String? var interactionMode: String? var claudePermissionMode: String? @@ -1862,6 +1868,7 @@ struct AgentChatModelInfo: Codable, Equatable, Identifiable { var description: String? var isDefault: Bool var reasoningEfforts: [AgentChatModelReasoningEffort]? + var serviceTiers: [String]? var maxThinkingTokens: Int? var modelId: String? var family: String? @@ -1870,6 +1877,18 @@ struct AgentChatModelInfo: Codable, Equatable, Identifiable { var color: String? } +extension AgentChatModelInfo { + /// Mirrors desktop `modelSupportsServiceTier` — case-insensitive lookup so + /// callers don't have to normalize before checking, e.g. "Fast" vs "fast". + func supportsServiceTier(_ tier: String) -> Bool { + let needle = tier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !needle.isEmpty else { return false } + return serviceTiers?.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == needle }) == true + } + + var supportsCodexFastMode: Bool { supportsServiceTier("fast") } +} + struct AgentChatModelCatalogModel: Codable, Equatable, Identifiable { var id: String var runtimeModelId: String @@ -1880,6 +1899,7 @@ struct AgentChatModelCatalogModel: Codable, Equatable, Identifiable { var description: String? var isDefault: Bool var reasoningEfforts: [AgentChatModelReasoningEffort]? + var serviceTiers: [String]? var maxThinkingTokens: Int? var modelId: String? var family: String? diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index a9d7f6bc7..ed1cab985 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3412,6 +3412,7 @@ final class SyncService: ObservableObject { provider: String, model: String = "", reasoningEffort: String? = nil, + codexFastMode: Bool? = nil, sessionProfile: String? = nil, permissionMode: String? = nil, interactionMode: String? = nil, @@ -3437,6 +3438,9 @@ final class SyncService: ObservableObject { if let reasoningEffort, !reasoningEffort.isEmpty { args["reasoningEffort"] = reasoningEffort } + if let codexFastMode { + args["codexFastMode"] = codexFastMode + } if let sessionProfile, !sessionProfile.isEmpty { args["sessionProfile"] = sessionProfile } @@ -3582,6 +3586,7 @@ final class SyncService: ObservableObject { title: String? = nil, modelId: String? = nil, reasoningEffort: String? = nil, + codexFastMode: Bool? = nil, permissionMode: String? = nil, interactionMode: String? = nil, claudePermissionMode: String? = nil, @@ -3602,6 +3607,7 @@ final class SyncService: ObservableObject { title: title, modelId: modelId, reasoningEffort: reasoningEffort, + codexFastMode: codexFastMode, permissionMode: permissionMode, interactionMode: interactionMode, claudePermissionMode: claudePermissionMode, diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index bcbe324ce..377bc5de6 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -48,6 +48,7 @@ private enum WorkPreviewData { title: "Fix iOS Work tab lag", goal: "Make the Work tab responsive on iPhone", reasoningEffort: nil, + codexFastMode: nil, executionMode: nil, permissionMode: "edit", interactionMode: "default", @@ -332,6 +333,7 @@ private enum WorkPreviewData { title: title, goal: goal, reasoningEffort: nil, + codexFastMode: nil, executionMode: nil, permissionMode: "edit", interactionMode: "default", diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift index c98653343..6473a33e2 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift @@ -47,6 +47,8 @@ extension WorkSessionSettingsSheet { let normalizedReasoning = selectedReasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) let reasoningPayload = normalizedReasoning.isEmpty ? "" : normalizedReasoning let reasoningChanged = reasoningPayload != resolvedInitialReasoningEffort + let effectiveCodexFastMode = supportsCodexFastModeToggle ? selectedCodexFastMode : false + let codexFastModeChanged = summary.provider == "codex" && effectiveCodexFastMode != resolvedInitialCodexFastMode let initialRuntimeMode = workInitialRuntimeMode(summary) let initialCursorModeId = workInitialCursorModeId(summary) @@ -107,7 +109,7 @@ extension WorkSessionSettingsSheet { break } - guard titleChanged || modelChanged || reasoningChanged || runtimeChanged else { + guard titleChanged || modelChanged || reasoningChanged || runtimeChanged || codexFastModeChanged else { dismiss() return } @@ -119,6 +121,7 @@ extension WorkSessionSettingsSheet { title: titleChanged ? trimmedTitle : nil, modelId: modelChanged ? selectedModelId : nil, reasoningEffort: reasoningChanged ? reasoningPayload : nil, + codexFastMode: codexFastModeChanged ? effectiveCodexFastMode : nil, permissionMode: permissionMode, interactionMode: interactionMode, claudePermissionMode: claudePermissionMode, diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift index 5bc2e4ab2..30fe79b51 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift @@ -17,6 +17,7 @@ struct WorkSessionSettingsSheet: View { @State var selectedReasoningEffort: String @State var selectedRuntimeMode: String @State var selectedCursorModeId: String + @State var selectedCodexFastMode: Bool @State var busy = false @State var errorMessage: String? @@ -35,6 +36,7 @@ struct WorkSessionSettingsSheet: View { _selectedReasoningEffort = State(initialValue: summary.reasoningEffort ?? "") _selectedRuntimeMode = State(initialValue: workInitialRuntimeMode(summary)) _selectedCursorModeId = State(initialValue: workInitialCursorModeId(summary)) + _selectedCodexFastMode = State(initialValue: summary.codexFastMode ?? false) } var selectedModel: AgentChatModelInfo? { @@ -54,6 +56,14 @@ struct WorkSessionSettingsSheet: View { summary.reasoningEffort ?? "" } + var resolvedInitialCodexFastMode: Bool { + summary.codexFastMode ?? false + } + + var supportsCodexFastModeToggle: Bool { + summary.provider == "codex" && (selectedModel?.supportsCodexFastMode == true) + } + var runtimeOptions: [WorkRuntimeOption] { switch summary.provider { case "claude": @@ -231,6 +241,24 @@ struct WorkSessionSettingsSheet: View { } } + if supportsCodexFastModeToggle { + GlassSection(title: "Speed") { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: $selectedCodexFastMode) { + VStack(alignment: .leading, spacing: 2) { + Text("Fast mode") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Routes Codex turns to the fast service tier (uses extra usage credits).") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + } + .tint(ADEColor.accent) + } + } + } + if summary.provider == "cursor", !cursorModeOptions.isEmpty { GlassSection(title: "Cursor mode") { VStack(alignment: .leading, spacing: 12) { @@ -310,6 +338,9 @@ struct WorkSessionSettingsSheet: View { } else { selectedReasoningEffort = "" } + if !supportsCodexFastModeToggle { + selectedCodexFastMode = false + } } .task { await loadModels() diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 563364bf1..326103b22 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1007,6 +1007,7 @@ final class ADETests: XCTestCase { title: "Review run", modelId: "claude-sonnet-4", reasoningEffort: "high", + codexFastMode: true, permissionMode: "edit", interactionMode: "plan", claudePermissionMode: "default", @@ -1018,6 +1019,7 @@ final class ADETests: XCTestCase { )) XCTAssertEqual(update["modelId"] as? String, "claude-sonnet-4") XCTAssertEqual(update["permissionMode"] as? String, "edit") + XCTAssertEqual(update["codexFastMode"] as? Bool, true) let computerUse = update["computerUse"] as? [String: Any] XCTAssertEqual(computerUse?["enabled"] as? Bool, true) } @@ -4627,6 +4629,76 @@ final class ADETests: XCTestCase { XCTAssertEqual(summary.requestedCwd, "apps/ios/ADE") } + func testAgentChatSessionDecodesCodexFastModeFlag() throws { + let payload: [String: Any] = [ + "sessionId": "chat-fast", + "laneId": "lane-1", + "provider": "codex", + "model": "gpt-5.4", + "reasoningEffort": "high", + "codexFastMode": true, + "status": "active", + "createdAt": "2026-03-25T00:00:00.000Z", + "lastActivityAt": "2026-03-25T00:00:01.000Z", + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let session = try JSONDecoder().decode(AgentChatSession.self, from: data) + XCTAssertEqual(session.codexFastMode, true) + + let summaryPayload: [String: Any] = [ + "sessionId": "chat-fast", + "laneId": "lane-1", + "provider": "codex", + "model": "gpt-5.4", + "codexFastMode": false, + "status": "active", + "startedAt": "2026-03-25T00:00:00.000Z", + "lastActivityAt": "2026-03-25T00:00:01.000Z", + ] + let summaryData = try JSONSerialization.data(withJSONObject: summaryPayload) + let summary = try JSONDecoder().decode(AgentChatSessionSummary.self, from: summaryData) + XCTAssertEqual(summary.codexFastMode, false) + + // Missing key keeps the flag nil so older app servers continue to decode. + let legacyPayload: [String: Any] = [ + "sessionId": "chat-legacy", + "laneId": "lane-1", + "provider": "claude", + "model": "claude-sonnet-4-6", + "status": "active", + "startedAt": "2026-03-25T00:00:00.000Z", + "lastActivityAt": "2026-03-25T00:00:01.000Z", + ] + let legacyData = try JSONSerialization.data(withJSONObject: legacyPayload) + let legacy = try JSONDecoder().decode(AgentChatSessionSummary.self, from: legacyData) + XCTAssertNil(legacy.codexFastMode) + } + + func testAgentChatModelInfoDetectsFastServiceTier() throws { + let payload: [String: Any] = [ + "id": "gpt-5.5", + "displayName": "GPT-5.5", + "isDefault": true, + "serviceTiers": ["fast"], + "reasoningEfforts": [ + ["effort": "medium", "description": "balanced"], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let info = try JSONDecoder().decode(AgentChatModelInfo.self, from: data) + XCTAssertTrue(info.supportsCodexFastMode) + XCTAssertTrue(info.supportsServiceTier("FAST")) + XCTAssertFalse(info.supportsServiceTier("priority")) + + let plainData = try JSONSerialization.data(withJSONObject: [ + "id": "claude-sonnet-4-6", + "displayName": "Sonnet 4.6", + "isDefault": false, + ]) + let plain = try JSONDecoder().decode(AgentChatModelInfo.self, from: plainData) + XCTAssertFalse(plain.supportsCodexFastMode) + } + func testCtoRosterDecodesCtoSummaryAndWorkerEntries() throws { let ctoSummary: [String: Any] = [ "sessionId": "cto-session-1", @@ -5280,6 +5352,7 @@ final class ADETests: XCTestCase { description: "Latest Codex model", isDefault: true, reasoningEfforts: nil, + serviceTiers: nil, maxThinkingTokens: nil, modelId: "openai/gpt-5.5", family: "openai", @@ -5293,6 +5366,7 @@ final class ADETests: XCTestCase { description: nil, isDefault: false, reasoningEfforts: nil, + serviceTiers: nil, maxThinkingTokens: nil, modelId: "openai/gpt-5.4", family: "openai", @@ -5308,6 +5382,7 @@ final class ADETests: XCTestCase { description: nil, isDefault: false, reasoningEfforts: nil, + serviceTiers: nil, maxThinkingTokens: nil, modelId: nil, family: "anthropic", @@ -5321,6 +5396,7 @@ final class ADETests: XCTestCase { description: nil, isDefault: true, reasoningEfforts: nil, + serviceTiers: nil, maxThinkingTokens: nil, modelId: nil, family: "cursor", @@ -5355,6 +5431,7 @@ final class ADETests: XCTestCase { description: nil, isDefault: true, reasoningEfforts: nil, + serviceTiers: nil, maxThinkingTokens: nil, modelId: "openai/gpt-5.5", family: "openai", @@ -6860,6 +6937,7 @@ final class ADETests: XCTestCase { title: title, goal: nil, reasoningEffort: nil, + codexFastMode: nil, executionMode: nil, permissionMode: nil, interactionMode: nil, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 383d857d8..17889bc96 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -231,6 +231,7 @@ Types for these tables are split into domain modules under `apps/desktop/src/sha │ ├── templates/ # Lane/mission templates (tracked when human-authored) │ ├── skills/ # Exported skill markdown (tracked when human-authored) │ ├── workflows/linear/ # Linear workflow config (tracked when present) +│ ├── project-icons/ # Imported project icon overrides (tracked when ade.yaml.iconPath points at one) │ ├── ade.sock # Unix socket for ADE RPC (runtime) │ └── secrets/ # Machine-local secret material (ignored) │ ├── github/*.bin # safeStorage-encrypted tokens @@ -244,10 +245,16 @@ Types for these tables are split into domain modules under `apps/desktop/src/sha **Portability buckets** (intentionally distinct): -1. **Git-tracked shared scaffold** — `.ade/.gitignore`, `ade.yaml`, `cto/identity.yaml`, human-authored `templates/**`, `skills/**`, `workflows/linear/**`. This is the only `.ade/` subset that flows through normal clone/pull. +1. **Git-tracked shared scaffold** — `.ade/.gitignore`, `ade.yaml`, `cto/identity.yaml`, human-authored `templates/**`, `skills/**`, `workflows/linear/**`, `project-icons/**`. This is the only `.ade/` subset that flows through normal clone/pull. The shared `.ade/.gitignore` is now `*` with explicit allowlist entries for those scaffold files (so the next time someone touches `.ade/` from a fresh tool the runtime state stays out of git automatically). 2. **ADE sync state** — the replicated `ade.db` tables that flow through cr-sqlite over WebSocket when devices join the same host. 3. **Machine-local runtime** — worktrees, caches, transcripts, artifacts, secrets, sockets, generated context/memory markdown. Never leaves the device. +**Project scaffold modes.** `initializeOrRepairAdeProject(projectRoot, { mode })` controls whether a project gets the full shared scaffold or stays local-only: + +- `mode: "shared"` always materializes the canonical files (`.ade/.gitignore`, `ade.yaml`, `cto/identity.yaml`, the tracked placeholder `.gitkeep`s) and scrubs any leftover `.ade/` ignore lines from `.gitignore` / `.git/info/exclude`. Triggered automatically from `createLocalProject`, every shared-config save, and any helper that calls `ensureSharedAdeProjectScaffold(projectRoot)` (e.g. `setProjectIconOverrideFromSelection`, `linearWorkflowFileService.save`). +- `mode: "auto"` (the default for `openProject`) keeps the project local-only when no shared scaffold files exist yet — it ensures `.git/info/exclude` has a `.ade/` entry so a brand-new clone or a personal-only setup never accidentally promotes runtime state into git, and only flips to the shared layout when shared scaffold files are already present (or after a save call promotes them). +- `mode: "local"` is reserved for force-local repair flows. + ### 3.4 Migration strategy - Schema is defined idempotently — `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`. @@ -312,9 +319,9 @@ Agent tools are split by domain: `apps/desktop/src/shared/modelRegistry.ts` + `apps/desktop/src/shared/modelProfiles.ts`: -- `MODEL_REGISTRY` — static CLI-wrapped entries + dynamically populated API-key/local entries. Includes the Claude Opus 4.7 1M-context entry (`anthropic/claude-opus-4-7-1m`, aliases `opus[1m]` / `claude-opus-4-7[1m]`, 1,000,000 context / 128,000 max output, `costTier: "very_high"`, full `low|medium|high|max` reasoning tiers). -- `ModelProviderGroup` = `"claude" | "codex" | "opencode" | "cursor"`. -- Helpers: `getModelById`, `getModelPricing`, `updateModelPricingInRegistry`, `replaceDynamicOpenCodeModelDescriptors`, `resolveProviderGroupForModel`, `resolveModelDescriptorForProvider`, `getRuntimeModelRefForDescriptor`. +- `MODEL_REGISTRY` — static CLI-wrapped entries + dynamically populated API-key/local entries. Includes the Claude Opus 4.7 1M-context entry (`anthropic/claude-opus-4-7-1m`, aliases `opus[1m]` / `claude-opus-4-7[1m]`, 1,000,000 context / 128,000 max output, `costTier: "very_high"`, full `low|medium|high|max` reasoning tiers). `ModelDescriptor.serviceTiers?: string[]` advertises optional service tiers (today: `"fast"`, set on the Codex CLI GPT 5.4 / 5.5 entries) that the UI's Codex Fast Mode toggle and the Codex JSON-RPC `serviceTier` argument key off. +- `ModelProviderGroup` = `"claude" | "codex" | "opencode" | "cursor" | "droid"`. Cursor and Droid each have their own top-level provider group used by the model picker, identity routing, and tracked CLI provider catalog. +- Helpers: `getModelById`, `getModelPricing`, `updateModelPricingInRegistry`, `replaceDynamicOpenCodeModelDescriptors`, `resolveProviderGroupForModel`, `resolveModelDescriptorForProvider`, `getRuntimeModelRefForDescriptor`, `modelSupportsServiceTier(descriptor, tier)` / `modelSupportsFastMode(descriptor)`. - Reasoning tier passthrough (`providerOptions.ts`) maps tier strings directly to each provider's native config (`thinking.type`, `reasoningEffort`, `thinkingConfig.thinkingLevel`, etc.) — no arbitrary token budgets. The Claude vocabulary is `low | medium | high | max`. - Model profiles (`modelProfiles.ts`) derive the Missions UI model catalog and per-call-type intelligence defaults from `MODEL_REGISTRY` rather than maintaining parallel lists. @@ -386,7 +393,7 @@ ade.layout.* / ade.graph.* ade.computerUse.* ade.iosSimulator.* # macOS-only iOS Simulator drawer + Preview Lab: getStatus/launch/shutdown/screenshot/getScreenSnapshot/getInspectorSnapshot/inspectPoint/getPreviewCapability/listPreviewTargets/renderPreview/openPreviewWorkspace/startStream/stopStream/getStreamStatus/getWindowState/listWindowSources/tap/typeText/drag/swipe/selectPoint, plus the ade.iosSimulator.event push channel ade.appControl.* # Electron app control bridge over Chrome DevTools Protocol: getStatus/launch/launchInTerminal/connect/stop/screenshot/getSnapshot/inspectPoint/selectPoint/click/typeText/scroll/dispatchKey/listTargets/attachToTarget, plus the ade.appControl.event push channel (session-started/updated/stopped, selection, screencast frame) -ade.builtInBrowser.* # in-app web browser owned by `builtInBrowserService`: getStatus/setBounds/attachWebview/navigate/createTab/switchTab/closeTab/reload/goBack/goForward/stop/startInspect/stopInspect/captureScreenshot/selectPoint/selectCurrent/clearSelection, plus the ade.builtInBrowser.event push channel (status / selection / selection-cleared / error). Backs the Work sidebar's Browser tab and the renderer-wide `openUrlInAdeBrowser()` link router. +ade.builtInBrowser.* # in-app web browser owned by `builtInBrowserService`: getStatus/showPanel/setBounds/attachWebview/navigate/createTab/switchTab/closeTab/reload/goBack/goForward/stop/startInspect/stopInspect/captureScreenshot/selectPoint/selectCurrent/clearSelection, plus the ade.builtInBrowser.event push channel (status / open-request / selection / selection-cleared / error). Backs the Work sidebar's Browser tab and the renderer-wide `openUrlInAdeBrowser()` link router. ade.terminal.* # chat-owned terminal control: list/read/write/signal/activeForChat. Resolves a chat's active terminal via chatSessionId so in-chat agents and the App Control panel can drive the visible launch terminal. ade.updates.* ``` @@ -467,7 +474,7 @@ Every service lives under `apps/desktop/src/main/services//`. Summary: | `runtime/` | `tempCleanupService.ts` | Runtime temp cleanup. | | `sessions/` | `sessionService.ts`, `sessionDeltaService.ts` | Terminal session CRUD, post-session delta computation. | | `shared/` | `utils.ts`, `queueRebase.ts`, `packLegacyUtils.ts`, `transcriptInsights.ts` | Cross-domain utilities. | -| `state/` | `kvDb.ts`, `crsqliteExtension.ts`, `globalState.ts`, `projectState.ts`, `onConflictAudit.ts` | SQLite schema + open, CRR extension loader, global state file, per-project state init. | +| `state/` | `kvDb.ts`, `crsqliteExtension.ts`, `globalState.ts`, `projectState.ts`, `onConflictAudit.ts` | SQLite schema + open, CRR extension loader, global state file, per-project state init. `globalState.upsertRecentProject` accepts `preserveRecentOrder` so reactivating an already-known project (by app focus, deep link, etc.) refreshes its `lastOpenedAt` in place instead of jumping it to the front of the recents list. | | `sync/` | `syncService.ts`, `syncHostService.ts`, `syncPeerService.ts`, `syncRemoteCommandService.ts`, `syncProtocol.ts`, `deviceRegistryService.ts`, `syncPairingStore.ts` | WebSocket host, peer client, remote command routing, protocol framing, device registry, pairing secrets. | | `notifications/` | `apnsService.ts`, `notificationMapper.ts`, `notificationEventBus.ts` | APNs HTTP/2 client (ES256 JWT, encrypted `.p8`), pure domain-event → `MappedNotification` mapping (13 categories / 4 families), event bus routing to APNs alert pushes + Live Activity update pushes + in-app WS delivery, filtered by per-device `NotificationPreferences`. | | `tests/` | `testService.ts` | Test-suite execution + run history. | diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index ba9b6ab8d..ffe6be636 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -21,7 +21,7 @@ registry / ADE CLI integration that all three share. | `apps/desktop/src/main/services/cto/workerAgentService.ts` | Worker adapter configs: Claude-local, Codex-local, OpenClaw webhook, raw process. | | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO-only tools (spawnChat, mission control, worker management, Linear dispatch). | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools (Claude Code, Codex, Cursor, Aider, Continue) on PATH. | -| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md). | +| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser (`browser panel`, `browser open [--no-panel]`, `browser new-tab --background`, `browser switch`, `browser close`, plus selection / inspect commands). `ade chat create --provider codex --model --fast` opts a new Codex session into the fast service tier; `ade shell start --lane --chat-session ` (or `ADE_CHAT_SESSION_ID` from the env) attaches a tracked shell to an existing chat so `ade --socket terminal read --chat-session "$ADE_CHAT_SESSION_ID" --text` resolves to it. | | `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering. | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side install / status / uninstall surface for the `ade` launcher. Owns the install-target path resolution and the optional shell-rc PATH append. | | `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance for finding and using `ade` (env var fallback chain + "try `ade doctor` before declaring blocked"). | diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 61cbcbd2c..247eb4a68 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -11,9 +11,9 @@ machinery layered on top. | Path | Role | |---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, and prompt-derived lane-name suggestions for parallel launch. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Large orchestrator file. | -| `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | -| `apps/desktop/src/shared/types/builtInBrowser.ts` | Cross-process types for the built-in browser: `BuiltInBrowserStatus`, `BuiltInBrowserTab`, `BuiltInBrowserContextItem` (`kind: "built_in_browser_element" | "built_in_browser_capture"`), `BuiltInBrowserSelectResult`, `BuiltInBrowserScreenshot`, and the `BuiltInBrowserEventPayload` union (`status`, `selection`, `selection-cleared`, `error`). | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, and prompt-derived lane-name suggestions for parallel launch. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Large orchestrator file. | +| `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Spoofs a desktop Chrome `User-Agent` and the matching `Sec-CH-UA*` client hints on every request through `webRequest.onBeforeSendHeaders` so external sign-in flows (Google, etc.) treat the embedded view as a normal desktop Chrome instead of refusing to load — the previous "open Google sign-in in the system browser" branch was removed because the spoofed UA stops Google from blocking the page in the first place. Window-open requests are forwarded into a fresh tab with `openPanel: true` so the Work sidebar Browser tab pops automatically. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | +| `apps/desktop/src/shared/types/builtInBrowser.ts` | Cross-process types for the built-in browser: `BuiltInBrowserStatus`, `BuiltInBrowserTab`, `BuiltInBrowserContextItem` (`kind: "built_in_browser_element" | "built_in_browser_capture"`), `BuiltInBrowserSelectResult`, `BuiltInBrowserScreenshot`, `BuiltInBrowserOpenPanelArgs`, and the `BuiltInBrowserEventPayload` union (`status`, `open-request`, `selection`, `selection-cleared`, `error`). Navigate / create-tab / switch-tab args carry an optional `openPanel: boolean` so callers can ask for the Work sidebar Browser tab to flip open atomically with the navigation. | | `apps/desktop/src/main/services/chat/buildClaudeV2Message.ts` | Builds the message payload the Claude Agent SDK V2 session consumes. Handles base64 image content blocks and MIME inference. | | `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers per-project (`.claude/commands/**`) and per-user (`~/.claude/commands/**`) slash commands, including `.md` command files and `.skill` user-invocable skills, parsing YAML frontmatter for description and argument hints. Consumed by `agentChatService` to enrich the `chat.slashCommands` response so the composer's picker lists local Claude commands alongside SDK-provided ones. | | `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. | @@ -87,10 +87,32 @@ machinery layered on top. The Work right-edge sidebar's `browser` tab renders this surface through `ChatBuiltInBrowserPanel`; any URL clicked elsewhere in the renderer routes through `openUrlInAdeBrowser()` so it opens inside - the sidebar instead of the system browser. Inspect-mode hit-tests - produce `BuiltInBrowserContextItem` payloads that the sidebar - forwards to the active chat as composer chips alongside iOS / App - Control selections. + the sidebar instead of the system browser. The browser broker spoofs + a desktop Chrome User-Agent (with matching `Sec-CH-UA*` headers) so + third-party sign-in flows treat the embedded view as desktop Chrome + — the older "Google sign-in opens in the system browser" carve-out is + gone. Navigation, tab create, switch-tab, and the new dedicated + `built_in_browser.showPanel` / `ade.builtInBrowser.showPanel` IPC + channel each accept `openPanel: true|false`; `true` emits an + `open-request` event that `TerminalsPage` listens for to flip the + Work sidebar to its Browser tab. The `--no-panel` / `--hidden` flags + on the matching `ade browser ...` CLI commands set `openPanel: false` + so headless callers can prefetch tabs without yanking the user's + attention. Inspect-mode hit-tests produce `BuiltInBrowserContextItem` + payloads that the sidebar forwards to the active chat as composer + chips alongside iOS / App Control selections. +- **Localhost shortcuts in the work log.** When an agent's tool output + surfaces a `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` URL, the chat + work-log block renders a sky-toned strip above the tool-call panels. + The primary chip opens the URL inside the ADE built-in browser + (`openUrlInAdeBrowser`); a sibling Logs button reveals the chat's + active terminal inside the bottom drawer through `onRevealChatTerminal`, + or — when no terminal exists yet — drafts a guided "please move this + server into the chat terminal" prompt for the agent through + `onInsertDraft`. URLs are extracted from `entry.localUrls` (set by + `withLocalhostUrls` in `chatTranscriptRows.ts`) so the strip works + uniformly across shell commands, tool calls, and arbitrary tool + results. See the detail docs for the specifics: @@ -183,10 +205,10 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. |---|---|---| | `ade.agentChat.list` | invoke | List sessions with optional `includeIdentity`, `includeAutomation`. | | `ade.agentChat.getSummary` | invoke | Fetch `AgentChatSessionSummary` for a single session. | -| `ade.agentChat.create` | invoke | Create a new session; returns the `AgentChatSession`. | +| `ade.agentChat.create` | invoke | Create a new session; returns the `AgentChatSession`. Accepts `codexFastMode?: boolean` for Codex sessions to start with the `serviceTier: "fast"` default. | | `ade.agentChat.suggestLaneName` | invoke | Derive a slug-safe base lane name from a parallel prompt using a lightweight model call with deterministic fallback. | | `ade.agentChat.parallelLaunchState.get` / `.set` | invoke | Read/write crash-recovery state for renderer-orchestrated parallel launches. State is scoped by project root and parent lane id. | -| `ade.agentChat.handoff` | invoke | End current session and create a new one with summarized context. | +| `ade.agentChat.handoff` | invoke | End current session and create a new one with summarized context. Forwards `codexFastMode` when the target provider is Codex. | | `ade.agentChat.send` | invoke | Dispatch a user message + attachments into an active session. | | `ade.agentChat.steer` | invoke | Send a follow-up message mid-turn; queued when appropriate. | | `ade.agentChat.cancelSteer` / `ade.agentChat.editSteer` | invoke | Queue management for queued steers. | @@ -197,7 +219,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.respondToInput` | invoke | Unified pending-input answer channel. | | `ade.agentChat.dispose` | invoke | End the runtime and persist final state ("End chat"). The row stays in `terminal_sessions` as `ended` so it remains resumable. | | `ade.agentChat.delete` | invoke | Permanently remove a chat session: disposes the runtime if still running, cancels any pending turn collector, resolves outstanding input waiters, removes the persisted JSON + transcript, and deletes the `terminal_sessions` row. Renderer surfaces this as "Delete chat" on ended sessions. | -| `ade.agentChat.updateSession` | invoke | Mutate permission modes, `manuallyNamed`, capability mode. | +| `ade.agentChat.updateSession` | invoke | Mutate permission modes, `manuallyNamed`, capability mode, and the `codexFastMode` toggle. | | `ade.agentChat.warmupModel` | invoke | Preload a Claude V2 session for an eventual turn. | | `ade.agentChat.slashCommands` | invoke | List provider + local slash commands. | | `ade.agentChat.fileSearch` | invoke | Debounced attachment picker backend. | diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index def6c8698..cc12f66d9 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -148,18 +148,37 @@ values into the Codex app-server wire format at the JSON-RPC boundary: `on-request` -> `onRequest`, `untrusted` -> `unlessTrusted`, `on-failure` -> `onFailure`, and `workspace-write` -> `workspaceWrite`. Every `thread/start` and `thread/resume` call passes `{ model, cwd, -reasoningEffort, ...codexPolicyArgs, persistExtendedHistory: true }`. -The return envelope is consumed by `applyCodexEffectiveThreadState`, -which normalizes `approvalPolicy`, `sandbox` (including the camel-case -aliases `readOnly` / `workspaceWrite` / `dangerFullAccess` that the -server emits), and `reasoningEffort`. That snapshot becomes the session -state, so the picker chips always show what the runtime actually -applied. On resume, the persisted chat state is re-written after -normalization instead of being re-copied from the on-disk file — the -server's reading of `.codex/config.toml` wins over a stale persisted -pair. Turns use the Codex-native `effort` key -(`turn/start({ threadId, input, effort? })`) instead of the lifecycle -`reasoningEffort` name. +reasoningEffort, ...codexPolicyArgs, ...codexServiceTierArgs(session), +persistExtendedHistory: true }`. The return envelope is consumed by +`applyCodexEffectiveThreadState`, which normalizes `approvalPolicy`, +`sandbox` (including the camel-case aliases `readOnly` / +`workspaceWrite` / `dangerFullAccess` that the server emits), and +`reasoningEffort`. That snapshot becomes the session state, so the +picker chips always show what the runtime actually applied. On resume, +the persisted chat state is re-written after normalization instead of +being re-copied from the on-disk file — the server's reading of +`.codex/config.toml` wins over a stale persisted pair. Turns use the +Codex-native `effort` key (`turn/start({ threadId, input, effort?, +serviceTier? })`) instead of the lifecycle `reasoningEffort` name. + +#### Codex service tiers (Fast Mode) + +`ModelDescriptor.serviceTiers?: string[]` advertises the optional +service tiers a model accepts (today only `"fast"`). The composer's +**Fast** toggle (a yellow Lightning chip next to the model picker) +shows whenever `modelSupportsFastMode(descriptor)` is true for the +selected model and the session provider is Codex. `AgentChatSession` +carries `codexFastMode?: boolean` and the chat adapter forwards it as +`serviceTier: "fast" | null` on every `turn/start` and `thread/start` +JSON-RPC call (an explicit `null` clears any app-server default). The +flag persists with the session, survives reload through +`PersistedChatState`, and is forwarded to remote devices through the +sync command service. Parallel-model rows track Fast mode per slot +(`ParallelModelRowState.codexFastMode`) so launching multiple Codex +runs side-by-side can mix Fast and Standard turns. The discovery layer +populates `serviceTiers` from app-server-reported `additionalSpeedTiers` +/ `serviceTiers` rows; the static registry pre-marks the GPT 5.4 / 5.5 +Codex CLI entries. Codex plan mode uses the native app-server planning flow. ADE passes its runtime guidance as an ordinary system-context input item and keeps diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 0b7b0372b..b392aa685 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -28,7 +28,7 @@ stream plus session metadata. | `ChatTerminalDrawer.tsx` | Collapsible terminal drawer at the bottom of the chat. | | `ChatGitToolbar.tsx` | Git status and quick-action toolbar above the composer. | | `ChatProposedPlanCard.tsx` | Plan approval card inline in the transcript. | -| `ChatWorkLogBlock.tsx` | Collapsible work-log group (see `chatTranscriptRows.ts`). Accepts `animate` so completed groups render a static glyph while in-flight ones pulse; prefers `waiting` over `working` when any entry is `interrupted`. | +| `ChatWorkLogBlock.tsx` | Collapsible work-log group (see `chatTranscriptRows.ts`). Accepts `animate` so completed groups render a static glyph while in-flight ones pulse; prefers `waiting` over `working` when any entry is `interrupted`. Also renders a `LocalhostServersStrip` above the panels when any work-log entry produced a `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` URL: a sky-toned chip per detected URL routes through `openUrlInAdeBrowser()` (so the click opens the Work sidebar Browser tab in a new tab), and a sibling Logs button either reveals the chat's currently active terminal (via `onRevealChatTerminal`) or — when no terminal exists — drafts a "please move this server into the ADE chat terminal" prompt for the agent through `onInsertDraft`. | | `AgentQuestionModal.tsx` | Pending input modal for question-type requests. | | `CodeHighlighter.tsx`, `chatStatusVisuals.tsx`, `chatSurfaceTheme.ts`, `chatToolAppearance.tsx` | Supporting visuals. `chatStatusVisuals.ChatStatusGlyph` takes an `animate` prop so non-active rows skip the ping/spin animation; `AgentChatMessageList.ActivityIndicator` mirrors this and switches to a dimmed static tone plus a non-looping Brain lottie for `thinking` once the turn ends. | | `pendingInput.ts`, `chatExecutionSummary.ts`, `chatNavigation.ts`, `chatTranscriptRows.ts` | Pure state derivations consumed by the UI. | @@ -101,6 +101,13 @@ and a footer that contains the composer. handoff. - **Reasoning effort.** Dropdown for models that support reasoning tiers. +- **Fast mode (Codex).** A yellow Lightning chip next to the model + selector that toggles `codexFastMode` for the selected session. + Renders only when `modelSupportsFastMode(getModelById(modelId))` + returns true and the session provider is Codex (today: GPT 5.4 / + GPT 5.5 in the Codex CLI). The toggle is also exposed per-slot in + parallel mode through `onParallelSlotCodexFastModeChange`. State + flows into the next `turn/start` as `serviceTier: "fast"`. - **Attachments.** Allows the user to attach files and artifacts to the next turn. - **Permission controls.** Inline with the composer: @@ -273,7 +280,15 @@ surface. Each drawer tab creates an untracked shell PTY in the current lane, reusing the shared `TerminalView` component (with global terminal preferences) rather than managing raw xterm instances directly. Tabs track PTY exit state and auto-close the drawer when the -last tab is removed. +last tab is removed. When a new chat-owned terminal is created from a +non-drawer source (e.g. an in-chat agent calling +`ade --socket app-control launch`, the localhost-strip "Logs" button, +or another chat surface) the pane subscribes to +`window.ade.sessions.onChanged` and dedupes the new terminal into the +drawer instead of opening a duplicate tab — `ChatTerminalDrawer.openTab` +checks the existing tab list by `sessionId` / `ptyId` before pushing a +new entry, and the `AgentChatPane` `revealCreatedTerminal` effect calls +the same drawer with the recovered `{ terminalId, ptyId, label }`. `ChatTerminalToggle` is the header button that shows the active tab count. diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index 079db6ada..8abea66cb 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -119,6 +119,15 @@ Each work-log entry carries a `collapseKey` built from `turnId`, Streaming updates for the same tool call merge into the existing entry instead of appending a new row. +`withLocalhostUrls(entry)` runs at every emit/merge step and stamps +`entry.localUrls?: ChatLocalhostUrl[]` whenever the entry's +command/output/args/result/label/detail mention a `localhost`, +`127.0.0.1`, `0.0.0.0`, or `[::1]` URL. The extractor (also exported as +`extractLocalhostUrlsFromText`) trims trailing punctuation, normalises +the host to `localhost` for the canonical `href`, and dedupes by +`href`. Downstream `ChatWorkLogBlock` consumes `entry.localUrls` to +render the localhost-strip chips that route into the in-app browser. + ## Text merging Adjacent `text` events merge via `shouldMergeTextRows()`: diff --git a/docs/features/linear-integration/README.md b/docs/features/linear-integration/README.md index df2a8c565..277ce50b6 100644 --- a/docs/features/linear-integration/README.md +++ b/docs/features/linear-integration/README.md @@ -146,7 +146,12 @@ Core Linear services on desktop - `linearClient.ts` — GraphQL client wrapper - `linearIssueTracker.ts` — normalization into `NormalizedLinearIssue` - `linearTemplateService.ts` — mission/session template resolution -- `linearWorkflowFileService.ts` — YAML workflow files under `.ade/` +- `linearWorkflowFileService.ts` — YAML workflow files under + `.ade/workflows/linear/**`. Every `save(config)` call invokes + `ensureSharedAdeProjectScaffold(projectRoot)` first so a project that + was previously local-only gets promoted to the shared `.ade/` + scaffold (including the canonical `.ade/.gitignore` and + `cto/identity.yaml`) the moment a Linear workflow is persisted. - `flowPolicyService.ts` — versioned policy read/write, rollback, revisions - `linearRoutingService.ts` — match triggers against an issue, pick workflow - `linearIntakeService.ts` — issue discovery loop, snapshots, hashes diff --git a/docs/features/onboarding-and-settings/configuration-schema.md b/docs/features/onboarding-and-settings/configuration-schema.md index 1bf0e2795..dcf74e312 100644 --- a/docs/features/onboarding-and-settings/configuration-schema.md +++ b/docs/features/onboarding-and-settings/configuration-schema.md @@ -22,6 +22,20 @@ time. `projectConfigService.get()` returns a `ProjectConfigSnapshot` with all three (`shared`, `local`, `effective`) plus validation and trust metadata. +`projectConfigService.save({ shared, local })` is also the seam that +promotes a project from the **local-only ADE scaffold** to the **shared +scaffold**. Saving with any non-empty shared content +(`hasSharedConfigContent(shared)` checks for processes, stack buttons, +test suites, overlays, automations, environments, github/git/ai +metadata, lane init, lane templates, lane cleanup, providers, linear +sync, notifications, or a `project` block) calls +`ensureSharedAdeProjectScaffold(projectRoot)` so the canonical +`.ade/.gitignore`, `ade.yaml`, and `cto/identity.yaml` exist before the +write hits disk and `.git/info/exclude` is scrubbed. Saves that only +change `local` skip the shared write entirely (so a brand-new project +can stay local-only) and re-run `initializeOrRepairAdeProject` in auto +mode to keep the local-only `.git/info/exclude .ade/` rule in place. + ## Top-level type ```ts @@ -59,8 +73,11 @@ type ProjectIdentityConfig = { `project.iconPath` is the user-overridable input to `projectIconResolver`. Validation rejects paths outside the project root or with unsupported extensions (must be one of `.ico`, `.jpeg`, -`.jpg`, `.png`, `.svg`, `.webp`). The TopBar tab icon picker -(`window.ade.project.chooseIcon` / `removeIcon`) writes this field. +`.jpg`, `.png`, `.svg`, `.webp`) and enforces a 10 MB cap. The TopBar +tab icon picker (`window.ade.project.chooseIcon` / `removeIcon`) +writes this field; selecting a file outside the project root copies +the bytes into `.ade/project-icons/.` so the icon +travels with the repo. The lenient `Config*` variants allow every field to be optional so `ade.yaml` and `local.yaml` can be partial. `projectConfigService` @@ -402,8 +419,18 @@ name handled inside `registerIpc.ts`). ## Gotchas -- `ade.yaml` is the only file version-controlled; be explicit about - what belongs in `local.yaml` to avoid leaking user paths/secrets. +- `ade.yaml`, `cto/identity.yaml`, and the human-authored `templates/` + / `skills/` / `workflows/linear/` / `project-icons/` directories are + the only `.ade/` paths under version control. The shared + `.ade/.gitignore` is `*` with explicit allowlist entries, so any new + runtime file dropped into `.ade/` stays out of git automatically. +- A project that has only ever saved local-only state (no shared + config, no shared icon override, no Linear workflow) keeps `.ade/` + ignored via `.git/info/exclude` instead of materializing the shared + `.ade/.gitignore`. The first save that changes shared content (or + any caller of `ensureSharedAdeProjectScaffold`) promotes the + scaffold and removes the local exclude rule. After that the project + behaves like a normal shared-scaffold ADE project. - Hot-reload of config changes is best-effort. Process env, lane overlay policies, and AI mode apply to new launches, not live ones. diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md index ed2fbe7ad..fec6faa31 100644 --- a/docs/features/project-home/README.md +++ b/docs/features/project-home/README.md @@ -127,12 +127,20 @@ Main process (the substrate): validates the path stays inside the project root and points at a supported file, then writes `project.iconPath` into `.ade/ade.yaml`. `removeProjectIconOverride(rootPath)` writes - `iconPath: null`. Both helpers return the freshly resolved - `ProjectIcon` so the renderer can update the cache in one round - trip. + `iconPath: null`. `setProjectIconOverrideFromSelection(rootPath, srcPath)` + is the file-picker entry point: it validates the source file + (`.ico` / `.jpg` / `.jpeg` / `.png` / `.svg` / `.webp`, ≤ 10 MB), + copies the bytes into `.ade/project-icons/.` so the + icon travels with the repo, then writes `project.iconPath` to that + relative path. Every override write also runs + `ensureSharedAdeProjectScaffold(projectRoot)` so a project that was + previously local-only gets promoted to the shared scaffold the moment + the user picks a custom icon. All three helpers return the freshly + resolved `ProjectIcon` so the renderer can update the cache in one + round trip. `resolveProjectIcon(rootPath)` returns - `{ dataUrl, sourcePath, mimeType }`: any matched file under 1 MB is + `{ dataUrl, sourcePath, mimeType }`: any matched file under 10 MB is base64-encoded as a data URL (svg / ico / png / jpeg / webp), larger files report only `sourcePath`. Path traversal outside the project root is blocked end-to-end (probe paths run through @@ -347,10 +355,16 @@ Each project gets a best-effort icon resolved by `IPC.projectResolveIcon` → `ipcMain.handle("ade.project.resolveIcon", …)`); the desktop TopBar project tab strip caches the result per `rootPath` in a module-local -`Map` so a tab swap doesn't re-scan the disk. When the resolver finds -no icon (or the file is over the 1 MB cap), the tab falls back to the -`Folder` Phosphor glyph. Missing-project tabs skip the lookup -entirely. +`Map` so a tab swap doesn't re-scan the disk. The same TopBar derives +a per-project accent colour from the resolved data URL by sampling the +icon's dominant pixel through a tiny offscreen canvas +(`deriveIconAccentColor`), then drives the project tab's active / +hovered / focused background and border via the `--project-tab-accent` +CSS variable in `index.css`; the colour is luminance-balanced and +cached per data-URL (`PROJECT_ICON_ACCENT_CACHE_MAX = 48`). When the +resolver finds no icon (or the file is over the 10 MB cap), the tab +falls back to the `Folder` Phosphor glyph and the default accent. +Missing-project tabs skip the lookup entirely. The TopBar tab also exposes a small icon-override dialog: clicking the icon button opens a Radix dialog with **Choose icon…** and **Reset to @@ -358,14 +372,17 @@ auto-detected**. **Choose icon…** calls `window.ade.project.chooseIcon(rootPath)` which opens an Electron file picker (filtered to `ico`/`jpeg`/`jpg`/`png`/`svg`/`webp`); the selected path is validated (must live inside the project root and be a -supported image type), persisted to `.ade/ade.yaml` under -`project.iconPath`, and the freshly resolved icon is returned to the -renderer. **Reset to auto-detected** calls -`window.ade.project.removeIcon(rootPath)`, which writes -`project.iconPath: null` so the project deliberately shows the +supported image type, ≤ 10 MB), copied into +`.ade/project-icons/.` so the icon ships with the +repo, persisted to `.ade/ade.yaml` under `project.iconPath`, and the +freshly resolved icon is returned to the renderer. **Reset to +auto-detected** calls `window.ade.project.removeIcon(rootPath)`, which +writes `project.iconPath: null` so the project deliberately shows the fallback glyph (use the file picker to pick a new one to re-enable detection or override). The override is committed to `.ade/ade.yaml` -(shared, committed) so collaborators see the same project icon. +(shared, committed) so collaborators see the same project icon, and +the `.ade/project-icons/` directory is part of the tracked shared +scaffold so the actual bytes travel with the override. The mobile companion gets the icon through a dedicated path: the host's `mobileProjectSummaryForContext` / `mobileProjectSummaryForRecent` in diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 8038bb8f7..dc2f72994 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -156,19 +156,31 @@ Renderer surfaces: - `apps/desktop/src/renderer/components/terminals/useSessionDelta.ts` — fetches `SessionDeltaSummary` for a given session. - `apps/desktop/src/renderer/components/terminals/cliLaunch.ts` — - builds Claude/Codex CLI launch payloads with permission and sandbox - flags. `buildTrackedCliLaunchCommand` returns a typed - `TrackedCliLaunchCommand` (`{ command, args, startupCommand }`) so - `ptyService.create` can spawn the CLI directly via argv (preferred) - while `startupCommand` stays as a shell-typed fallback for systems - whose Claude/Codex shim only resolves through the user's shell rc. - Both providers get ADE session guidance injected at launch: - Claude through `--append-system-prompt` (text from - `ADE_CLI_AGENT_GUIDANCE`), Codex through a leading initial prompt - built from `ADE_CLI_INLINE_GUIDANCE`. The legacy + builds tracked CLI launch payloads with permission flags for every + supported provider. `CliProvider = "claude" | "codex" | "cursor" | + "droid" | "opencode"` and `LaunchProfile = CliProvider | "shell"`; + `LAUNCH_PROFILE_TOOL_TYPE` and `LAUNCH_PROFILE_TITLE` map a launch + profile to the recorded `TerminalToolType` (`cursor-cli`, `droid`, + `opencode`, etc.) and the human tab title. `buildTrackedCliLaunchCommand` + returns a typed `TrackedCliLaunchCommand` (`{ command?, args, + startupCommand, env? }`) so `ptyService.create` can spawn Claude/ + Codex directly via argv (preferred) while `startupCommand` stays as a + shell-typed fallback for the providers (Cursor, Droid, OpenCode) that + need a multi-line shell preamble: Cursor pre-allocates a chat with + `cursor-agent create-chat` so the resume target is known up front, + Droid materializes a temp `--settings` JSON keyed off the active + permission mode, and OpenCode passes its inline permission policy + through the `OPENCODE_CONFIG_CONTENT` env var. ADE session guidance is + injected on every launch — Claude through `--append-system-prompt` + (text from `ADE_CLI_AGENT_GUIDANCE`), every other provider through a + leading prompt built from `ADE_CLI_INLINE_GUIDANCE`. The legacy `buildTrackedCliStartupCommand` and `defaultTrackedCliStartupCommand` are now thin wrappers over `buildTrackedCliLaunchCommand` for - callers that only need the shell string. + callers that only need the shell string. `buildTrackedCliResumeCommand` + rebuilds a resume command line from `TerminalResumeMetadata` for any + provider; `parseTrackedCliResumeCommand` + (`apps/desktop/src/main/utils/terminalSessionSignals.ts`) is the + inverse it relies on for round-tripping. - `apps/desktop/src/shared/adeCliGuidance.ts` — single source of truth for the ADE session guidance text injected into Claude/Codex CLI launches. Exported as `ADE_CLI_AGENT_GUIDANCE` and @@ -231,10 +243,11 @@ schema is used for: - interactive shell PTYs (`toolType = "shell"`) - managed processes launched by `processService` (`toolType = "run-shell"`) -- CLI agent terminals (`claude`, `codex`, `claude-orchestrated`, - `codex-orchestrated`, `opencode-orchestrated`) -- agent chat sessions that run through the Claude/Codex SDKs rather than - a PTY (`claude-chat`, `codex-chat`, `opencode-chat`) +- tracked CLI agent terminals (`claude`, `codex`, `cursor-cli`, `droid`, + `opencode`, plus the `*-orchestrated` variants used by missions) +- agent chat sessions that run through the Claude/Codex/Cursor/Droid/ + OpenCode SDKs rather than a PTY (`claude-chat`, `codex-chat`, + `opencode-chat`, `cursor-chat`, `droid-chat`) - other tracked tools (`cursor`, `aider`, `continue`, `other`) Status transitions: `running` → `completed` | `failed` | `disposed`. @@ -376,12 +389,15 @@ Chat-owned terminals (`ade.terminal.*` — used by the chat terminal drawer, the `activeTerminalByChatSession` (chatSessionId → terminalId). Disposing the active terminal automatically promotes the most recently created sibling, so `ade terminal read --chat-session ` always resolves a -sensible target for in-chat agents. The headless ADE runtime and -agent chat runtime both export `ADE_CHAT_SESSION_ID`, -`ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` into the -agent process so an agent can call `ade --socket terminal read ---chat-session "$ADE_CHAT_SESSION_ID" --text` without resolving the -chat ID itself. +sensible target for in-chat agents. Every PTY launched through +`ptyService.create` runs through `withAdeTerminalContextEnv` which +exports `ADE_PROJECT_ROOT`, `ADE_LANE_ID`, and (when the PTY is +chat-owned) `ADE_CHAT_SESSION_ID` into the spawn env — that's how a +plain shell that the user types `ade --socket terminal read --chat-session +"$ADE_CHAT_SESSION_ID" --text` into will resolve to the parent chat's +terminal even though no agent runtime spawned it. The headless ADE +runtime and agent chat runtime both layer the same identity envs +(plus `ADE_WORKSPACE_ROOT`) on top through `buildAgentRuntimeEnv`. Processes (managed): diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index 1b4df840f..edfbe9317 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -212,12 +212,13 @@ presence of an AI integration service in non-guest mode: `aiTitleTimer` fires after 6 s, sends up to 800 chars of ANSI-stripped early output to `aiIntegrationService.summarizeTerminal` with a "max 80 chars, plain text" prompt. -- **CLI user title** (claude, codex): `tryCliUserTitleFromWrite` - listens to PTY *writes* (keyboard input) and commits the first - submitted prompt line (3 to 180 chars). This avoids the alt-screen - noise of Claude/Codex TUIs. Skipped when the session is - `manuallyNamed`. If the current session title is still a CLI - placeholder (`Claude`, `Codex`, `Claude Code`, etc. — see +- **CLI user title** (claude, codex, cursor-cli, droid, opencode): + `tryCliUserTitleFromWrite` listens to PTY *writes* (keyboard input) + and commits the first submitted prompt line (3 to 180 chars). This + avoids the alt-screen noise that every interactive agent TUI hides + output behind. Skipped when the session is `manuallyNamed`. If the + current session title is still a CLI placeholder (`Claude`, `Codex`, + `Cursor Agent CLI`, `Factory Droid CLI`, `OpenCode CLI`, etc. — see `isCliPlaceholderTitle`), a deterministic fallback title is committed immediately from the seed via `deterministicCliTitleFromSeed` (strips filler lead-ins like "ok"/"please", clips to 72 chars on a clause or @@ -242,7 +243,10 @@ on-demand call path is `async` and returns whether a target was resolved. Strategies, in order: 1. Scan the transcript tail with provider-specific regexes - (`extractResumeCommandFromOutput`). + (`extractResumeCommandFromOutput`). The regex now matches resume / + continue / session flags for `claude`, `codex`, `cursor-agent`, + `droid`, and `opencode` (`--resume`, `--continue`, `--session`, + `-r`, `-c`, `-s`, `resume`). 2. Read Claude's local storage: `~/.claude/projects//*.jsonl`, newest file modified in the last 5 minutes, filename is the session UUID. @@ -266,6 +270,22 @@ resolved. Strategies, in order: runs while a Codex session is still streaming uses all three gates; the close-time backfill only enforces a 10-minute drift window so it can match older sessions on resume. +4. Read Droid's local storage: + `~/.factory/sessions//*.jsonl`. Each candidate's first + line must be a `session_start` record whose `cwd` matches the ADE + session; the file's mtime is scored against `startedAt` with a + 10-minute drift window. The recovered session UUID becomes + `droid --resume ` and is written through + `sessionService.setResumeCommand`. +5. Shell out to `opencode session list --format json --max-count 80` + in the lane cwd. Sessions whose `directory` matches are scored by + `created`/`updated` against `startedAt` with the same 10-minute + drift window. The recovered id becomes `opencode --session `. + +The Droid storage scan and the OpenCode `session list` invocation only +fire on the `close` / `dispose` reasons (and on demand), not on +`session-list` or `resume-launch`, so renderer list refreshes don't +spawn a `spawnSync` or hit external storage on every render. Any found ID updates the row's `resumeMetadata.targetId` through `sessionService.updateMeta`. A resume command is always written even diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index fe353c85c..4e08ca9b9 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -384,10 +384,10 @@ Rendered when the Work view has no open sessions. Contains: - A three-mode liquid-glass pill (`ModeSwitcherPills` in `WorkViewArea.tsx`) toggling `draftKind` between **Chat** (compose a - new ADE chat in the lane), **CLI** (spawn a tracked Claude Code or - Codex CLI session), and **Shell** (plain shell terminal in the - lane's worktree). `draftKind` is `WorkDraftKind = "chat" | "cli" | - "shell"` in `appStore`. + new ADE chat in the lane), **CLI** (spawn a tracked agent CLI + session), and **Shell** (plain shell terminal in the lane's + worktree). `draftKind` is `WorkDraftKind = "chat" | "cli" | "shell"` + in `appStore`. - A sessions-pane expand affordance (`SessionsPaneExpandAffordance`) on the toolbar when the sidebar is collapsed: a sidebar glyph plus a count chip ("N in list, M running"). Clicking it expands the @@ -395,26 +395,59 @@ Rendered when the Work view has no open sessions. Contains: - lane selector (`LaneCombobox`) synced to the global `selectedLaneId` - for chat drafts: `AgentChatPane` in draft mode with provider-specific permission controls (`getPermissionOptions`, `safetyColors`) -- for cli/shell drafts: provider picker (Claude / Codex / Shell), - permission mode dropdown, and a "Launch" button that calls - `onLaunchPtySession` with the launch payload from - `buildTrackedCliLaunchCommand` (`{ command, args, startupCommand }`). - `onLaunchPtySession` forwards `command` + `args` to `pty.create` - for direct argv spawn; `startupCommand` rides along as the shell - fallback for hosts that need rc-resolved CLI shims. +- for cli drafts: a five-tile provider grid (Claude Code, Codex CLI, + Cursor Agent CLI, Factory Droid CLI, OpenCode CLI) with logos sourced + from `ToolLogos.tsx` / `ProviderLogos.tsx`. Selecting a provider + resets the permission picker to that provider's documented default + (`getPermissionOptions` keyed by `family`); Droid and OpenCode default + to `edit`, the rest default to `default`. The "Launch" button calls + `onLaunchPtySession` with the payload from + `buildTrackedCliLaunchCommand` (`{ command?, args, startupCommand, + env? }`). `onLaunchPtySession` forwards `command` + `args` for direct + argv spawn (Claude / Codex), passes `env` through to the PTY when set + (OpenCode's `OPENCODE_CONFIG_CONTENT`), and ships `startupCommand` as + the shell fallback the multi-line Cursor / Droid / OpenCode preambles + always rely on. The recorded `toolType` and tab title come from the + shared `LAUNCH_PROFILE_TOOL_TYPE` / `LAUNCH_PROFILE_TITLE` maps in + `cliLaunch.ts`, so adding a new provider only requires extending the + registry there plus the `WorkStartSurface` option list. +- for shell drafts: a "Launch" button that opens an untracked shell PTY + in the lane's worktree (`profile = "shell"`). Launch commands are built by `cliLaunch.ts`: - `buildTrackedCliLaunchCommand({ provider, permissionMode, ... })` - returns the canonical `{ command, args, startupCommand }` triple - used for both fresh launches and resumes. The args list now embeds - the ADE CLI guidance prompt — Claude through `--append-system-prompt`, - Codex as the leading initial prompt — so every tracked CLI session - starts with the agent already aware of the ADE wrappers. + returns the canonical `{ command?, args, startupCommand, env? }` + shape used for both fresh launches and resumes. Permission mode + choices map onto provider-native flags / configs: + - **Claude** → `--permission-mode` flag (CLI default plus + plan/acceptEdits/bypassPermissions). + - **Codex** → `--ask-for-approval` + `--sandbox` pair (or + `--full-auto`/`--dangerously-bypass-approvals-and-sandbox` for the + presets), plus `config-toml` mode that defers to `.codex/config.toml`. + - **Cursor** → `--mode plan|ask` for read-only modes and `--force` + for full-auto. Sessions pre-allocate a chat id with + `cursor-agent create-chat` so `--resume ` is always known. + - **Droid** → an autonomy-tiered settings JSON written to a temp file + that `droid --settings $ADE_DROID_SETTINGS` consumes; `spec` + autonomy is the plan/read-only fallback. + - **OpenCode** → an inline JSON permission policy passed via the + `OPENCODE_CONFIG_CONTENT` env var (`config-toml` mode skips the env + so OpenCode reads `opencode.json` instead). Plan mode adds `--agent + plan`. + Every provider also receives the ADE CLI guidance prompt — Claude + through `--append-system-prompt`, every other provider as a leading + prompt argument — so the agent starts with the ADE wrappers in + context. - `buildTrackedCliStartupCommand({ provider, permissionMode, ... })` thin wrapper that returns just the shell-typed `startupCommand`. - `resolveTrackedCliResumeCommand(session)` — used for the resume - action on the session card + action on the session card. Internally calls + `buildTrackedCliResumeCommand(metadata)`, which knows how to format + Claude (`claude --resume `), Codex (`codex resume `), + Cursor (`cursor-agent --resume ` / `--continue`), Droid (the + same `--settings` preamble plus `droid --resume `), and OpenCode + (`opencode --session ` / `--continue`). ## Context menu: `SessionContextMenu.tsx` @@ -448,12 +481,19 @@ before the IPC round-trip completes), `refresh`, and the right-sidebar setters `setWorkSidebarOpen`, `setWorkSidebarTab` (also forces the sidebar open), and `setWorkSidebarWidthPct` (clamped 26–55%). The `launchPtySession({ laneId, profile, command?, args?, startupCommand?, -title?, tracked? })` helper (and its lane-scoped twin in +env?, title?, tracked? })` helper (and its lane-scoped twin in `useLaneWorkSessions`) builds a default launch payload with `buildTrackedCliLaunchCommand` when the caller didn't override -`command`/`args`, so every entry point — chat composer launch button, -TopBar work controls, lane Work pane — produces the same argv-based -spawn with ADE CLI guidance baked in. +`command`/`args`/`env`, so every entry point — chat composer launch +button, TopBar work controls, lane Work pane — produces the same +argv-based spawn with ADE CLI guidance baked in. `profile` is a +`LaunchProfile` (`"claude" | "codex" | "cursor" | "droid" | "opencode" +| "shell"`); the matching tab title and recorded `TerminalToolType` +come from the shared `LAUNCH_PROFILE_TITLE` / `LAUNCH_PROFILE_TOOL_TYPE` +maps in `cliLaunch.ts`. `inferToolFromResumeCommand` strips leading +`ENV=value` assignments before sniffing the provider, so resume +commands the OpenCode preamble emits (`OPENCODE_CONFIG_CONTENT=… +opencode --session …`) round-trip correctly. `useLaneWorkSessions` (same file) wraps the same state but scopes to a single lane for the Lanes tab.