diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e6ff947c1..566b74d72c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -626,6 +626,12 @@ jobs: - name: Test Windows desktop, SQLite, and capability contracts run: cd apps/desktop && npx vitest run src/main/packagedRuntimeSmoke.test.ts src/main/services/computerUse/localComputerUse.test.ts src/renderer/lib/platform.test.ts + # These path-equivalence assertions skip on Linux because case-only path + # changes are meaningful there. Run them on Windows so the worker pools' + # case-insensitive path reuse is exercised by a native filesystem target. + - name: Test Windows SDK worker activity path contracts + run: cd apps/desktop && npx vitest run src/main/services/chat/cursorSdkPool.test.ts src/main/services/chat/piSdkPool.test.ts + # The suites docs/development/windows-port-lane.md names as the Windows # validation set. Before this step the intersection with this job was # empty, so the documented gate was never actually run on Windows. diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index aedc66ef3a..dadb857c88 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -623,7 +623,8 @@ ade chat steer session-id --text "active-turn context" ade chat steer session-id --text "active-turn context" --dispatch interrupt # atomic active-turn delivery: inline | interrupt; omit to stage for the next turn (Claude and Cursor take both; a Cursor cloud run declines inline and stages) ade chat note "testing desktop auth fallback" # update Work status (aim for 6 words or fewer; truncated past 72 characters); add --session to target explicitly ade chat ask "Which account should I use?" # escalate a blocking question; add --session to target explicitly -ade session show session-id --text # status + elapsed, live agent pids, settle/snooze state, and why a snoozed row came back +ade chat activity testing # set one fixed detail: planning|implementing|testing|reviewing|debugging|monitoring; `clear` removes it; add --session to target explicitly +ade session show session-id --text # status/activity detail + elapsed, live agent pids, settle/snooze state, and why a snoozed row came back ade --role cto session move session-id --to done # file the row under a Work-board column: needs-you|working|done ('needs_you' spelling also accepted) # CTO-only, like every other settle-column writer: a move tells the agent the USER moved it, so a session-bound agent must not move its own card # 'waiting' is refused — a row sits there because it is snoozed or its PR is mid-CI, so it is derived, never a target diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 63a4982b21..a31ed00586 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -15,6 +15,7 @@ import { } from "../../desktop/src/main/services/builtInBrowser/builtInBrowserActorCapabilities"; import { BUILT_IN_BROWSER_ACTOR_CAPABILITY_PARAM } from "./services/builtInBrowser/desktopBridgeMethods"; import { ADE_BUNDLED_AGENT_SKILLS_DIR_ENV } from "../../desktop/src/shared/agentSkillRoots"; +import { buildTrackedCliSessionActivityGuidance } from "../../desktop/src/shared/cliLaunch"; import { CTO_VOICE_ACTIONS } from "../../desktop/src/shared/types/ctoVoice"; type RuntimeFixture = ReturnType; @@ -94,6 +95,7 @@ function createRuntime() { projectRoot, workspaceRoot: projectRoot, projectId: "project-1", + sessionActivityReportingEnabled: true, project: { rootPath: projectRoot, displayName: "project", baseRef: "main" }, paths: { adeDir: path.join(projectRoot, ".ade"), @@ -219,6 +221,7 @@ function createRuntime() { updateMeta: vi.fn(), readTranscriptTail: vi.fn(() => ""), requestAttention: vi.fn(() => true), + setSessionActivity: vi.fn(() => true), setStatusNote: vi.fn(() => true), settleSession: vi.fn(() => true), unsettleSession: vi.fn(() => true), @@ -1641,6 +1644,20 @@ describe("adeRpcServer", () => { attentionRequestedAt: null, lastTurnFailedAt: null, } as any); + runtime.sessionService.get.mockImplementation((sessionId: string) => { + if (sessionId === "attached-terminal") { + return { id: "attached-terminal", chatSessionId: "chat-1" } as any; + } + if (sessionId === "chat-1") { + return { + id: "chat-1", + toolType: "codex-chat", + attentionRequestedAt: null, + lastTurnFailedAt: null, + } as any; + } + return null; + }); const lifecycleCalls = [ { @@ -1651,6 +1668,14 @@ describe("adeRpcServer", () => { "Choose a release channel.", ), }, + { + action: "setSessionActivity", + args: { value: "testing" }, + assert: () => expect(runtime.sessionService.setSessionActivity).toHaveBeenCalledWith( + "chat-1", + "testing", + ), + }, { action: "setSessionStatusNote", args: { note: "Waiting for release choice" }, @@ -1670,6 +1695,14 @@ describe("adeRpcServer", () => { lifecycle.assert(); } + const attachedTerminalActivity = await callTool(handler, "run_ade_action", { + domain: "session", + action: "setSessionActivity", + args: { sessionId: "attached-terminal", value: "testing" }, + }); + expect(attachedTerminalActivity?.isError).toBeUndefined(); + expect(runtime.sessionService.setSessionActivity).toHaveBeenCalledWith("attached-terminal", "testing"); + const denied = await callTool(handler, "run_ade_action", { domain: "session", action: "setSessionStatusNote", @@ -1680,6 +1713,13 @@ describe("adeRpcServer", () => { "chat-2", "Cross-session write", ); + const activityDenied = await callTool(handler, "run_ade_action", { + domain: "session", + action: "setSessionActivity", + args: { sessionId: "chat-2", value: "testing" }, + }); + expect(activityDenied.isError).toBe(true); + expect(runtime.sessionService.setSessionActivity).not.toHaveBeenCalledWith("chat-2", "testing"); // Settlement is user- and PR-merge-driven only (2026-07). A session-bound // caller gets no settle writer at all: the caller-scoped `*SelfSession` @@ -1726,6 +1766,14 @@ describe("adeRpcServer", () => { "Running CLI checks", ); + const activity = await callTool(handler, "run_ade_action", { + domain: "session", + action: "setSessionActivity", + args: { value: "monitoring" }, + }); + expect(activity?.isError).toBeUndefined(); + expect(runtime.sessionService.setSessionActivity).toHaveBeenCalledWith("chat-from-env", "monitoring"); + const denied = await callTool(handler, "run_ade_action", { domain: "session", action: "setSessionStatusNote", @@ -2502,6 +2550,102 @@ describe("adeRpcServer", () => { }); }); + it("omits activity guidance when the runtime cannot accept activity reports", async () => { + const fixture = createRuntime(); + fixture.runtime.sessionActivityReportingEnabled = false; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "agent" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + permissionMode: "edit", + initialInput: "run the checks", + }); + + expect(response?.isError).toBeUndefined(); + const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall).toBeDefined(); + expect(JSON.stringify(createCall)).not.toContain("Activity detail for this tracked ADE CLI session"); + }); + + it.each([ + { + name: "Claude", + args: { provider: "claude", permissionMode: "default" }, + available: false, + }, + { + name: "Codex", + args: { provider: "codex", permissionMode: "edit" }, + available: true, + }, + { + name: "Cursor with an initial prompt", + args: { provider: "cursor", permissionMode: "edit", hasInitialPrompt: true }, + available: true, + }, + { + name: "blank Cursor", + args: { provider: "cursor", permissionMode: "edit", hasInitialPrompt: false }, + available: false, + }, + { + name: "write-capable Droid", + args: { provider: "droid", permissionMode: "default", droidPermissionMode: "auto-medium" }, + available: true, + }, + { + name: "AGI Droid", + args: { provider: "droid", permissionMode: "default", droidPermissionMode: "agi" }, + available: false, + }, + { + name: "OpenCode", + args: { provider: "opencode", permissionMode: "edit" }, + available: true, + }, + { + name: "OpenCode with external config", + args: { provider: "opencode", permissionMode: "config-toml" }, + available: false, + }, + { + name: "full-auto Pi", + args: { provider: "pi", permissionMode: "full-auto" }, + available: true, + }, + { + name: "non-full-auto Pi", + args: { provider: "pi", permissionMode: "edit" }, + available: false, + }, + { name: "Qwen", args: { provider: "qwen", permissionMode: "edit" }, available: false }, + { name: "Kimi", args: { provider: "kimi", permissionMode: "edit" }, available: false }, + { name: "Grok", args: { provider: "grok", permissionMode: "edit" }, available: false }, + { name: "Copilot", args: { provider: "copilot", permissionMode: "edit" }, available: false }, + { + name: "Codex in Plan mode", + args: { provider: "codex", permissionMode: "plan" }, + available: false, + }, + ] satisfies Array<{ + name: string; + args: Parameters[0]; + available: boolean; + }>)("gates tracked CLI activity guidance for $name", ({ args, available }) => { + const guidance = buildTrackedCliSessionActivityGuidance({ + ...args, + sessionActivityReportingEnabled: true, + }); + + expect(guidance !== null).toBe(available); + if (available) { + expect(guidance).toContain("ADE_ACTIVITY_SESSION_ID"); + expect(guidance).toContain("chat activity testing"); + } + }); + it("persists a requested preset id in resume metadata even when resolution is unavailable", async () => { const fixture = createRuntime(); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-rpc-preset-metadata-")); @@ -7014,6 +7158,21 @@ describe("run_ade_action search scope", () => { expect(args.callerScope).toBeUndefined(); }); + it("denies unbound agent CLI activity writes to arbitrary sessions", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "ade-cli:4242", role: "agent" }); + + const response = await callTool(handler, "run_ade_action", { + domain: "session", + action: "setSessionActivity", + args: { sessionId: "another-session", value: "testing" }, + }); + + expect(response?.isError).toBe(true); + expect(runtime.sessionService.setSessionActivity).not.toHaveBeenCalled(); + }); + it("leaves an unbound external caller unscoped", async () => { const fixture = createRuntime(); const search = searchServiceMock(); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index e622f97ded..41ca20b12c 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2747,6 +2747,7 @@ const SCOPED_CHAT_ACTIONS = new Set([ // session-bound agent may only aim it at its own row. "continueUsageLimitOnAlternate", "requestSessionAttention", + "setSessionActivity", "setSessionStatusNote", // `settleSelfSession` / `unsettleSelfSession` used to be scoped here so a // bound agent could only settle its OWN row. Both actions were removed in @@ -2769,6 +2770,7 @@ function chatUpdateSessionMutatesSpawnKind(chatArgs: Record): b } function scopeChatAdeActionArgs( + runtime: AdeRuntime, session: SessionState, action: string, chatArgs: Record, @@ -2777,7 +2779,18 @@ function scopeChatAdeActionArgs( const method = `run_ade_action:${domain}.${action}`; const spawnKindUpdate = action === "updateSession" && chatUpdateSessionMutatesSpawnKind(chatArgs); if (!SCOPED_CHAT_ACTIONS.has(action) && !spawnKindUpdate) return chatArgs; - if (isUnboundAdeCliCaller(session)) return chatArgs; + if (isUnboundAdeCliCaller(session)) { + // A direct, unbound `ade` CLI keeps project-wide access for read actions, + // but agent-reported activity is a write and must stay attached to the + // caller's own chat or tracked terminal. The CTO/user path is unaffected. + if (action === "setSessionActivity") { + chatAccessDenied(method, { + callerChatSessionId: null, + requestedSessionId: asOptionalTrimmedString(chatArgs.sessionId), + }); + } + return chatArgs; + } const scopedArgs = { ...chatArgs }; const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId); @@ -2787,7 +2800,14 @@ function scopeChatAdeActionArgs( chatAccessDenied(method, { callerChatSessionId, requestedSessionId }); } - if (!callerChatSessionId || (requestedSessionId && requestedSessionId !== callerChatSessionId)) { + const requestedOwnedTerminal = action === "setSessionActivity" + && requestedSessionId != null + && callerChatSessionId != null + && runtime.sessionService.get(requestedSessionId)?.chatSessionId === callerChatSessionId; + if ( + !callerChatSessionId + || (requestedSessionId && requestedSessionId !== callerChatSessionId && !requestedOwnedTerminal) + ) { chatAccessDenied(method, { callerChatSessionId, requestedSessionId }); } if (!requestedSessionId) scopedArgs.sessionId = callerChatSessionId; @@ -4119,6 +4139,7 @@ async function runTool(args: { }; } else { scopedObjectArgs = scopeChatAdeActionArgs( + runtime, session, action, chatArgs, @@ -4130,6 +4151,7 @@ async function runTool(args: { && SCOPED_CHAT_ACTIONS.has(action) ) { scopedObjectArgs = scopeChatAdeActionArgs( + runtime, session, action, requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs), @@ -4427,6 +4449,7 @@ async function runTool(args: { return buildTrackedCliLaunchCommand({ provider, permissionMode, + sessionActivityReportingEnabled: runtime.sessionActivityReportingEnabled, ...(droidPermissionMode ? { droidPermissionMode } : {}), sessionId: preassignedSessionId, model, diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index f52d2403c6..977df49a6c 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -322,6 +322,8 @@ export type AdeRuntime = { projectId: string; project: { rootPath: string; displayName: string; baseRef: string }; paths: AdeRuntimePaths; + /** Whether this runtime serves the RPC endpoint needed by activity reports. */ + sessionActivityReportingEnabled: boolean; logger: Logger; db: AdeDb; keybindingsService?: ReturnType | null; @@ -744,6 +746,7 @@ export async function createAdeRuntime(args: { const runtimeSocketPath = typeof resolvedArgs.runtimeSocketPath === "string" ? resolvedArgs.runtimeSocketPath.trim() || paths.socketPath : paths.socketPath; + const sessionActivityReportingEnabled = !embeddedRuntime && Boolean(runtimeSocketPath); const logger = createFileLogger(path.join(paths.logsDir, "ade-cli.jsonl")); const diskPressureMonitor = createDiskPressureMonitor({ roots: [projectRoot, resolveMachineAdeLayout().adeDir], @@ -1304,6 +1307,8 @@ export async function createAdeRuntime(args: { const ptyService = createPtyService({ projectRoot, + runtimeSocketPath, + sessionActivityReportingEnabled, transcriptsDir: paths.transcriptsDir, laneService, sessionService, @@ -1640,6 +1645,7 @@ export async function createAdeRuntime(args: { browserActorCapabilityIssuer, projectRoot, runtimeSocketPath, + sessionActivityReportingEnabled, adeDir: paths.adeDir, transcriptsDir: paths.transcriptsDir, fileService: headlessLinearServices.fileService, @@ -2390,6 +2396,7 @@ export async function createAdeRuntime(args: { projectRoot, appVersion: syncRuntimeOptions.appVersion ?? "ade-cli", runtimeKind: syncRuntimeOptions.runtimeKind ?? "headless", + sessionActivityReportingEnabled, localDeviceIdPath: syncRuntimeOptions.localDeviceIdPath, phonePairingStateDir: syncRuntimeOptions.phonePairingStateDir, fileService: headlessLinearServices.fileService, @@ -2552,6 +2559,7 @@ export async function createAdeRuntime(args: { projectId, project, paths, + sessionActivityReportingEnabled, logger, db, keybindingsService, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index b8de5a8f37..4a6b40429a 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2964,6 +2964,26 @@ describe("ADE CLI", () => { // provider happened to emit three seconds ago. expect(text).toMatch(/^status\s+Background work \u00d72 2h$/mu); expect(text).toContain("pid 59213 (2h)"); + + const activityText = formatOutput( + { + sessionId: "chat-1", + status: "running", + runtimeState: "running", + toolType: "claude-chat", + currentTurnStartedAt: new Date(now - 60_000).toISOString(), + lastActivityAt: new Date(now - 1_000).toISOString(), + activityStatus: { + value: "testing", + source: "agent", + updatedAt: new Date(now - 30_000).toISOString(), + }, + }, + { ...baseResolveOpts(), projectRoot: null, workspaceRoot: null, text: true }, + "session-lifecycle", + ); + expect(activityText).toMatch(/^status\s+Testing\b/mu); + expect(activityText).not.toMatch(/^status\s+Working\b/mu); }); it("ade session show mutation acks carry no activity lines", () => { @@ -4988,6 +5008,11 @@ describe("ADE CLI", () => { action: "setSessionStatusNote", args: { note: "running e2e shard 2/4" }, }, + { + command: ["activity", "testing"], + action: "setSessionActivity", + args: { value: "testing" }, + }, ]; for (const testCase of cases) { @@ -5011,6 +5036,29 @@ describe("ADE CLI", () => { }, }); + const clearActivity = expectExecutePlan(buildCliPlan(["chat", "activity", "clear"])); + expect(clearActivity.steps[0]?.params).toMatchObject({ + arguments: { + domain: "session", + action: "setSessionActivity", + args: { value: null }, + }, + }); + const terminalActivity = withEnv({ ADE_ACTIVITY_SESSION_ID: "terminal-row-1" }, () => + expectExecutePlan(buildCliPlan(["chat", "activity", "testing"])), + ); + expect(terminalActivity.steps[0]?.params).toMatchObject({ + arguments: { + domain: "session", + action: "setSessionActivity", + args: { sessionId: "terminal-row-1", value: "testing" }, + }, + }); + expect(() => buildCliPlan(["chat", "activity", "coding"])) + .toThrow(/Unsupported chat activity 'coding'.*planning.*monitoring.*clear/i); + expect(() => buildCliPlan(["chat", "activity"])) + .toThrow(/chat activity requires one value/i); + const textOutput = parseCliArgs(["chat", "note", "working", "--text"]); expect(textOutput.options.text).toBe(true); expect(textOutput.command).toEqual(["chat", "note", "working"]); @@ -5053,6 +5101,11 @@ describe("ADE CLI", () => { expect(help.kind).toBe("help"); if (help.kind === "help") { expect(help.text).toContain("ade chat note"); + expect(help.text).toContain("ade chat activity testing"); + expect(help.text).toContain("planning | implementing | testing | reviewing | debugging | monitoring"); + expect(help.text).toContain( + "Agent callers need a bound ADE Work chat; --session may target that chat or a tracked terminal it owns. CTO callers may target sessions explicitly.", + ); expect(help.text).toContain("ade chat ask"); expect(help.text).toContain("ade chat generate-names"); expect(help.text).toContain("ade chat demote"); @@ -5071,6 +5124,7 @@ describe("ADE CLI", () => { it.each([ ["ask", ["q"], "requestSessionAttention", { message: "q" }], ["note", ["working"], "setSessionStatusNote", { note: "working" }], + ["activity", ["debugging"], "setSessionActivity", { value: "debugging" }], ])( "passes --session through for chat %s", (subcommand, commandArgs, action, expectedArgs) => { @@ -6315,6 +6369,18 @@ describe("ADE CLI", () => { argsList: ["chat-1"], }, }); + expect(show.formatter).toBe("chat-summary"); + expect(formatOutput({ + sessionId: "chat-1", + provider: "codex", + model: "gpt-5.6", + codexEffectiveCollaborationMode: "plan", + activityStatus: { + value: "testing", + source: "agent", + updatedAt: "2026-09-22T12:00:00.000Z", + }, + }, { text: true } as any, inferFormatter(show))).toMatch(/collaboration mode\s+plan\s+activity\s+Testing/); const status = buildCliPlan(["chat", "status", "--session-id", "chat-2"]); expect(status.kind).toBe("execute"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index ce4c767f1e..710d9b3995 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -110,6 +110,11 @@ import { type ChatTurnStatusSnapshot, } from "../../desktop/src/shared/chatTurnStatus"; import type { TerminalSessionSummary } from "../../desktop/src/shared/types/sessions"; +import { SESSION_ACTIVITY_VALUES } from "../../desktop/src/shared/types/sessions"; +import { + isSessionActivityValue, + SESSION_ACTIVITY_SESSION_ID_ENV, +} from "../../desktop/src/shared/sessionActivity"; import { formatWorkingDuration, sessionElapsedLabel, @@ -408,6 +413,7 @@ type FormatterId = | "pr-checks" | "pr-comments" | "chat-list" + | "chat-summary" | "chat-read" | "chat-status" | "chat-models" @@ -2892,7 +2898,7 @@ const HELP_BY_COMMAND: Record = { $ ade chat launch-status One launch's stages (exit 1 when unknown or expired) $ ade chat launch-cancel Cancel a launch before its agent starts; deletes its chat, lane, and branch $ ade chat send --text "next step" Send a message; steers automatically if the turn is active - $ ade chat show Session summary (title, provider, model) + $ ade chat show Session summary (title, provider, model, activity report) $ ade chat status Live turn status: RUNNING / BLOCKED / IDLE Exit 0 running, 1 idle, 2 blocked. Use --text. Adds a 'resume' line while a usage limit is live. @@ -2901,6 +2907,8 @@ const HELP_BY_COMMAND: Record = { $ ade chat continue-on-account Continue a usage-limited chat on another account that still has room Exit 1 when no other account can take it. $ ade chat note "testing desktop auth fallback" # Update the Work status line (aim for ${STATUS_NOTE_GUIDELINE_WORDS} words or fewer; truncated past ${MAX_STATUS_NOTE_CHARACTERS} characters) + $ ade chat activity testing Report a fixed activity label for this turn; use clear to remove it + Values: ${SESSION_ACTIVITY_VALUES.join(" | ")}. Agent callers need a bound ADE Work chat; --session may target that chat or a tracked terminal it owns. CTO callers may target sessions explicitly. $ ade chat ask "Which account should I use?" Escalate a blocking question to the user 'note' and 'ask' default to the caller and accept --session . 'chat settle' / 'chat unsettle' were removed: only the user (or a @@ -9243,7 +9251,7 @@ function buildChatPlan(args: string[]): CliPlan { : null; // `ask` / `note` take free text, not a session positional — they default to // the caller's own $ADE_CHAT_SESSION_ID and accept --session . - const selfLifecycleSub = sub === "ask" || sub === "note" + const selfLifecycleSub = sub === "ask" || sub === "note" || sub === "activity" || sub === "generate-names" || sub === "generate_names" || sub === "names"; // New-lane launches take a prompt (`launch`) or a launch id (`launch-status`, // `launch-cancel`), never a session positional; they read their own. @@ -9252,6 +9260,7 @@ function buildChatPlan(args: string[]): CliPlan { const explicitSessionId = readValue(args, ["--session", "--session-id"]); const sessionId = explicitSessionId ?? + (sub === "activity" ? process.env[SESSION_ACTIVITY_SESSION_ID_ENV]?.trim() || null : null) ?? (sub !== "create" && sub !== "list" && !linearSessionSub && !selfLifecycleSub && !launchSub ? firstStandalonePositional(args) : null); @@ -9294,6 +9303,32 @@ function buildChatPlan(args: string[]): CliPlan { ], }; } + if (sub === "activity") { + const rawValue = firstStandalonePositional(args); + const normalizedValue = rawValue?.trim().toLowerCase(); + if (!normalizedValue) { + throw new CliUsageError( + `chat activity requires one value: ${[...SESSION_ACTIVITY_VALUES, "clear"].join(" | ")}.`, + ); + } + if (normalizedValue !== "clear" && !isSessionActivityValue(normalizedValue)) { + throw new CliUsageError( + `Unsupported chat activity '${rawValue}'. Use: ${[...SESSION_ACTIVITY_VALUES, "clear"].join(" | ")}.`, + ); + } + return { + kind: "execute", + label: "chat activity", + steps: [ + actionStep( + "result", + "session", + "setSessionActivity", + withSession({ value: normalizedValue === "clear" ? null : normalizedValue }), + ), + ], + }; + } if (sub === "generate-names" || sub === "generate_names" || sub === "names") { const fields: string[] = []; if (readFlag(args, ["--title"])) fields.push("title"); @@ -9353,6 +9388,7 @@ function buildChatPlan(args: string[]): CliPlan { return { kind: "execute", label: "chat show", + formatter: "chat-summary", steps: [ actionArgsListStep("result", "chat", "getSessionSummary", [ requireValue(sessionId, "sessionId"), @@ -25013,6 +25049,24 @@ function formatChatList(value: unknown): string { ); } +function formatChatSummary(value: unknown): string { + const record = isRecord(value) ? value : {}; + const activity = isRecord(record.activityStatus) ? record.activityStatus : null; + const rawActivity = asString(activity?.value); + const activityLabel = rawActivity + ? `${rawActivity.slice(0, 1).toUpperCase()}${rawActivity.slice(1)}` + : null; + return renderKeyValues("ADE chat session", [ + ["session", record.sessionId], + ["title", record.title], + ["provider", record.provider], + ["model", record.model], + ["collaboration mode", record.codexEffectiveCollaborationMode], + ["activity", activityLabel], + ["reported at", activity?.updatedAt], + ]); +} + /** * The runtime provider a model family belongs to. * @@ -26928,6 +26982,8 @@ function formatTextOutput( return formatPrComments(value); case "chat-list": return formatChatList(value); + case "chat-summary": + return formatChatSummary(value); case "chat-models": return formatChatModels(value); case "chat-status": @@ -27103,6 +27159,7 @@ function inferFormatter( if (label === "pr checks") return "pr-checks"; if (label === "pr comments") return "pr-comments"; if (label === "chat list") return "chat-list"; + if (label === "chat show") return "chat-summary"; if (label === "chat models" || label === "personal chat models") return "chat-models"; if (label === "chat status") return "chat-status"; if (label === "chat resume-now") return "chat-resume-now"; diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts index 1bb8113159..e775d1df54 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts @@ -51,6 +51,7 @@ function seedDatabase(): void { tool_type text, title text, status text, + ended_at text, last_output_preview text, last_output_at text, pinned integer, @@ -59,6 +60,8 @@ function seedDatabase(): void { archived_at text, settled_at text, status_note text, + activity_status_json text, + activity_status_changed_at text, attention_requested_at text, attention_message text, last_turn_failed_at text, @@ -105,12 +108,14 @@ function seedDatabase(): void { db.prepare( ` update terminal_sessions - set settled_at = ?, status_note = ? + set settled_at = ?, status_note = ?, activity_status_json = ?, activity_status_changed_at = ? where id = ? `, ).run( "2026-01-02T00:01:00Z", "Indexing complete and waiting for final review now", + JSON.stringify({ value: "testing", source: "agent", updatedAt: "2026-01-02T00:02:00Z" }), + "2026-01-02T00:02:00.000Z", "chat-run", ); db.prepare( @@ -123,6 +128,8 @@ function seedDatabase(): void { db.prepare( "update terminal_sessions set last_turn_failed_at = ? where id = ?", ).run("2026-01-01T12:01:00Z", "cli-fail"); + db.prepare("update terminal_sessions set ended_at = ? where id = ?") + .run("2026-01-01T07:00:00Z", "cli-end"); db.close(); @@ -267,6 +274,10 @@ describe("buildRosterSnapshot", () => { settledAt: "2026-01-02T00:01:00Z", // Eight words survive: the note only truncates past 72 characters. statusNote: "Indexing complete and waiting for final review now", + activityStatus: { value: "testing", source: "agent", updatedAt: "2026-01-02T00:02:00.000Z" }, + activityStatusChangedAt: "2026-01-02T00:02:00.000Z", + lifecycleUpdatedAt: "2026-01-02T00:01:00Z", + lastActivityAt: "2026-01-02T00:02:00.000Z", exitCode: null, }); expect(byId.get("chat-await")).toMatchObject({ @@ -279,7 +290,27 @@ describe("buildRosterSnapshot", () => { lastTurnFailedAt: "2026-01-01T12:01:00Z", exitCode: 1, }); - expect(byId.get("cli-end")!.exitCode).toBe(0); + expect(byId.get("cli-end")).toMatchObject({ + exitCode: 0, + lifecycleUpdatedAt: "2026-01-01T07:00:00Z", + }); + }); + + it("preserves an explicit activity clear timestamp in the roster", async () => { + const db = new DatabaseSync(path.join(projectRoot, ".ade", "ade.db")); + db.prepare( + `update terminal_sessions + set activity_status_json = null, activity_status_changed_at = ? + where id = ?`, + ).run("2026-01-02T00:03:00Z", "chat-run"); + db.close(); + + const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); + const chat = projects[0]!.chats.find((row) => row.id === "chat-run")!; + expect(chat.activityStatus).toBeNull(); + expect(chat.activityStatusChangedAt).toBe("2026-01-02T00:03:00Z"); + expect(chat.lifecycleUpdatedAt).toBe("2026-01-02T00:01:00Z"); + expect(chat.lastActivityAt).toBe("2026-01-02T00:03:00Z"); }); it("tolerates legacy project databases that omit settled lifecycle columns", async () => { @@ -287,6 +318,8 @@ describe("buildRosterSnapshot", () => { for (const column of [ "settled_at", "status_note", + "activity_status_json", + "activity_status_changed_at", "attention_requested_at", "attention_message", "last_turn_failed_at", @@ -299,6 +332,7 @@ describe("buildRosterSnapshot", () => { const chat = projects[0]!.chats.find((row) => row.id === "chat-run")!; expect(chat.settledAt).toBeNull(); expect(chat.statusNote).toBeNull(); + expect(chat.activityStatus).toBeNull(); expect(chat.attentionRequestedAt).toBeNull(); expect(chat.attentionMessage).toBeNull(); expect(chat.lastTurnFailedAt).toBeNull(); diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts index fc4127bf56..5dbec7d092 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; import { normalizeSessionStatusNote } from "../../../../desktop/src/shared/sessionStatusNote"; +import { normalizeSessionActivityReport } from "../../../../desktop/src/shared/sessionActivity"; import { isSessionSnoozed } from "../../../../desktop/src/shared/sessionCanonicalState"; import type { SyncRosterChat, @@ -130,6 +131,7 @@ type TerminalSessionRow = { tool_type: string | null; title: string | null; status: string | null; + ended_at: string | null; last_output_preview: string | null; last_output_at: string | null; pinned: number | null; @@ -137,6 +139,8 @@ type TerminalSessionRow = { started_at: string | null; settled_at: string | null; status_note: string | null; + activity_status_json: string | null; + activity_status_changed_at: string | null; attention_requested_at: string | null; attention_message: string | null; last_turn_failed_at: string | null; @@ -286,6 +290,12 @@ function readProjectFromDisk(projectRoot: string, logger?: Pick const statusNoteColumn = hasColumn(activeDb, "terminal_sessions", "status_note") ? "status_note" : "null as status_note"; + const activityStatusColumn = hasColumn(activeDb, "terminal_sessions", "activity_status_json") + ? "activity_status_json" + : "null as activity_status_json"; + const activityStatusChangedAtColumn = hasColumn(activeDb, "terminal_sessions", "activity_status_changed_at") + ? "activity_status_changed_at" + : "null as activity_status_changed_at"; const attentionRequestedAtColumn = hasColumn(activeDb, "terminal_sessions", "attention_requested_at") ? "attention_requested_at" : "null as attention_requested_at"; @@ -304,9 +314,9 @@ function readProjectFromDisk(projectRoot: string, logger?: Pick return activeDb .prepare( ` - select id, lane_id, ${chatSessionIdColumn}, tool_type, title, status, last_output_preview, + select id, lane_id, ${chatSessionIdColumn}, tool_type, title, status, ended_at, last_output_preview, last_output_at, pinned, exit_code, started_at, - ${settledAtColumn}, ${statusNoteColumn}, ${attentionRequestedAtColumn}, + ${settledAtColumn}, ${statusNoteColumn}, ${activityStatusColumn}, ${activityStatusChangedAtColumn}, ${attentionRequestedAtColumn}, ${attentionMessageColumn}, ${lastTurnFailedAtColumn}, ${snoozedUntilColumn}, ${snoozedAtColumn} from terminal_sessions @@ -521,14 +531,18 @@ async function buildRosterProject( const countsTowardAttention = isRosterTopLevelToolType(row.tool_type) || normalizedParentSessionId(row) != null; if ((status === "awaiting" || status === "failed") && countsTowardAttention) attentionCount += 1; - const lastActivityAt = latestActivityTimestamp( + const activityStatus = normalizeSessionActivityReport(row.activity_status_json); + const activityStatusChangedAt = row.activity_status_changed_at ?? activityStatus?.updatedAt ?? null; + const lifecycleUpdatedAt = latestActivityTimestamp( live?.lastActivityAt, row.attention_requested_at, row.settled_at, row.last_turn_failed_at, + row.ended_at, row.last_output_at, row.started_at, ); + const lastActivityAt = latestActivityTimestamp(lifecycleUpdatedAt, activityStatusChangedAt); chats.push({ id: row.id, laneId: row.lane_id, @@ -542,9 +556,12 @@ async function buildRosterProject( ...(awaitingInput ? { awaitingInput: true } : {}), ...(row.pinned ? { pinned: true } : {}), lastActivityAt, + lifecycleUpdatedAt, + activityStatusChangedAt, preview: truncatePreview(row.last_output_preview), settledAt: row.settled_at, statusNote: normalizeSessionStatusNote(row.status_note), + activityStatus, attentionRequestedAt: row.attention_requested_at, attentionMessage: row.attention_message, lastTurnFailedAt: row.last_turn_failed_at, diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index e3b66f3d1b..9a55d5b3ce 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -23,6 +23,8 @@ import type { SyncPeerMetadata, SyncProjectCatalogPayload, SyncRemoteCommandDescriptor, + SyncRosterProject, + TerminalSessionChangedEvent, } from "../../../../desktop/src/shared/types"; import { SYNC_COMPACT_INVALIDATION_V1_CAPABILITY, @@ -8566,7 +8568,7 @@ describe("inbound changeset_batch guards", () => { } /** One column of one `terminal_sessions` row, as a peer would author it. */ - function makeSettleChange(cid: string, dbVersion: number, seq: number, val: string): CrsqlChangeRow { + function makeTerminalSessionChange(cid: string, dbVersion: number, seq: number, val: string): CrsqlChangeRow { const change = makePeerChange("terminal_sessions", dbVersion, seq, val); change.cid = cid; change.pk = "session-1"; @@ -8705,7 +8707,7 @@ describe("inbound changeset_batch guards", () => { } }); - it("strips a phone's settle columns while applying the rest of the same batch", async () => { + it("strips a phone's host-authoritative session columns while applying the rest of the batch", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const applyChanges = vi.fn((changes: CrsqlChangeRow[]) => ({ appliedCount: changes.length })); const host = createGuardHost(projectRoot, applyChanges); @@ -8714,12 +8716,11 @@ describe("inbound changeset_batch guards", () => { const port = await host.waitUntilListening(); peer = await connectPeer(port, host.getBootstrapToken(), "ios-settler"); - // `settled_at` is host-authoritative. A phone on a build that predates the - // fix still writes it into its own CRR replica optimistically, and - // `terminal_sessions` replicates — so without this filter the phone's row - // merges upstream and settles a session the host *rejected*. The guard has - // to live here because a CRDT merge never reaches the caller a host-side - // check would guard. + // The host owns both settlement and agent activity reports. A phone on an + // older build can write these columns optimistically into its own CRR + // replica; without this filter, those values merge upstream even though + // the host did not authorize them. The guard has to live here because a + // CRDT merge never reaches the caller a host-side check would guard. const requestId = "batch-settle"; peer.ws.send(encodeSyncEnvelope({ type: "changeset_batch", @@ -8727,21 +8728,25 @@ describe("inbound changeset_batch guards", () => { payload: { batchId: requestId, fromDbVersion: 0, - toDbVersion: 5, + toDbVersion: 7, changes: [ - makeSettleChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z"), - makeSettleChange("settle_override", 2, 1, "settled"), - makeSettleChange("settle_source", 3, 2, "user"), + makeTerminalSessionChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z"), + makeTerminalSessionChange("settle_override", 2, 1, "settled"), + makeTerminalSessionChange("settle_source", 3, 2, "user"), // The snooze overlay is NOT host-authoritative — the phone owns its // optimistic write there and it must keep replicating. - makeSettleChange("snoozed_until", 4, 3, "2026-08-11T00:00:00.000Z"), - makeSettleChange("title", 5, 4, "renamed from phone"), + makeTerminalSessionChange("snoozed_until", 4, 3, "2026-08-11T00:00:00.000Z"), + makeTerminalSessionChange("title", 5, 4, "renamed from phone"), + makeTerminalSessionChange("activity_status_json", 6, 5, '{"value":"testing"}'), + makeTerminalSessionChange("activity_status_changed_at", 7, 6, "2026-08-10T00:01:00.000Z"), ], }, })); const ack = await waitForEnvelope(peer.envelopes, "changeset_ack", requestId); - expect((ack.payload as { ok?: boolean }).ok).toBe(true); + const ackPayload = ack.payload as { ok?: boolean; appliedCount?: number }; + expect(ackPayload.ok).toBe(true); + expect(ackPayload.appliedCount).toBe(2); expect(applyChanges).toHaveBeenCalledTimes(1); const appliedRows = applyChanges.mock.calls[0]?.[0] as CrsqlChangeRow[]; expect(appliedRows.map((row) => row.cid)).toEqual(["snoozed_until", "title"]); @@ -8756,7 +8761,7 @@ describe("inbound changeset_batch guards", () => { } }); - it("acks a batch that was entirely settle columns without applying anything", async () => { + it("acks a batch made entirely of host-authoritative session columns", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const applyChanges = vi.fn((changes: CrsqlChangeRow[]) => ({ appliedCount: changes.length })); const host = createGuardHost(projectRoot, applyChanges); @@ -8772,8 +8777,13 @@ describe("inbound changeset_batch guards", () => { payload: { batchId: requestId, fromDbVersion: 0, - toDbVersion: 1, - changes: [makeSettleChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z")], + toDbVersion: 4, + changes: [ + makeTerminalSessionChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z"), + makeTerminalSessionChange("settle_override", 2, 1, "settled"), + makeTerminalSessionChange("activity_status_json", 3, 2, '{"value":"testing"}'), + makeTerminalSessionChange("activity_status_changed_at", 4, 3, "2026-08-10T00:01:00.000Z"), + ], }, })); @@ -8795,15 +8805,15 @@ describe("inbound changeset_batch guards", () => { } }); - it("keeps applying settle columns from a paired desktop peer", async () => { + it("keeps applying host-authoritative session columns from a paired desktop peer", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const applyChanges = vi.fn((changes: CrsqlChangeRow[]) => ({ appliedCount: changes.length })); const host = createGuardHost(projectRoot, applyChanges); let peer: Awaited> | null = null; try { const port = await host.waitUntilListening(); - // A desktop runs the same `sessionService` chokepoint, so its settle - // writes are host-decided too and must keep replicating. + // A desktop runs the same `sessionService` chokepoint, so its settlement + // and activity writes are host-decided too and must keep replicating. peer = await connectPeer(port, host.getBootstrapToken(), "desktop-peer", { platform: "macOS", deviceType: "desktop", @@ -8816,18 +8826,26 @@ describe("inbound changeset_batch guards", () => { payload: { batchId: requestId, fromDbVersion: 0, - toDbVersion: 1, - changes: [makeSettleChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z")], + toDbVersion: 3, + changes: [ + makeTerminalSessionChange("settled_at", 1, 0, "2026-08-10T00:00:00.000Z"), + makeTerminalSessionChange("activity_status_json", 2, 1, '{"value":"testing"}'), + makeTerminalSessionChange("activity_status_changed_at", 3, 2, "2026-08-10T00:01:00.000Z"), + ], }, })); const ack = await waitForEnvelope(peer.envelopes, "changeset_ack", requestId); const ackPayload = ack.payload as { ok?: boolean; appliedCount?: number }; expect(ackPayload.ok).toBe(true); - expect(ackPayload.appliedCount).toBe(1); + expect(ackPayload.appliedCount).toBe(3); expect(applyChanges).toHaveBeenCalledTimes(1); const appliedRows = applyChanges.mock.calls[0]?.[0] as CrsqlChangeRow[]; - expect(appliedRows.map((row) => row.cid)).toEqual(["settled_at"]); + expect(appliedRows.map((row) => row.cid)).toEqual([ + "settled_at", + "activity_status_json", + "activity_status_changed_at", + ]); } finally { try { peer?.ws.close(); @@ -13739,7 +13757,7 @@ describe("createSyncHostService all-projects roster", () => { spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); }); - function rosterProject(projectId: string, runningCount: number) { + function rosterProject(projectId: string, runningCount: number): SyncRosterProject { return { projectId, rootPath: `/tmp/${projectId}`, @@ -13754,8 +13772,11 @@ describe("createSyncHostService all-projects roster", () => { function createRosterHost( projectRoot: string, - rosterState: { projects: ReturnType[] }, - options: { withRosterProvider?: boolean } = {}, + rosterState: { projects: SyncRosterProject[] }, + options: { + withRosterProvider?: boolean; + onSessionChanged?: (listener: (event: TerminalSessionChangedEvent) => void) => () => void; + } = {}, ) { const base = createHostArgs(projectRoot, []); const args = { @@ -13774,6 +13795,10 @@ describe("createSyncHostService all-projects roster", () => { ...base.deviceRegistryService, upsertPeerMetadata: vi.fn(), }, + sessionService: { + ...base.sessionService, + ...(options.onSessionChanged ? { onChanged: options.onSessionChanged } : {}), + }, projectCatalogProvider: { listProjects: vi.fn(async () => ({ projects: [] })), prepareProjectConnection: vi.fn(), @@ -13854,6 +13879,70 @@ describe("createSyncHostService all-projects roster", () => { } }); + it("pushes an activity report after a session change without waiting for the safety poll", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const rosterState: { projects: SyncRosterProject[] } = { + projects: [rosterProject("project-a", 1)], + }; + const sessionChanges: { + listener: ((event: TerminalSessionChangedEvent) => void) | null; + } = { listener: null }; + const unsubscribe = vi.fn(); + const host = createRosterHost(projectRoot, rosterState, { + onSessionChanged: (listener) => { + sessionChanges.listener = listener; + return unsubscribe; + }, + }); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-roster-activity"); + + peer.ws.send(encodeSyncEnvelope({ type: "roster_subscribe", requestId: "roster-activity", payload: {} })); + await waitForEnvelope(peer.envelopes, "roster_snapshot", "roster-activity"); + + rosterState.projects = [{ + ...rosterProject("project-a", 1), + chats: [{ + id: "session-1", + laneId: "lane-1", + status: "running", + activityStatus: { + value: "testing", + source: "agent", + updatedAt: "2026-09-23T12:00:00.000Z", + }, + activityStatusChangedAt: "2026-09-23T12:00:00.000Z", + }], + }]; + sessionChanges.listener?.({ sessionId: "session-1", reason: "meta-updated" }); + + const delta = await waitForValue( + () => peer?.envelopes.find((envelope) => envelope.type === "roster_delta"), + "roster_delta after session activity update", + ); + expect(delta.payload).toMatchObject({ + seq: 2, + changed: [{ + projectId: "project-a", + chats: [{ id: "session-1", activityStatus: { value: "testing" } }], + }], + }); + + await host.dispose(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + it("stays silent on roster_subscribe when no roster provider is wired (older host)", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const rosterState = { projects: [] as ReturnType[] }; diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index ed2d5b02e0..ab5401a081 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -409,29 +409,33 @@ const isHostAuthoritativeTable = (change: CrsqlChangeRow): boolean => SYNC_HOST_AUTHORITATIVE_TABLES.has(change.table); /** - * Settle columns on `terminal_sessions`. The host decides these — a settle - * arrives as a `session.settle*` remote command and is written by - * `sessionService`, which is the only place that can weigh the decision against - * live work. + * Host-authoritative columns on `terminal_sessions`: settlement outcome and + * agent activity reports. The host decides settlement through `sessionService`, + * which can weigh it against live work; agent activity reports are also written + * there with a host timestamp. * * A phone must not author them over CRR. `terminal_sessions` replicates, so a - * phone's optimistic `settled_at` carries no host lifecycle revision and merges - * in regardless of what the host decided: the host can *reject* a settle and - * still end up with a settled row. That is a guard defeated by a merge rather - * than by a caller, and no amount of host-side checking closes it. + * phone's optimistic write carries no host decision and merges in regardless of + * what the host decided. That is a guard defeated by a merge rather than by a + * caller, and no amount of host-side checking closes it. * - * Current iOS builds no longer write these (they use a local pending-UI overlay - * instead — see `PendingSessionSettleStates.swift`), but a paired phone on an - * older build still does, so the host enforces it rather than trusting the - * client version. The drop is silent and per-column: everything else in the - * batch, including the phone's own snooze overlay, applies normally. + * The phone may hold optimistic copies of these columns, so the host enforces + * this rather than trusting the client version. The drop is silent and + * per-column: everything else in the batch, including the phone's own snooze + * overlay, applies normally. * * Scoped to phone peers on purpose. A paired *desktop* peer runs the same - * `sessionService` chokepoint, so its settle writes are host-decided too and - * must keep replicating. + * `sessionService` chokepoint, so its settlement and activity writes are + * host-decided too and must keep replicating. */ const HOST_AUTHORITATIVE_COLUMNS_BY_TABLE = new Map>([ - ["terminal_sessions", new Set(["settled_at", "settle_override", "settle_source"])], + ["terminal_sessions", new Set([ + "settled_at", + "settle_override", + "settle_source", + "activity_status_json", + "activity_status_changed_at", + ])], ]); const isHostAuthoritativeColumn = (change: CrsqlChangeRow): boolean => @@ -1135,6 +1139,8 @@ type SyncHostServiceArgs = { sessionService: ReturnType; sessionDeltaService?: ReturnType | null; ptyService: ReturnType; + /** False when this runtime has no RPC endpoint that accepts activity reports. */ + sessionActivityReportingEnabled?: boolean; agentChatService?: ReturnType; chatLaunchService?: ChatLaunchService | null; cursorCloudFleetService?: ReturnType | null; @@ -2252,6 +2258,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { prService: args.prService, prSummaryService: args.prSummaryService, ptyService: args.ptyService, + sessionActivityReportingEnabled: args.sessionActivityReportingEnabled, sessionService: args.sessionService, sessionDeltaService: args.sessionDeltaService, fileService: args.fileService, @@ -3169,6 +3176,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { broadcastChatEvent(event); }, ) ?? null; + // Session metadata writes do not necessarily produce a transcript event. + // Feed them through the same coalesced roster path so activity reports and + // other session-card changes reach subscribed mobile hubs without waiting + // for the safety poll. + const sessionChangeSubscription = args.sessionService.onChanged?.(() => { + markRosterDirty(); + }) ?? null; // New-lane launch progress goes to every phone of this project: a launch is // visible before its chat exists, so there is no chat subscription to key on. const chatLaunchSubscription = args.chatLaunchService?.subscribe((event) => { @@ -9417,6 +9431,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { lanePresenceByLaneId.clear(); dropInFlightCommandRecordsForProject(); chatEventSubscription?.(); + sessionChangeSubscription?.(); chatLaunchSubscription?.(); clearInterval(pollTimer); clearInterval(heartbeatTimer); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 3f68d1c9c6..3487d82985 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -42,6 +42,7 @@ function createService(options?: { prSummaryService?: Record; projectRoot?: string; ptyService?: Record; + sessionActivityReportingEnabled?: boolean; sessionDeltaService?: Record; sessionService?: Record; projectConfigService?: Record; @@ -93,6 +94,7 @@ function createService(options?: { prService: options?.prService ?? {}, ...(options?.prSummaryService ? { prSummaryService: options.prSummaryService } : {}), ptyService, + sessionActivityReportingEnabled: options?.sessionActivityReportingEnabled, sessionService, ...(options?.sessionDeltaService ? { sessionDeltaService: options.sessionDeltaService } : {}), fileService: {}, @@ -3841,6 +3843,48 @@ describe("web-reachable settings and lane-risk commands", () => { })); }); + it("omits activity guidance when the runtime cannot accept activity reports", async () => { + const create = vi.fn().mockResolvedValue({ sessionId: "session-chat-cli", ptyId: "pty-chat-cli", pid: 1 }); + const { service } = createService({ + sessionActivityReportingEnabled: false, + laneService: { + getLaneWorktreePath: vi.fn(() => "/repo/lane-1"), + getLaneBaseAndBranch: vi.fn(() => undefined), + }, + ptyService: { create }, + }); + + await service.execute(makePayload("chat.launchCli", { + laneId: "lane-1", + provider: "codex", + kickoffPrompt: "Run the checks.", + })); + + const createArg = create.mock.calls[0]?.[0] as Record; + expect(JSON.stringify(createArg)).not.toContain("Activity detail for this tracked ADE CLI session"); + }); + + it("omits work-session activity guidance when the runtime cannot accept activity reports", async () => { + const create = vi.fn().mockResolvedValue({ sessionId: "session-work-cli", ptyId: "pty-work-cli", pid: 1 }); + const { service } = createService({ + sessionActivityReportingEnabled: false, + laneService: { + getLaneBaseAndBranch: vi.fn(() => ({ worktreePath: "/repo/lane-1" })), + }, + ptyService: { create }, + }); + + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "codex", + permissionMode: "edit", + initialInput: "Run the checks.", + })); + + const createArg = create.mock.calls[0]?.[0] as Record; + expect(JSON.stringify(createArg)).not.toContain("Activity detail for this tracked ADE CLI session"); + }); + it("persists the selected account and preset for remote CLI resume and reattach", async () => { const previousAdeHome = process.env.ADE_HOME; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-sync-cli-identity-")); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 985c6cf3ee..1196b574ac 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -373,6 +373,8 @@ type SyncRemoteCommandServiceArgs = { prService: ReturnType; prSummaryService?: ReturnType | null; ptyService: ReturnType; + /** False when this runtime has no RPC endpoint that accepts activity reports. */ + sessionActivityReportingEnabled?: boolean; sessionService: ReturnType; sessionDeltaService?: ReturnType | null; fileService: ReturnType; @@ -4350,6 +4352,7 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio return buildTrackedCliLaunchCommand({ provider, permissionMode, + sessionActivityReportingEnabled: args.sessionActivityReportingEnabled, ...(parsed.droidPermissionMode !== undefined ? { droidPermissionMode: parsed.droidPermissionMode } : {}), sessionId: preassignedSessionId, model: parsed.modelId ?? parsed.model ?? undefined, @@ -4598,6 +4601,7 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio launchAgentChatCli(parseAgentChatLaunchCliArgs(payload), { laneService: args.laneService, ptyService: args.ptyService, + sessionActivityReportingEnabled: args.sessionActivityReportingEnabled, logger: args.logger, })); // Auto-lane naming. It degrades to a deterministic name on the client, so an diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index aed4dd7df8..837051068b 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -134,6 +134,8 @@ type SyncServiceArgs = { sessionService: ReturnType; sessionDeltaService?: ReturnType | null; ptyService: ReturnType; + /** False when this runtime has no RPC endpoint that accepts activity reports. */ + sessionActivityReportingEnabled?: boolean; aiIntegrationService?: ReturnType | null; projectConfigService?: ReturnType; portAllocationService?: ReturnType; @@ -735,6 +737,7 @@ export function createSyncService(args: SyncServiceArgs) { prService: args.prService, prSummaryService: args.prSummaryService, ptyService: args.ptyService, + sessionActivityReportingEnabled: args.sessionActivityReportingEnabled, sessionService: args.sessionService, sessionDeltaService: args.sessionDeltaService, fileService: args.fileService, @@ -891,6 +894,7 @@ export function createSyncService(args: SyncServiceArgs) { sessionService: args.sessionService, sessionDeltaService: args.sessionDeltaService, ptyService: args.ptyService, + sessionActivityReportingEnabled: args.sessionActivityReportingEnabled, agentChatService: args.agentChatService, chatLaunchService: args.chatLaunchService, cursorCloudFleetService: args.cursorCloudFleetService, diff --git a/apps/ade-cli/src/tuiClient/__tests__/WorkSessionsPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/WorkSessionsPane.test.tsx index 3ecbe49730..544ec20a52 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/WorkSessionsPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/WorkSessionsPane.test.tsx @@ -280,6 +280,61 @@ describe("WorkSessionsPane cards", () => { expect(frame).not.toContain("✻"); }); + it("renders one effective status per card, with Needs you replacing activity detail", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + + const frame = paneFrame({ + lanes: [lane("lane-1", "Feature")], + sessions: [ + session({ + sessionId: "chat-testing", + laneId: "lane-1", + title: "Tests underway", + status: "active", + runtimeState: "running", + currentTurnStartedAt: "2026-05-12T11:52:00.000Z", + activityStatus: { + value: "testing", + source: "agent", + updatedAt: "2026-05-12T11:59:00.000Z", + }, + }), + session({ + sessionId: "chat-needs-you", + laneId: "lane-1", + title: "Question pending", + status: "active", + runtimeState: "running", + currentTurnStartedAt: "2026-05-12T11:52:00.000Z", + activityStatus: { + value: "testing", + source: "agent", + updatedAt: "2026-05-12T11:59:00.000Z", + }, + attentionRequestedAt: "2026-05-12T11:59:30.000Z", + attentionMessage: "Which account?", + }), + ], + width: 72, + }); + + const lines = frame.split("\n"); + const card = (title: string) => { + const titleIndex = lines.findIndex((line) => line.includes(title)); + return lines.slice(titleIndex - 1, titleIndex + 2).join("\n"); + }; + const testingCard = card("Tests underway"); + const needsYouCard = card("Question pending"); + + expect(testingCard).toContain("Testing"); + expect(testingCard.match(/\bTesting\b/g)).toHaveLength(1); + expect(testingCard).not.toContain("Working"); + expect(needsYouCard).toContain("Needs you"); + expect(needsYouCard.match(/\bNeeds you\b/g)).toHaveLength(1); + expect(needsYouCard).not.toContain("Testing"); + }); + it("keeps a lane header when the lane has more than one live chat", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(NOW)); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index bfae4f115b..730a8c44ba 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -100,8 +100,14 @@ describe("session lifecycle parity", () => { const lifecycle = { id: "session-1", lastActivityAt: "2026-07-23T12:00:00.000Z", + currentTurnStartedAt: "2026-07-23T11:58:00.000Z", settledAt: "2026-07-23T12:01:00.000Z", statusNote: "PR merged", + activityStatus: { + value: "testing", + source: "agent", + updatedAt: "2026-07-23T11:59:00.000Z", + }, instanceId: "codex-work", presetId: "preset-1", credentialId: "cred-1", @@ -131,6 +137,8 @@ describe("session lifecycle parity", () => { expect(chat[0]).toMatchObject({ settledAt: lifecycle.settledAt, statusNote: "PR merged", + activityStatus: lifecycle.activityStatus, + currentTurnStartedAt: lifecycle.currentTurnStartedAt, lastActivityAt: "2026-07-23T11:30:00.000Z", }); @@ -157,6 +165,8 @@ describe("session lifecycle parity", () => { expect(terminal[0]).toMatchObject({ settledAt: lifecycle.settledAt, statusNote: "PR merged", + activityStatus: lifecycle.activityStatus, + currentTurnStartedAt: lifecycle.currentTurnStartedAt, lastActivityAt: lifecycle.lastActivityAt, instanceId: "codex-work", presetId: "preset-1", @@ -229,6 +239,7 @@ describe("session lifecycle parity", () => { model: "gpt-5.5", status: "idle", startedAt: "2026-07-26T11:00:00.000Z", + currentTurnStartedAt: "2026-07-26T11:25:00.000Z", endedAt: null, lastActivityAt: "2026-07-26T11:30:00.000Z", lastOutputPreview: null, @@ -242,8 +253,27 @@ describe("session lifecycle parity", () => { snoozedAt: "2026-07-26T12:00:00.000Z", wokeAt: "2026-07-26T13:00:00.000Z", wokeReason: "needs_you", + currentTurnStartedAt: "2026-07-26T11:25:00.000Z", }); }); + + it("does not synthesize a current-turn anchor when neither source has one", () => { + const [chat] = enrichChatSessionsWithLifecycle([{ + sessionId: "session-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-07-26T11:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-07-26T11:30:00.000Z", + lastOutputPreview: null, + summary: null, + nextWakeAt: null, + }], [{ id: "session-1" } as TerminalSessionSummary]); + + expect(chat).not.toHaveProperty("currentTurnStartedAt"); + }); }); describe("getMainTranscript", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index da1c416e20..a692433de5 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -238,6 +238,9 @@ describe("connectToAde embedded mode", () => { role: "cto", }, }); + expect(embedded.createAdeRuntime).toHaveBeenCalledWith(expect.objectContaining({ + runtimeProfile: "embedded", + })); expect(process.env.ADE_DEFAULT_ROLE).toBeUndefined(); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/workListModel.test.ts b/apps/ade-cli/src/tuiClient/__tests__/workListModel.test.ts index 36294a8b0a..508593643f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/workListModel.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/workListModel.test.ts @@ -3,6 +3,8 @@ import type { AttentionItem } from "../../../../desktop/src/shared/types/attenti import { ATTENTION_CONTRACT_VERSION } from "../../../../desktop/src/shared/types/attention"; import type { AgentChatUsageLimitResume } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +import { SESSION_ACTIVITY_VALUES } from "../../../../desktop/src/shared/types/sessions"; +import { sessionGlyphMark } from "../theme"; import type { TuiChatSessionSummary } from "../adeApi"; import { buildWorkListModel, @@ -425,6 +427,59 @@ describe("workListModel shelves", () => { }); describe("workListModel status", () => { + it.each(SESSION_ACTIVITY_VALUES)("shows a current agent report as a detail inside the running phase: %s", (value) => { + const model = build({ + lanes: [lane("lane-1", "Feature")], + sessions: [session({ + sessionId: "chat-report", + laneId: "lane-1", + status: "active", + runtimeState: "running", + currentTurnStartedAt: "2026-05-12T11:50:00.000Z", + activityStatus: { + value, + source: "agent", + updatedAt: "2026-05-12T11:59:00.000Z", + }, + })], + activeSessionId: null, + }); + + const [row] = sessionRows(model); + expect(row!.filing).toBe("running"); + expect(row!.status?.label).toBe(`${value[0]!.toUpperCase()}${value.slice(1)}`); + expect(row!.status?.glyph).toBe(value); + }); + + it("gives Testing a distinct TUI mark from Done", () => { + expect(sessionGlyphMark("testing")).toBe("T"); + expect(sessionGlyphMark("testing")).not.toBe(sessionGlyphMark("done")); + }); + + it("keeps Needs you above a current agent activity report", () => { + const model = build({ + lanes: [lane("lane-1", "Feature")], + sessions: [session({ + sessionId: "chat-report-needs-you", + laneId: "lane-1", + status: "active", + runtimeState: "running", + currentTurnStartedAt: "2026-05-12T11:50:00.000Z", + activityStatus: { + value: "testing", + source: "agent", + updatedAt: "2026-05-12T11:59:00.000Z", + }, + attentionRequestedAt: "2026-05-12T11:59:30.000Z", + attentionMessage: "Which account?", + })], + activeSessionId: null, + }); + + const [row] = sessionRows(model); + expect(row!.status?.label).toBe("Needs you"); + }); + it("lets a raised hand outrank a live snooze — a needs-you row is never buried", () => { const model = build({ lanes: [lane("lane-1", "Feature")], diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 5165bef36f..9306f4e904 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -197,6 +197,7 @@ export type TuiSessionLifecycleFields = Pick< TerminalSessionSummary, | "settledAt" | "statusNote" + | "activityStatus" | "attentionRequestedAt" | "attentionMessage" | "lastTurnFailedAt" @@ -226,6 +227,7 @@ export type TuiSessionLifecycleFields = Pick< TerminalSessionSummary, | "runtimeState" | "toolType" + | "currentTurnStartedAt" | "attentionSource" | "exitCode" | "laneName" @@ -267,6 +269,7 @@ function lifecycleFields( return { settledAt: summary?.settledAt ?? null, statusNote: summary?.statusNote ?? null, + activityStatus: summary?.activityStatus ?? null, attentionRequestedAt: summary?.attentionRequestedAt ?? null, attentionMessage: summary?.attentionMessage ?? null, lastTurnFailedAt: summary?.lastTurnFailedAt ?? null, @@ -277,6 +280,9 @@ function lifecycleFields( wokeReason: summary?.wokeReason ?? null, runtimeState: summary?.runtimeState, toolType: summary?.toolType, + ...(summary?.currentTurnStartedAt != null + ? { currentTurnStartedAt: summary.currentTurnStartedAt } + : {}), attentionSource: summary?.attentionSource ?? null, exitCode: summary?.exitCode ?? null, laneName: summary?.laneName, diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index 8501271be0..759c64a57c 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -120,7 +120,7 @@ type CreateEmbeddedRuntime = (args: { projectRoot: string; workspaceRoot: string; chatRuntime: "agent"; - runtimeProfile: "chat"; + runtimeProfile: "embedded"; }) => Promise; type CreateEmbeddedRpcRequestHandler = (args: { @@ -198,7 +198,7 @@ async function loadEmbeddedAdeCli(): Promise<{ projectRoot: string; workspaceRoot: string; chatRuntime: "agent"; - runtimeProfile: "chat"; + runtimeProfile: "embedded"; }) => Promise; createAdeRpcRequestHandler: CreateEmbeddedRpcRequestHandler; }> { @@ -1046,7 +1046,7 @@ export async function connectToAde(args: { projectRoot: args.project.projectRoot, workspaceRoot: args.project.workspaceRoot, chatRuntime: "agent", - runtimeProfile: "chat", + runtimeProfile: "embedded", }); const handler: DirectHandler = createAdeRpcRequestHandler({ runtime, diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts index ad162651f7..0b89226947 100644 --- a/apps/ade-cli/src/tuiClient/theme.ts +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -209,6 +209,10 @@ const SESSION_GLYPH_MARK: Record, string> = { working: "◐", monitoring: "◇", planning: "◈", + implementing: "✎", + testing: "T", + reviewing: "⌕", + debugging: "⚙", waiting: "⏳", "needs-you": "●", done: "✓", diff --git a/apps/ade-cli/src/tuiClient/workRow.ts b/apps/ade-cli/src/tuiClient/workRow.ts index 35ef36bac3..1cc3ad4cae 100644 --- a/apps/ade-cli/src/tuiClient/workRow.ts +++ b/apps/ade-cli/src/tuiClient/workRow.ts @@ -193,6 +193,7 @@ export function toWorkSessionSummary( ...(session.steeringInput || summary?.steeringInput ? { steeringInput: true } : {}), settledAt: session.settledAt ?? null, statusNote: session.statusNote ?? null, + activityStatus: session.activityStatus ?? summary?.activityStatus ?? null, attentionRequestedAt: session.attentionRequestedAt ?? null, attentionMessage: session.attentionMessage ?? null, attentionSource: session.attentionSource ?? null, diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 65905e56bf..acedccb9af 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -255,6 +255,23 @@ They are two separate signals on the row the user is looking at: to **Working** while the reply is handled. If the reply does not unblock you, leave an updated note and `ask` again. +#### Activity detail on the card + +ADE derives the parent state automatically. Some provider adapters also +surface structured activity, such as Plan mode or a background monitor. ADE +offers agent-reported activity only when the current provider can invoke the +session's ADE CLI. When available, session-specific guidance gives the exact +command and allowed values. For tracked terminals, `ade chat activity` targets +`ADE_ACTIVITY_SESSION_ID`, while other ADE commands continue to use the owning +chat in `ADE_CHAT_SESSION_ID`. Use that guidance to report or clear a detail; +when it is absent, do not try to set one. + +An activity report refines a Working card and never moves it to Needs you, +Waiting, or Done. ADE clears it when a new user turn is accepted. Update it +when the work changes; do not keep a stale label. Each card shows at most one +status: Needs you has priority, otherwise one current activity detail occupies +the label in place of generic Working. + #### Board and status The Work tab has a board view with four columns. Your row sits in exactly one of diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 1a941901ba..ef8c2d573c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3913,6 +3913,7 @@ app.whenReady().then(async () => { }); const ptyService = createPtyService({ projectRoot, + runtimeSocketPath: machineAdeLayout.socketPath, transcriptsDir: adePaths.transcriptsDir, laneService, sessionService, @@ -5206,6 +5207,7 @@ app.whenReady().then(async () => { projectId, project, paths: adePaths as unknown as AdeRuntimePaths, + sessionActivityReportingEnabled: true, logger, db, keybindingsService, diff --git a/apps/desktop/src/main/services/__tests__/diskFullIncident.integration.test.ts b/apps/desktop/src/main/services/__tests__/diskFullIncident.integration.test.ts index 56aaf534f7..aa600f73a2 100644 --- a/apps/desktop/src/main/services/__tests__/diskFullIncident.integration.test.ts +++ b/apps/desktop/src/main/services/__tests__/diskFullIncident.integration.test.ts @@ -92,6 +92,7 @@ function sessionServiceFor(row: Record) { updateMeta: vi.fn(), setLastOutputPreview: vi.fn(), clearTurnStartMarkers: vi.fn(), + clearSessionActivity: vi.fn(), markLastTurnFailed: vi.fn(), clearLastTurnFailed: vi.fn(), setSummary: vi.fn(), diff --git a/apps/desktop/src/main/services/adeActions/actionInputContracts.ts b/apps/desktop/src/main/services/adeActions/actionInputContracts.ts index b5a3a2c254..4a5f40af55 100644 --- a/apps/desktop/src/main/services/adeActions/actionInputContracts.ts +++ b/apps/desktop/src/main/services/adeActions/actionInputContracts.ts @@ -1,7 +1,10 @@ import type { CtoVoiceAction } from "../../../shared/types/ctoVoice"; +import { SESSION_ACTIVITY_VALUES } from "../../../shared/types/sessions"; import type { AdeActionDomain } from "./domains"; import { ADE_ACCOUNT_DELETE_MACHINE_CONFIRMATION } from "../../../shared/types/account"; +const sessionActivityValueInput = SESSION_ACTIVITY_VALUES.map((value) => `"${value}"`).join(" | "); + /** * The documented input shape for every action the CTO's curated tools reach. * @@ -486,6 +489,14 @@ const ADE_ACTION_INPUT_CONTRACTS: AdeActionInputContractTable = { }, }, session: { + setSessionActivity: { + description: + "Agent callers must use an ADE-bound tracked session. The target may be the caller's chat or a tracked terminal owned by that chat; " + + "`--session` cannot target another session. Report one fixed activity label for the current turn. This is a detail inside the existing parent phase, " + + "not a board-state change; ADE stamps the source and update time, and null clears the report.", + input: `object { sessionId: string, value: ${sessionActivityValueInput} | null }`, + example: "ade actions run session.setSessionActivity --input-json '{\"sessionId\":\"chat-123\",\"value\":\"testing\"}' --text", + }, moveOnBoard: { description: "Move one chat between Work-board columns. Applies the lifecycle write and stages a host-authored message the agent reacts to; " diff --git a/apps/desktop/src/main/services/adeActions/actionPolicy.test.ts b/apps/desktop/src/main/services/adeActions/actionPolicy.test.ts index fb818bf9af..dcbe4448f7 100644 --- a/apps/desktop/src/main/services/adeActions/actionPolicy.test.ts +++ b/apps/desktop/src/main/services/adeActions/actionPolicy.test.ts @@ -167,6 +167,7 @@ describe("isAllowedAdeAction", () => { it("exposes caller lifecycle writes through the runtime session surface", () => { expect(isAllowedAdeAction("session", "requestSessionAttention")).toBe(true); + expect(isAllowedAdeAction("session", "setSessionActivity")).toBe(true); expect(isAllowedAdeAction("session", "setSessionStatusNote")).toBe(true); expect(isAllowedAdeAction("session", "settleSession")).toBe(true); // The residue read path. It was added to the CTO-only list but NOT to the diff --git a/apps/desktop/src/main/services/adeActions/actionPolicy.ts b/apps/desktop/src/main/services/adeActions/actionPolicy.ts index 218291df2c..1327551ddb 100644 --- a/apps/desktop/src/main/services/adeActions/actionPolicy.ts +++ b/apps/desktop/src/main/services/adeActions/actionPolicy.ts @@ -721,6 +721,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { description: expect.stringContaining("stalled Codex turn"), input: expect.stringContaining("restart_resume_thread"), }); + expect(getAdeActionInputContract("session", "setSessionActivity")).toMatchObject({ + input: expect.stringContaining("monitoring"), + example: expect.stringContaining("session.setSessionActivity"), + }); + const activityDescription = getAdeActionInputContract("session", "setSessionActivity")?.description; + expect(activityDescription).toContain("fixed activity label"); + expect(activityDescription).toContain("ADE-bound tracked session"); + expect(activityDescription).toContain("tracked terminal owned by that chat"); }); it("documents lane reclaim contracts for safe CLI action discovery", () => { @@ -501,6 +509,9 @@ describe("runtime domain services behind the allowlist", () => { const createSession = vi.fn(async (args?: unknown) => ({ sessionId: "chat-new", args })); const getAvailableModels = vi.fn(async (args: { provider?: string }) => [{ id: args.provider ?? "any" }]); const getSessionSummary = vi.fn(async (sessionId: string) => ({ sessionId })); + const getByChatSessionId = vi.fn((sessionId: string) => sessionId === "chat-1" + ? { activityStatus: { value: "testing", source: "agent", updatedAt: "2026-08-01T12:00:00.000Z" } } + : null); const getTurnStatus = vi.fn(async (sessionId: string) => ({ sessionId, phase: "idle" })); const readTranscript = vi.fn(async (sessionId: string, limit?: number, since?: string) => ([ { role: "user", text: sessionId, timestamp: since ?? "now", limit }, @@ -517,6 +528,7 @@ describe("runtime domain services behind the allowlist", () => { const messageSession = vi.fn(async (args: unknown) => ({ ok: true, args })); const steer = vi.fn(async (args: unknown) => ({ ok: true, args })); const runtime = { + sessionService: { getByChatSessionId }, agentChatService: { createSession, getAvailableModels, @@ -552,11 +564,17 @@ describe("runtime domain services behind the allowlist", () => { // actually schedules in. `chat.createScheduledWork` reports the same value. const brainTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; await expect(chat.getSessionSummary?.({ sessionId: " chat-1 " })) - .resolves.toEqual({ sessionId: "chat-1", timeZone: brainTimeZone }); + .resolves.toEqual({ + sessionId: "chat-1", + timeZone: brainTimeZone, + activityStatus: { value: "testing", source: "agent", updatedAt: "2026-08-01T12:00:00.000Z" }, + }); await expect(chat.getSessionSummary?.("chat-2")) .resolves.toEqual({ sessionId: "chat-2", timeZone: brainTimeZone }); expect(getSessionSummary).toHaveBeenNthCalledWith(1, "chat-1"); expect(getSessionSummary).toHaveBeenNthCalledWith(2, "chat-2"); + expect(getByChatSessionId).toHaveBeenNthCalledWith(1, "chat-1"); + expect(getByChatSessionId).toHaveBeenNthCalledWith(2, "chat-2"); await expect(chat.getTurnStatus?.({ sessionId: " chat-1 " })).resolves.toEqual({ sessionId: "chat-1", phase: "idle" }); await expect(chat.getTurnStatus?.("chat-2")).resolves.toEqual({ sessionId: "chat-2", phase: "idle" }); @@ -1600,6 +1618,7 @@ describe("runtime session actions", () => { it("exposes caller note/ask writes but no caller-scoped settle action", async () => { const requestAttention = vi.fn(() => true); const setStatusNote = vi.fn(() => true); + const setSessionActivity = vi.fn(() => true); const settleSession = vi.fn(() => true); const settleSessionReportingAbort = vi.fn(() => ({ found: true, settled: true })); const unsettleSession = vi.fn(() => true); @@ -1619,6 +1638,7 @@ describe("runtime session actions", () => { list: vi.fn(), requestAttention, setStatusNote, + setSessionActivity, settleSession, settleSessionReportingAbort, unsettleSession, @@ -1635,6 +1655,7 @@ describe("runtime session actions", () => { } as unknown as Parameters[0]; const sessionActions = getAdeActionDomainServices(runtime).session as { requestSessionAttention: (args: { sessionId: string; message: string }) => unknown; + setSessionActivity: (args: { sessionId: string; value: string | null }) => unknown; setSessionStatusNote: (args: { sessionId: string; note: string }) => unknown; unsettleSession: (args: { sessionId: string }) => unknown; } & Record; @@ -1643,6 +1664,7 @@ describe("runtime session actions", () => { expect(allowed).toEqual( expect.arrayContaining([ "requestSessionAttention", + "setSessionActivity", "setSessionStatusNote", ]), ); @@ -1673,6 +1695,12 @@ describe("runtime session actions", () => { .toEqual({ ok: true, sessionId: "session-1" }); expect(setStatusNote).toHaveBeenCalledWith("session-1", null); + expect(sessionActions.setSessionActivity({ sessionId: "session-1", value: "testing" })) + .toEqual({ ok: true, sessionId: "session-1", value: "testing" }); + expect(setSessionActivity).toHaveBeenCalledWith("session-1", "testing"); + expect(() => sessionActions.setSessionActivity({ sessionId: "session-1", value: 3 as never })) + .toThrow(/supported string `value` or null/i); + expect(sessionActions.settleSelfSession).toBeUndefined(); expect(sessionActions.unsettleSelfSession).toBeUndefined(); expect(settleSession).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 1b72dabd7e..51a7cde2be 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -922,11 +922,16 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null { readStringActionArg(args, "sessionId"), ); if (!summary) return null; + const sessionActivity = runtime.sessionService?.getByChatSessionId?.(summary.sessionId); // The host zone is added here rather than on `AgentChatSessionSummary` // itself so the per-row list payload does not carry the same constant N // times; `chat.createScheduledWork` reports the same value the same way. // See `AdeChatSessionSummaryActionResult` for the full reasoning. - return { ...summary, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; + return { + ...summary, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + ...(sessionActivity ? { activityStatus: sessionActivity.activityStatus ?? null } : {}), + }; }; } if (typeof base.getTurnStatus === "function") { @@ -1389,6 +1394,18 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { } return { ok: true, sessionId }; }, + setSessionActivity: (args?: unknown) => { + const record = readObjectActionArg(args, "session.setSessionActivity"); + const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); + const value = record.value; + if (value !== null && typeof value !== "string") { + throw new Error("setSessionActivity requires a supported string `value` or null."); + } + if (!sessionService.setSessionActivity(sessionId, value)) { + throw new Error(`Session '${sessionId}' was not found.`); + } + return { ok: true, sessionId, value }; + }, // ----------------------------------------------------------------------- // There is deliberately NO `settleSelfSession` / `unsettleSelfSession` // pair here any more (removed 2026-07). An agent used to be able to file diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index c5403d6310..be8acb2e78 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -75,6 +75,15 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("Autonomous mode"); }); + it("includes session activity guidance only when a provider supplies it", () => { + const guidance = "Report this session's current activity through ADE."; + expect(buildCodingAgentSystemPrompt({ + cwd: "/x", + sessionActivityGuidance: guidance, + })).toContain(guidance); + expect(buildCodingAgentSystemPrompt({ cwd: "/x" })).not.toContain(guidance); + }); + it("lists provided tool names when non-empty", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 2841e9d50f..8a70e9a76d 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -140,6 +140,8 @@ export function buildCodingAgentSystemPrompt(args: { interactive?: boolean; runtime?: AdeRuntimeKind; adeSkillRoots?: readonly string[]; + /** Provider-gated status advice; omitted unless this session can run ADE CLI. */ + sessionActivityGuidance?: string | null; }): string { const mode = args.mode ?? "coding"; const permissionMode = args.permissionMode ?? "edit"; @@ -242,6 +244,7 @@ export function buildCodingAgentSystemPrompt(args: { "If tool results fail or contradict the current plan, synthesize the finding and adapt rather than repeating the same failing action.", "", buildAdeCliAgentGuidance(adeSkillRoots), + ...(args.sessionActivityGuidance ? ["", args.sessionActivityGuidance] : []), ...(hasWorkflowTools ? [ "", diff --git a/apps/desktop/src/main/services/chat/__tests__/agentChatEventSequenceHydration.test.ts b/apps/desktop/src/main/services/chat/__tests__/agentChatEventSequenceHydration.test.ts index 97af78f1d8..03aef437f9 100644 --- a/apps/desktop/src/main/services/chat/__tests__/agentChatEventSequenceHydration.test.ts +++ b/apps/desktop/src/main/services/chat/__tests__/agentChatEventSequenceHydration.test.ts @@ -98,6 +98,7 @@ function createMockSessionService(rows: Map) { setHeadShaEnd: vi.fn(), setLastOutputPreview: vi.fn(), clearTurnStartMarkers: vi.fn(), + clearSessionActivity: vi.fn(), markLastTurnFailed: vi.fn(), clearLastTurnFailed: vi.fn(), setSummary: vi.fn(), diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts index 6b542bf61f..d9005e5eea 100644 --- a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts @@ -30,6 +30,8 @@ type LoggerForCliLaunch = { export type AgentChatCliLaunchDeps = { laneService: LaneServiceForCliLaunch; ptyService: PtyServiceForCliLaunch; + /** False when this runtime has no RPC endpoint that accepts activity reports. */ + sessionActivityReportingEnabled?: boolean; logger?: LoggerForCliLaunch | null; }; @@ -108,6 +110,7 @@ export async function launchAgentChatCli( const launch = buildTrackedCliLaunchCommand({ provider, permissionMode, + sessionActivityReportingEnabled: deps.sessionActivityReportingEnabled, ...(provider === "claude" ? { sessionId } : {}), model: trackedPreset?.model || arg.model || null, reasoningEffort: arg.reasoningEffort ?? null, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 3ee03c96fd..a8a29c0fa6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1718,6 +1718,7 @@ function createMockSessionService() { setHeadShaEnd: vi.fn(), setLastOutputPreview: vi.fn(), clearTurnStartMarkers: vi.fn(), + clearSessionActivity: vi.fn(), markLastTurnFailed: vi.fn(), clearLastTurnFailed: vi.fn(), setSummary: vi.fn(), @@ -10062,6 +10063,95 @@ describe("createAgentChatService", () => { expect(secondUserContent).not.toContain("CLI controls ADE state"); }); + it("gives OpenCode activity guidance an explicit per-chat CLI and runtime target", async () => { + vi.mocked(streamText).mockImplementation(() => ({ + fullStream: (async function* () { + yield { type: "finish", usage: {} }; + })(), + } as any)); + const cliPath = path.join(tmpRoot, "activity-cli", "ade"); + fs.mkdirSync(path.dirname(cliPath), { recursive: true }); + fs.writeFileSync(cliPath, "#!/bin/sh\nexit 0\n"); + fs.chmodSync(cliPath, 0o755); + const runtimeSocketPath = "/Users/admin/.ade-beta/sock/ade.sock"; + const staleRuntimeSocketPath = "/Users/admin/.ade/sock/ade.sock"; + const { service } = createService({ + runtimeSocketPath, + getAdeCliAgentEnv: () => ({ + PATH: path.dirname(cliPath), + ADE_CLI_PATH: cliPath, + ADE_RUNTIME_SOCKET_PATH: staleRuntimeSocketPath, + ADE_RPC_SOCKET_PATH: staleRuntimeSocketPath, + ADE_RPC_URL: staleRuntimeSocketPath, + }), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/openai/gpt-5.4", + }); + + await service.runSessionTurn({ sessionId: session.id, text: "Check the test state." }); + + let promptBody: Record | undefined; + await vi.waitFor(() => { + const openCodeState = [...mockState.openCodeSessions.values()].at(-1); + promptBody = openCodeState?.promptBodies.at(-1) as Record | undefined; + expect(promptBody).toBeDefined(); + }); + const systemPromptArgs = vi.mocked(buildCodingAgentSystemPrompt).mock.calls.at(-1)?.[0]; + for (const selector of ["ADE_RPC_URL", "ADE_RPC_SOCKET_PATH", "ADE_RUNTIME_SOCKET_PATH"]) { + expect(systemPromptArgs?.sessionActivityGuidance) + .toContain(`${selector}='${runtimeSocketPath}'`); + } + expect(systemPromptArgs?.sessionActivityGuidance).toContain("ADE_DEFAULT_ROLE='agent'"); + expect(systemPromptArgs?.sessionActivityGuidance).toContain(`ADE_CHAT_SESSION_ID='${session.id}'`); + expect(systemPromptArgs?.sessionActivityGuidance) + .toContain(`'${cliPath}' chat activity testing --session '${session.id}'`); + expect(systemPromptArgs?.sessionActivityGuidance).not.toContain(staleRuntimeSocketPath); + await service.dispose({ sessionId: session.id }); + }); + + it("withholds SDK activity guidance for an embedded runtime without RPC", async () => { + vi.mocked(streamText).mockImplementation(() => ({ + fullStream: (async function* () { + yield { type: "finish", usage: {} }; + })(), + } as any)); + const cliPath = path.join(tmpRoot, "activity-cli", "ade"); + fs.mkdirSync(path.dirname(cliPath), { recursive: true }); + fs.writeFileSync(cliPath, "#!/bin/sh\nexit 0\n"); + fs.chmodSync(cliPath, 0o755); + const { service } = createService({ + runtimeSocketPath: "/runtime/unserved.sock", + sessionActivityReportingEnabled: false, + getAdeCliAgentEnv: () => ({ + PATH: path.dirname(cliPath), + ADE_CLI_PATH: cliPath, + ADE_RUNTIME_SOCKET_PATH: "/runtime/stable.sock", + }), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/openai/gpt-5.4", + }); + + await service.runSessionTurn({ sessionId: session.id, text: "Check the test state." }); + + let promptBody: Record | undefined; + await vi.waitFor(() => { + const openCodeState = [...mockState.openCodeSessions.values()].at(-1); + promptBody = openCodeState?.promptBodies.at(-1) as Record | undefined; + expect(promptBody).toBeDefined(); + }); + const systemPromptArgs = vi.mocked(buildCodingAgentSystemPrompt).mock.calls.at(-1)?.[0]; + expect(systemPromptArgs?.sessionActivityGuidance).toBeNull(); + await service.dispose({ sessionId: session.id }); + }); + it("starts Codex sessions without ADE-owned tool server injection", async () => { const laneRootPath = path.join(tmpRoot, "lane-2"); fs.mkdirSync(laneRootPath, { recursive: true }); @@ -10385,6 +10475,51 @@ describe("createAgentChatService", () => { expect(spawnArgs).not.toContain("computer_use"); }); + it("routes Codex activity reports through the runtime that owns the chat", async () => { + const cliPath = path.join(tmpRoot, "activity-cli", "ade"); + fs.mkdirSync(path.dirname(cliPath), { recursive: true }); + fs.writeFileSync(cliPath, "#!/bin/sh\nexit 0\n"); + fs.chmodSync(cliPath, 0o755); + const runtimeSocketPath = "/Users/admin/.ade-beta/sock/ade.sock"; + const staleRuntimeSocketPath = "/Users/admin/.ade/sock/ade.sock"; + const getAdeCliAgentEnv = vi.fn(() => ({ + PATH: path.dirname(cliPath), + ADE_CLI_PATH: cliPath, + ADE_RUNTIME_SOCKET_PATH: staleRuntimeSocketPath, + ADE_RPC_SOCKET_PATH: staleRuntimeSocketPath, + ADE_RPC_URL: staleRuntimeSocketPath, + })); + const { service } = createService({ getAdeCliAgentEnv, runtimeSocketPath }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + try { + await service.sendMessage( + { sessionId: session.id, text: "Run the checks." }, + { awaitDispatch: true }, + ); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(true); + }); + + const spawnCall = vi.mocked(spawn).mock.calls.find((call) => + call[0] === "codex" && Array.isArray(call[1]) && call[1].includes("app-server") + ); + const spawnEnv = (spawnCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env; + expect(spawnEnv).toMatchObject({ + ADE_RPC_URL: runtimeSocketPath, + ADE_RPC_SOCKET_PATH: runtimeSocketPath, + ADE_RUNTIME_SOCKET_PATH: runtimeSocketPath, + }); + } finally { + await service.dispose({ sessionId: session.id }); + } + }); + it("passes raw CLI access env to the Cursor SDK pool for worker sanitization", async () => { process.env.CURSOR_API_KEY = "cursor-test-key"; const getAdeCliAgentEnv = vi.fn(() => ({ @@ -10441,6 +10576,79 @@ describe("createAgentChatService", () => { expect(resolveBuiltInBrowserActorCapability(actorToken)).toBeNull(); }); + + it("targets Cursor SDK activity reports at the service runtime and disables them without an exact socket", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const cliPath = path.join(tmpRoot, "activity-cli", "ade"); + fs.mkdirSync(path.dirname(cliPath), { recursive: true }); + fs.writeFileSync(cliPath, "#!/bin/sh\nexit 0\n"); + fs.chmodSync(cliPath, 0o755); + const runtimeSocketPath = "/Users/admin/.ade-beta/sock/ade.sock"; + const getAdeCliAgentEnv = vi.fn(() => ({ + PATH: path.dirname(cliPath), + ADE_CLI_PATH: cliPath, + // The service's socket is authoritative if a launcher carries stale env. + ADE_RUNTIME_SOCKET_PATH: "/Users/admin/.ade/sock/ade.sock", + })); + + const { service } = createService({ getAdeCliAgentEnv, runtimeSocketPath }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + await service.sendMessage({ sessionId: session.id, text: "Run locally." }, { awaitDispatch: true }); + + expect(mockState.cursorSdkAcquireCalls.at(-1)).toEqual(expect.objectContaining({ + activityRuntimeSocketPath: runtimeSocketPath, + })); + expect(String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? "")) + .toContain(`chat activity testing --session '${session.id}'`); + await service.dispose({ sessionId: session.id }); + + mockState.cursorSdkAcquireCalls = []; + mockState.cursorSdkSendCalls = []; + const fallbackSocketPath = "/runtime/fallback.sock"; + const { service: fallbackService } = createService({ + runtimeSocketPath: " ", + getAdeCliAgentEnv: () => ({ + PATH: path.dirname(cliPath), + ADE_CLI_PATH: cliPath, + ADE_RUNTIME_SOCKET_PATH: fallbackSocketPath, + }), + }); + const fallbackSession = await fallbackService.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + await fallbackService.sendMessage({ sessionId: fallbackSession.id, text: "Run locally." }, { awaitDispatch: true }); + + expect(mockState.cursorSdkAcquireCalls.at(-1)).toEqual(expect.objectContaining({ + activityRuntimeSocketPath: fallbackSocketPath, + })); + await fallbackService.dispose({ sessionId: fallbackSession.id }); + + mockState.cursorSdkAcquireCalls = []; + mockState.cursorSdkSendCalls = []; + const { service: noSocketService } = createService({ + getAdeCliAgentEnv: () => ({ PATH: path.dirname(cliPath), ADE_CLI_PATH: cliPath }), + }); + const noSocketSession = await noSocketService.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + await noSocketService.sendMessage({ sessionId: noSocketSession.id, text: "Run locally." }, { awaitDispatch: true }); + + expect(mockState.cursorSdkAcquireCalls.at(-1)).not.toHaveProperty("activityRuntimeSocketPath"); + expect(String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? "")) + .not.toContain("chat activity testing --session"); + await noSocketService.dispose({ sessionId: noSocketSession.id }); + }); }); // -------------------------------------------------------------------------- @@ -18322,6 +18530,7 @@ describe("createAgentChatService", () => { await vi.waitFor(() => { expect(warmupComplete).toBe(true); }); sessionService.clearTurnStartMarkers.mockClear(); + sessionService.clearSessionActivity.mockClear(); await service.sendMessage({ sessionId: session.id, text: "wake prompt", @@ -18335,10 +18544,13 @@ describe("createAgentChatService", () => { } as never, }); expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + expect(sessionService.clearSessionActivity).not.toHaveBeenCalled(); await service.sendMessage({ sessionId: session.id, text: "real user reply" }); expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledWith(session.id); expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledTimes(1); + expect(sessionService.clearSessionActivity).toHaveBeenCalledWith(session.id); + expect(sessionService.clearSessionActivity).toHaveBeenCalledTimes(1); }); it("writes a receipt for every Claude approval it settles, not just a cleared map", async () => { @@ -18646,6 +18858,7 @@ describe("createAgentChatService", () => { const { service, sessionService } = createService(); const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); sessionService.clearTurnStartMarkers.mockClear(); + sessionService.clearSessionActivity.mockClear(); await service.respondToInput({ sessionId: session.id, @@ -18654,6 +18867,7 @@ describe("createAgentChatService", () => { }); expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledWith(session.id); + expect(sessionService.clearSessionActivity).toHaveBeenCalledWith(session.id); // And a receipt was written, so the card cannot be redrawn either. const history = await service.getChatEventHistory(session.id); expect(history.events.some((envelope) => @@ -18690,18 +18904,21 @@ describe("createAgentChatService", () => { { hostContinuation: { reason: "plan_followup" } }, ]) { sessionService.clearTurnStartMarkers.mockClear(); + sessionService.clearSessionActivity.mockClear(); await service.sendMessage({ sessionId: session.id, text: "host-authored delivery", metadata: metadata as never, }); expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + expect(sessionService.clearSessionActivity).not.toHaveBeenCalled(); } // A board move is host-authored provenance but a HUMAN act, so it does // clear — except a move INTO Needs you, which exists to raise the hand // the clear would wipe in the same breath. sessionService.clearTurnStartMarkers.mockClear(); + sessionService.clearSessionActivity.mockClear(); await service.sendMessage({ sessionId: session.id, text: "You moved this chat from Done to Working.", @@ -18710,8 +18927,10 @@ describe("createAgentChatService", () => { } as never, }); expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledWith(session.id); + expect(sessionService.clearSessionActivity).toHaveBeenCalledWith(session.id); sessionService.clearTurnStartMarkers.mockClear(); + sessionService.clearSessionActivity.mockClear(); await service.sendMessage({ sessionId: session.id, text: "The user parked this for their input.", @@ -18720,6 +18939,7 @@ describe("createAgentChatService", () => { } as never, }); expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + expect(sessionService.clearSessionActivity).not.toHaveBeenCalled(); }); it.each([ @@ -18756,6 +18976,7 @@ describe("createAgentChatService", () => { modelId, }); sessionService.clearTurnStartMarkers.mockClear(); + sessionService.clearSessionActivity.mockClear(); let turnSettled = false; const steerPromise = service.steerUserMessage({ @@ -18788,6 +19009,8 @@ describe("createAgentChatService", () => { expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledWith(session.id); }); expect(sessionService.clearTurnStartMarkers).toHaveBeenCalledTimes(1); + expect(sessionService.clearSessionActivity).toHaveBeenCalledWith(session.id); + expect(sessionService.clearSessionActivity).toHaveBeenCalledTimes(1); expect(turnSettled).toBe(false); } finally { finishTurn(); @@ -18809,12 +19032,14 @@ describe("createAgentChatService", () => { modelId: "cursor/composer-2", }); sessionService.clearTurnStartMarkers.mockClear(); + sessionService.clearSessionActivity.mockClear(); await expect(service.steerUserMessage({ sessionId: session.id, text: "Continue from my answer.", })).rejects.toThrow("Cursor rejected the dispatch."); expect(sessionService.clearTurnStartMarkers).not.toHaveBeenCalled(); + expect(sessionService.clearSessionActivity).not.toHaveBeenCalled(); }); it("preserves lifecycle markers when an idle OpenCode prompt is rejected before dispatch", async () => { @@ -37708,7 +37933,21 @@ describe("createAgentChatService", () => { }); it("sends Codex plan collaboration mode on turn start for plan sessions", async () => { - const { service } = createService(); + const events: AgentChatEventEnvelope[] = []; + let readSessionSummary: ((sessionId: string) => Promise) | null = null; + let summaryReadAtClear: Promise | null = null; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + if ( + event.event.type === "session_meta_updated" + && event.event.codexEffectiveCollaborationMode === null + && readSessionSummary + ) { + summaryReadAtClear = readSessionSummary(event.sessionId); + } + }, + }); const session = await service.createSession({ laneId: "lane-1", provider: "codex", @@ -37718,6 +37957,7 @@ describe("createAgentChatService", () => { codexConfigSource: "flags", }); expect(session.permissionMode).toBe("plan"); + readSessionSummary = (sessionId) => service.getSessionSummary(sessionId); await service.sendMessage({ sessionId: session.id, @@ -37751,6 +37991,22 @@ describe("createAgentChatService", () => { expect(collaborationMode?.settings?.model).toBe("gpt-5.4"); expect(collaborationMode?.settings?.reasoning_effort).toBe("medium"); expect(collaborationMode?.settings?.developer_instructions).toBeNull(); + await vi.waitFor(async () => { + expect((await service.getSessionSummary(session.id))?.codexEffectiveCollaborationMode).toBe("plan"); + }); + expect((await service.getSessionSummary(session.id))?.codexEffectiveCollaborationModeWasCleared) + .toBeUndefined(); + expect(events.some(({ event }) => + event.type === "session_meta_updated" && event.codexEffectiveCollaborationMode === null, + )).toBe(true); + expect(events.some(({ event }) => + event.type === "session_meta_updated" && event.codexEffectiveCollaborationMode === "plan", + )).toBe(true); + const clearedSummary = summaryReadAtClear; + if (!clearedSummary) throw new Error("Expected a summary read while Codex mode was cleared"); + expect(await clearedSummary).toMatchObject({ + codexEffectiveCollaborationModeWasCleared: true, + }); expect(textInputs).toHaveLength(1); expect(textInputs.at(-1)?.text).toContain("User request:"); expect(textInputs.at(-1)?.text).toContain("Ask one planning question before coding."); @@ -37764,6 +38020,7 @@ describe("createAgentChatService", () => { runtime: "codex-app-server", }), ); + }); it("turns native Codex plan items into an implementation approval request", async () => { @@ -43763,7 +44020,10 @@ describe("createAgentChatService", () => { it("falls back to default collaboration mode when plan is not advertised", async () => { mockState.codexCollaborationModes = [{ mode: "default" }]; - const { service } = createService(); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); const session = await service.createSession({ laneId: "lane-1", provider: "codex", @@ -43788,6 +44048,12 @@ describe("createAgentChatService", () => { const collaborationMode = params?.collaborationMode as { mode?: unknown } | undefined; expect(collaborationMode?.mode).toBe("default"); + await vi.waitFor(async () => { + expect((await service.getSessionSummary(session.id))?.codexEffectiveCollaborationMode).toBe("default"); + }); + expect(events.some(({ event }) => + event.type === "session_meta_updated" && event.codexEffectiveCollaborationMode === "default", + )).toBe(true); }); }); @@ -52476,6 +52742,16 @@ it("fails a cleanly ended OpenCode event stream and clears active child sessions && event.event.itemId === "cursor-hook-preview-failure", ); + expect(service.listPendingInputs({ sessionId: session.id }).requests).toEqual([ + expect.objectContaining({ + itemId: "cursor-hook-preview-failure", + source: "cursor", + kind: "permissions", + blocking: true, + providerMetadata: expect.objectContaining({ cursorSdk: true, toolName: "shell" }), + }), + ]); + await service.respondToInput({ sessionId: session.id, itemId: approvalEvent.event.itemId, @@ -52483,6 +52759,7 @@ it("fails a cleanly ended OpenCode event stream and clears active child sessions }); await expect(hookResponse).resolves.toEqual({ permission: "allow" }); + expect(service.listPendingInputs({ sessionId: session.id }).requests).toEqual([]); expect(logger.warn).toHaveBeenCalledWith( "agent_chat.preview_update_failed", expect.objectContaining({ @@ -52839,6 +53116,68 @@ it("fails a cleanly ended OpenCode event stream and clears active child sessions expect(doneEvent.event.modelId).toBe("droid/custom:claude-sonnet-5-thinking-32000"); }); + it("lists and resolves a live Droid SDK permission card", async () => { + let finishTurn = () => {}; + mockState.droidPromptGate = new Promise((resolve) => { finishTurn = resolve; }); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "droid", + model: "custom:claude-sonnet-5-thinking-32000", + modelId: "droid/custom:claude-sonnet-5-thinking-32000", + }); + + try { + const turnPromise = service.sendMessage({ + sessionId: session.id, + text: "Read a file that needs permission.", + }, { awaitDispatch: true }); + await vi.waitFor(() => { + expect(mockState.droidPromptCalls.length).toBeGreaterThan(0); + expect(typeof mockState.droidPooled?.bridge.onPermissionRequest).toBe("function"); + }); + + const permissionResponse = mockState.droidPooled.bridge.onPermissionRequest({ + id: "droid-permission-card", + title: "Read file", + summary: "Read README.md", + toolName: "Read", + toolInput: { filePath: "README.md" }, + toolUseIds: ["tool-use-1"], + options: [ + { label: "Allow once", value: "proceed_once" }, + { label: "Cancel", value: "cancel" }, + ], + raw: { filePath: "README.md" }, + }); + + expect(service.listPendingInputs({ sessionId: session.id }).requests).toEqual([ + expect.objectContaining({ + itemId: "droid-permission-card", + source: "droid", + kind: "permissions", + blocking: true, + options: expect.arrayContaining([ + expect.objectContaining({ label: "Allow once", value: "proceed_once" }), + ]), + }), + ]); + + await service.respondToInput({ + sessionId: session.id, + itemId: "droid-permission-card", + decision: "accept", + }); + + await expect(permissionResponse).resolves.toEqual({ selectedOption: "proceed_once" }); + expect(service.listPendingInputs({ sessionId: session.id }).requests).toEqual([]); + finishTurn(); + await expect(turnPromise).resolves.toBeUndefined(); + } finally { + finishTurn(); + } + }); + it("sends Droid screenshots as attachment paths over worker IPC", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 641034a28d..a0cc67124c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -538,6 +538,7 @@ import { type AcpChatProvider, type AgentChatAcpConfigSnapshot, type AgentChatAcpPermissionMode, + type AgentChatCodexCollaborationMode, type AgentChatResourceLink, type AgentChatWorkflowProgress, } from "../../../shared/types/chat"; @@ -660,7 +661,7 @@ import { type ModelProviderGroup, } from "../../../shared/modelRegistry"; import { piSdkToolPolicyForPermissionMode } from "../../../shared/cliLaunch"; -import { pathsEqual } from "../shared/pathCompare"; +import { pathKey, pathsEqual } from "../shared/pathCompare"; import { isProviderDisabled } from "../../../shared/providerEnablement"; import { buildProviderGroupBlocks, @@ -766,7 +767,12 @@ import { } from "../../../shared/claudeModelSwitch"; import { createChatAutoResumeCoordinator } from "./chatAutoResumeCoordinator"; import type { ChatAutoResumeAnalyticsProperties } from "./chatAutoResumeCoordinator"; -import { buildAdeCliAgentGuidance } from "../../../shared/adeCliGuidance"; +import { + buildAdeCliAgentGuidance, + buildAdeSessionActivityGuidance, + buildAdeRuntimeSocketEnv, + type AdeSessionActivityTarget, +} from "../../../shared/adeCliGuidance"; import { adePromptAgentSkillRoots, agentSkillSlashCommands, @@ -2033,6 +2039,8 @@ type CodexRuntime = { resetCreditNoticeEmitted: boolean; collaborationModes: Set | null; collaborationModesReady: Promise | null; + /** Accepted for the current turn/start; null until that request succeeds. */ + effectiveCollaborationMode: AgentChatCodexCollaborationMode | null; planModeFallbackNotified: boolean; goalBudgetClearInFlight: Set; goalBudgetClearRetryAfterByThreadId: Map; @@ -2638,6 +2646,7 @@ type OpenCodeRuntime = { type CursorPermissionWaiter = { toolName: string; + pendingInputRequest: PendingInputRequest; resolve: (value: CursorSdkHookDecision) => void; }; @@ -2763,7 +2772,7 @@ type PiRuntime = { sessionRoot: string; /** Pi has named the session file but not written it yet; no header to check. */ sessionFilePending: boolean; - /** Tool/extension policy this worker was built with; a change forces a restart. */ + /** Worker tool/config identity; a change forces a restart. */ toolPolicyKey: string; }; @@ -2789,6 +2798,7 @@ function cancelCursorPermissionWaiter(waiter: CursorPermissionWaiter, reason: st type DroidPermissionWaiter = { toolName: string; request: DroidSdkPermissionRequest; + pendingInputRequest: PendingInputRequest; resolve: (value: DroidSdkPermissionDecision) => void; }; @@ -3148,13 +3158,10 @@ function hasLiveSteeringInput(managed: ManagedChatSession | null | undefined): b * `awaitingInput: true` with an empty request list is exactly the "blocked with * nothing to show" state the pending-inputs action exists to remove. * - * Cursor and Droid are the deliberate exception, and the reason this is not - * simply `hasLivePendingInput`'s size check inverted. Their `permissionWaiters` - * hold a resolver function and no `PendingInputRequest`: the request object was - * never built, so there is nothing to return for them. A session blocked on one - * reports `awaitingInput: true` and an empty list, which is a gap in those two - * runtimes rather than in this function. Pi and the ACP providers raise their - * cards through `localPendingInputs`, which the first loop covers. + * Cursor and Droid permission waiters retain the provider answer resolver and + * the normalized `PendingInputRequest`, so a host can redraw the blocking card + * after a renderer reload. Pi and the ACP providers raise their cards through + * `localPendingInputs`, which the first loop covers. * * `hasLivePendingInput` is NOT derived from this. It runs on the send path for * every message, and answering "is anything pending" by allocating an array of @@ -3185,6 +3192,8 @@ function collectPendingInputRequests( for (const pending of runtime.approvals.values()) add(pending.request); } else if (runtime.kind === "opencode") { for (const pending of runtime.pendingApprovals.values()) add(pending.request); + } else if (runtime.kind === "cursor" || runtime.kind === "droid") { + for (const pending of runtime.permissionWaiters.values()) add(pending.pendingInputRequest); } } return requests; @@ -8175,7 +8184,7 @@ function codexSessionAutoAccepts( } type CodexCollaborationModePayload = { - mode: "default" | "plan"; + mode: AgentChatCodexCollaborationMode; settings: { model: string; reasoning_effort: string | null; @@ -8335,8 +8344,9 @@ function buildCodexDeveloperInstructions(args: { | "surface" | "instructions" >; - collaborationMode: "default" | "plan"; + collaborationMode: AgentChatCodexCollaborationMode; spawnGuidance?: SpawnSelfReportGuidanceOpts; + sessionActivityGuidance?: string | null; /** Optional Linear-tracked-work directive appended to the base instructions. */ linearDirective?: string | null; }): string { @@ -8351,6 +8361,9 @@ function buildCodexDeveloperInstructions(args: { interactive: true, runtime: "codex-app-server", adeSkillRoots: adePromptAgentSkillRoots({ cwd: args.laneWorktreePath }), + sessionActivityGuidance: args.collaborationMode === "default" + ? args.sessionActivityGuidance + : null, }); const spawnGuidance = buildSpawnSelfReportGuidance(args.session, args.spawnGuidance); return [base, args.linearDirective, spawnGuidance].filter(Boolean).join("\n\n"); @@ -8369,6 +8382,7 @@ function buildOpenCodeSystemPrompt(args: { | "instructions" >; spawnGuidance?: SpawnSelfReportGuidanceOpts; + sessionActivityGuidance?: string | null; }): string { if (args.session.surface === "personal") return resolvePersonalSystemPrompt(args.session); const mode = args.session.permissionMode === "plan" || args.session.interactionMode === "plan" @@ -8381,6 +8395,7 @@ function buildOpenCodeSystemPrompt(args: { interactive: true, runtime: "opencode", adeSkillRoots: adePromptAgentSkillRoots({ cwd: args.laneWorktreePath }), + sessionActivityGuidance: args.sessionActivityGuidance, }); return [base, buildAdeSessionLineageGuidance(args.session, args.spawnGuidance)] .filter(Boolean) @@ -8396,6 +8411,7 @@ function resolveCodexInstructionCollaborationMode( function buildCodexCollaborationMode( session: Pick< AgentChatSession, + | "id" | "provider" | "permissionMode" | "interactionMode" @@ -8410,6 +8426,7 @@ function buildCodexCollaborationMode( laneWorktreePath: string, linearDirective?: string | null, spawnGuidance?: SpawnSelfReportGuidanceOpts, + sessionActivityGuidance?: string | null, ): CodexCollaborationModePayload | null { if (session.provider !== "codex") return null; if (resolveSessionCodexConfigSource(session) === "config-toml") return null; @@ -8434,6 +8451,7 @@ function buildCodexCollaborationMode( collaborationMode: mode, linearDirective, spawnGuidance, + sessionActivityGuidance: mode === "default" ? sessionActivityGuidance : null, }), }, }; @@ -8987,6 +9005,8 @@ export function createAgentChatService(args: { projectRoot: string; /** Control endpoint this runtime actually bound, used for ownership attribution. */ runtimeSocketPath?: string | null; + /** Activity reports require an RPC endpoint that this process actually serves. */ + sessionActivityReportingEnabled?: boolean; adeDir?: string; transcriptsDir: string; fileService?: ReturnType | null; @@ -9224,6 +9244,7 @@ export function createAgentChatService(args: { const runtimeSocketPath = typeof injectedRuntimeSocketPath === "string" ? injectedRuntimeSocketPath.trim() || null : null; + const sessionActivityReportingEnabled = args.sessionActivityReportingEnabled !== false; const claudeResumeDialogPreference = args.claudeResumeDialogPreference ?? null; const browserActorCapabilityIssuer = args.browserActorCapabilityIssuer ?? localBrowserActorCapabilityIssuer; @@ -9619,6 +9640,80 @@ export function createAgentChatService(args: { */ const agentSkillRootEnv = (): NodeJS.ProcessEnv => getAdeCliAgentEnv?.(process.env) ?? process.env; + const resolveSessionActivityRuntime = ( + session: Pick, + enabled: boolean, + ): { cliPath: string; runtimeSocketPath: string } | null => { + if (!sessionActivityReportingEnabled || !enabled || isPersonalSession(session)) return null; + // Only trust ADE_CLI_PATH when the launch-time ADE CLI resolver supplied + // it. A PATH entry or an inherited user-provided value is not proof that + // this agent can reach this ADE runtime. + const agentEnv = getAdeCliAgentEnv?.(process.env); + const cliPath = agentEnv?.ADE_CLI_PATH; + if (!cliPath?.trim()) return null; + try { + const stat = fs.statSync(cliPath); + if (!stat.isFile()) return null; + if (process.platform !== "win32") fs.accessSync(cliPath, fs.constants.X_OK); + } catch { + return null; + } + // SDK workers intentionally receive a small ADE environment allowlist. + // Use the socket that this service was created for first, then the exact + // launch-time resolver value. Without an explicit socket, the CLI can + // silently fall back to the stable ADE home on Windows and report to a + // different channel's runtime, so do not advertise activity reporting. + const exactRuntimeSocketPath = runtimeSocketPath + ?? (agentEnv?.ADE_RUNTIME_SOCKET_PATH?.trim() || null); + if (!exactRuntimeSocketPath) return null; + return { + cliPath, + runtimeSocketPath: exactRuntimeSocketPath, + }; + }; + + const sessionActivityGuidanceForRuntime = ( + session: Pick, + runtime: { cliPath: string; runtimeSocketPath: string } | null, + target: AdeSessionActivityTarget = { type: "environment" }, + ): string | null => { + if (!runtime) return null; + return buildAdeSessionActivityGuidance({ + sessionId: session.id, + cliPath: runtime.cliPath, + shell: process.platform === "win32" ? "powershell" : "posix", + target, + }); + }; + + const buildSessionActivityGuidance = ( + session: Pick, + enabled: boolean, + ): string | null => sessionActivityGuidanceForRuntime( + session, + resolveSessionActivityRuntime(session, enabled), + ); + + const buildOpenCodeSessionActivityGuidance = ( + session: Pick, + enabled: boolean, + ): string | null => { + const runtime = resolveSessionActivityRuntime(session, enabled); + const target: AdeSessionActivityTarget = runtime + ? { type: "inline", runtimeSocketPath: runtime.runtimeSocketPath } + : { type: "environment" }; + return sessionActivityGuidanceForRuntime(session, runtime, target); + }; + + const buildCodexSessionActivityGuidance = ( + managed: ManagedChatSession, + collaborationMode = resolveCodexInstructionCollaborationMode(managed.session), + ): string | null => buildSessionActivityGuidance( + managed.session, + collaborationMode === "default" + && resolveSessionCodexConfigSource(managed.session) !== "config-toml", + ); + const buildAgentRuntimeEnv = (managed: ManagedChatSession): NodeJS.ProcessEnv => { const personalSession = isPersonalSession(managed.session); const issueSync = browserActorCapabilityIssuer.issueSync; @@ -9692,6 +9787,16 @@ export function createAgentChatService(args: { if (presetPlan) { Object.assign(env, presetPlan.env); } + // `ade chat activity` is executed by the agent's child CLI, so its runtime + // selection must match this service even when the launcher environment or + // a provider preset still carries another ADE channel's socket. + const activityRuntime = resolveSessionActivityRuntime(managed.session, true); + if (activityRuntime) { + // Different ADE CLI entry points prefer different socket environment + // variables. Pin every selector so child commands cannot follow a stale + // channel URL or RPC socket from the provider preset. + Object.assign(env, buildAdeRuntimeSocketEnv(activityRuntime.runtimeSocketPath)); + } return env; }; @@ -14305,11 +14410,38 @@ export function createAgentChatService(args: { // created, so a session switched from default to plan would keep its write // tools until something else restarted it. Restart on any policy change. const piToolPolicy = piSdkToolPolicyForPermissionMode(managed.session.permissionMode); + const piActivityRuntime = resolveSessionActivityRuntime( + managed.session, + process.platform !== "win32" + && managed.session.interactionMode !== "plan" + && managed.session.permissionMode !== "plan" + && piToolPolicy.tools.includes("bash"), + ); + const piActivityGuidance = sessionActivityGuidanceForRuntime(managed.session, piActivityRuntime); + const piActivityScope = piActivityRuntime + ? { + cliPath: piActivityRuntime.cliPath, + chatSessionId: managed.session.id, + ...(piActivityRuntime.runtimeSocketPath + ? { runtimeSocketPath: piActivityRuntime.runtimeSocketPath } + : {}), + } + : null; const piExtensionsEnabled = piChatExtensionsEnabled(managed); + const piActivityScopeKey = piActivityScope + ? createHash("sha256").update(JSON.stringify({ + cliPath: pathKey(piActivityScope.cliPath), + chatSessionId: piActivityScope.chatSessionId, + ...(piActivityScope.runtimeSocketPath + ? { runtimeSocketPath: pathKey(piActivityScope.runtimeSocketPath) } + : {}), + }), "utf8").digest("hex").slice(0, 12) + : "no-activity"; const toolPolicyKey = [ piToolPolicy.tools.join(","), piToolPolicy.approvalTools.join(","), piExtensionsEnabled ? "ext" : "no-ext", + piActivityScopeKey, ].join("|"); if (managed.runtime?.kind === "pi") { // `runtimeInvalidated` is checked here, not only on teardown: a session @@ -14386,6 +14518,7 @@ export function createAgentChatService(args: { interactive: true, runtime: "pi-sdk", adeSkillRoots: adePromptAgentSkillRoots({ cwd: managed.laneWorktreePath }), + sessionActivityGuidance: piActivityGuidance, }); // Pi's built-in tool registry only contains read, bash, edit, and write. // Passing ADE's generic grep/find/ls names would make the SDK launch fail. @@ -14399,31 +14532,32 @@ export function createAgentChatService(args: { acquired = await acquirePiSdkConnection({ poolKey, packageRoot: installation.packageRoot, - packageEntry: installation.packageEntry, - cwd: managed.laneWorktreePath, - agentDir: installation.agentDir, - sessionRoot, - ...(sessionStore.storageDir ? { sessionStorageDir: sessionStore.storageDir } : {}), - tools: piTools, - ...(piToolPolicy.approvalTools.length ? { approvalTools: piToolPolicy.approvalTools } : {}), - // Lets Pi ask the user a question mid-turn, which no tool allowlist can - // express. Personal chats get it too — it is how the model checks in. - askUserTool: true, - ...(piExtensionsEnabled ? { extensions: true } : {}), - ...(piProviderId && piModelId ? { modelRef: { provider: piProviderId, id: piModelId } } : {}), - thinkingLevel: managed.session.reasoningEffort ?? null, - systemPrompt, - skillsEnv: skillRoots.length ? { ADE_AGENT_SKILLS_DIRS: skillRoots.join(path.delimiter) } : {}, - ...(sessionFile || sessionId - ? { - session: { - ...(sessionFile ? { sessionFile } : {}), - ...(sessionId ? { sessionId } : {}), - }, - } - : {}), - baseEnv: runtimeEnv, - logger, + packageEntry: installation.packageEntry, + cwd: managed.laneWorktreePath, + agentDir: installation.agentDir, + sessionRoot, + ...(sessionStore.storageDir ? { sessionStorageDir: sessionStore.storageDir } : {}), + tools: piTools, + ...(piToolPolicy.approvalTools.length ? { approvalTools: piToolPolicy.approvalTools } : {}), + // Lets Pi ask the user a question mid-turn, which no tool allowlist can + // express. Personal chats get it too — it is how the model checks in. + askUserTool: true, + ...(piExtensionsEnabled ? { extensions: true } : {}), + ...(piProviderId && piModelId ? { modelRef: { provider: piProviderId, id: piModelId } } : {}), + thinkingLevel: managed.session.reasoningEffort ?? null, + systemPrompt, + skillsEnv: skillRoots.length ? { ADE_AGENT_SKILLS_DIRS: skillRoots.join(path.delimiter) } : {}, + ...(sessionFile || sessionId + ? { + session: { + ...(sessionFile ? { sessionFile } : {}), + ...(sessionId ? { sessionId } : {}), + }, + } + : {}), + baseEnv: runtimeEnv, + activityScope: piActivityScope, + logger, }); } catch (error) { piLease?.release(); @@ -22266,6 +22400,11 @@ export function createAgentChatService(args: { runtime.activeTurnId && (pendingUserShell || pendingMemoryCommand), ); if (!skipTurnStartForActiveComposerCommand) { + runtime.effectiveCollaborationMode = null; + emitTransientChatEnvelope(managed.session.id, { + type: "session_meta_updated", + codexEffectiveCollaborationMode: null, + }); setSessionActive(managed); if (!args.optimisticCodexTurnStart) { emitPreparedUserMessage(managed, { @@ -22705,6 +22844,7 @@ export function createAgentChatService(args: { managed.laneWorktreePath, resolveSessionLinearDirective(managed.session.id), spawnSelfReportOpts(managed.session), + buildSessionActivityGuidance(managed.session, true), ); if ( requestedCollaborationMode === "plan" @@ -22754,11 +22894,17 @@ export function createAgentChatService(args: { ...codexTurnPolicyArgs(codexPolicy), ...(collaborationMode ? { collaborationMode } : {}), }); + runtime.effectiveCollaborationMode = collaborationMode?.mode ?? null; + emitTransientChatEnvelope(managed.session.id, { + type: "session_meta_updated", + codexEffectiveCollaborationMode: runtime.effectiveCollaborationMode, + }); } catch (error) { const contextToRestore = runtime.turnStartContextConsumed ? consumedTurnContext : null; runtime.awaitingTurnStart = false; runtime.turnStartContextConsumed = false; runtime.pendingTurnPlanningApprovalGuarded = null; + runtime.effectiveCollaborationMode = null; if (contextToRestore) { managed.pendingTranscriptReplay = contextToRestore.replay || null; managed.pendingReconstructionContext = contextToRestore.reconstruction || null; @@ -27113,6 +27259,12 @@ export function createAgentChatService(args: { interactive: true, runtime: "claude-code-cli", adeSkillRoots: adePromptAgentSkillRoots({ cwd: managed.laneWorktreePath }), + sessionActivityGuidance: buildSessionActivityGuidance( + managed.session, + managed.session.permissionMode !== "plan" + && managed.session.interactionMode !== "plan" + && managed.session.claudePermissionMode !== "plan", + ), }); return [ harnessPrompt, @@ -28424,6 +28576,13 @@ export function createAgentChatService(args: { laneWorktreePath: managed.laneWorktreePath, session: managed.session, spawnGuidance: spawnSelfReportOpts(managed.session), + sessionActivityGuidance: buildOpenCodeSessionActivityGuidance( + managed.session, + runtime.permissionMode !== "plan" + && runtime.permissionMode !== "config-toml" + && managed.session.permissionMode !== "plan" + && managed.session.interactionMode !== "plan", + ), }); const openCodePromptBody = { sessionID: runtime.handle.sessionId, @@ -34546,6 +34705,7 @@ export function createAgentChatService(args: { resetCreditNoticeEmitted: false, collaborationModes: null, collaborationModesReady: null, + effectiveCollaborationMode: null, planModeFallbackNotified: false, goalBudgetClearInFlight: new Set(), goalBudgetClearRetryAfterByThreadId: new Map(), @@ -34915,6 +35075,7 @@ export function createAgentChatService(args: { collaborationMode: resolveCodexInstructionCollaborationMode(managed.session), linearDirective: resolveSessionLinearDirective(managed.session.id), spawnGuidance: spawnSelfReportOpts(managed.session), + sessionActivityGuidance: buildCodexSessionActivityGuidance(managed), }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), @@ -35378,6 +35539,12 @@ export function createAgentChatService(args: { managed: ManagedChatSession, runtime: ClaudeRuntime, ): { model: string } & ClaudeSDKOptions => { + // Resolve the activity instruction before native permission normalization. + // A queued/default-mode send can temporarily leave the Plan sentinel in a + // legacy permission field while interactionMode already reads "default". + // Keep that query from receiving an activity command before the canonical + // normalization below clears the raw fields. + const hadPlanIntentAtOptionBuildStart = isSessionInPlanMode(managed.session); const chatConfig = resolveChatConfig(); const claudePermissionMode = resolveSessionClaudePermissionMode( managed.session, @@ -35669,6 +35836,10 @@ export function createAgentChatService(args: { interactive: true, runtime: "claude-agent-sdk-query", adeSkillRoots: adePromptAgentSkillRoots({ cwd: managed.laneWorktreePath }), + sessionActivityGuidance: buildSessionActivityGuidance( + managed.session, + !hadPlanIntentAtOptionBuildStart && managed.session.interactionMode !== "plan", + ), }); opts.systemPrompt = { type: "preset", @@ -41701,7 +41872,13 @@ export function createAgentChatService(args: { // Permission mode is enforced by SDK AgentOptions (tools / autoReview / // sandboxOptions) plus ADE hook path guards. This injects only the ADE // control-protocol reminder — never advisory "you are in Ask/Plan" text. - const buildCursorSdkModeDirective = (): string => cursorSdkAdeControlDirective(); + const buildCursorSdkModeDirective = ( + managed: ManagedChatSession, + policy: CursorSdkPermissionPolicy, + ): string => [ + cursorSdkAdeControlDirective(), + buildSessionActivityGuidance(managed.session, policy.chatMode === "agent"), + ].filter(Boolean).join("\n"); const buildCursorSdkPendingInputRequest = ( itemId: string, @@ -42547,16 +42724,17 @@ export function createAgentChatService(args: { if (allow) return { selectedOption: allow.value }; } const itemId = req.id || randomUUID(); + const request = buildDroidSdkPendingInputRequest(itemId, req, runtime.activeTurnId ?? null); return new Promise((outerResolve) => { runtime.permissionWaiters.set(itemId, { toolName: req.toolName, request: req, + pendingInputRequest: request, resolve: (decision) => { runtime.permissionWaiters.delete(itemId); outerResolve(decision); }, }); - const request = buildDroidSdkPendingInputRequest(itemId, req, runtime.activeTurnId ?? null); emitChatEvent(managed, { type: "approval_request", itemId, @@ -42762,15 +42940,16 @@ export function createAgentChatService(args: { } const itemId = req.id || randomUUID(); + const request = buildCursorSdkPendingInputRequest(itemId, req, runtime.activeTurnId ?? null); return new Promise((outerResolve) => { runtime.permissionWaiters.set(itemId, { toolName: req.toolName, + pendingInputRequest: request, resolve: (decision) => { runtime.permissionWaiters.delete(itemId); outerResolve(decision); }, }); - const request = buildCursorSdkPendingInputRequest(itemId, req, runtime.activeTurnId ?? null); emitChatEvent(managed, { type: "approval_request", itemId, @@ -42890,6 +43069,10 @@ export function createAgentChatService(args: { const browserCapabilityReady = prepareBrowserActorCapability(managed); if (browserCapabilityReady) await browserCapabilityReady; const cursorRuntimeEnv = buildAgentRuntimeEnv(managed); + const cursorActivityRuntime = resolveSessionActivityRuntime( + managed.session, + policy.chatMode === "agent", + ); // Cursor's own skill discovery. ADE copies the bundled catalog into a // private shim laid out the way Cursor scans (`.agents/skills// // SKILL.md`) and passes that root as an extra workspace dir, instead of @@ -42923,6 +43106,9 @@ export function createAgentChatService(args: { agentName: manualSessionTitleForRuntime(managed), sessionId: managed.session.id, policy, + ...(cursorActivityRuntime + ? { activityRuntimeSocketPath: cursorActivityRuntime.runtimeSocketPath } + : {}), ...(cursorMcpServerConfig ? { mcpServers: cursorMcpServerConfig } : {}), ...(cursorAgentSkills.dirs.length ? { agentSkillDirs: cursorAgentSkills.dirs } : {}), logger, @@ -43601,7 +43787,7 @@ export function createAgentChatService(args: { composed = `${pendingTurnContext.composed}\n\n${composed}`; } const policy = runtime.sdkPolicy ?? resolveCursorSdkPolicy(managed.session); - const modeDirective = buildCursorSdkModeDirective(); + const modeDirective = buildCursorSdkModeDirective(managed, policy); if (modeDirective) { composed = `${modeDirective}\n\n${composed}`; } @@ -45787,15 +45973,24 @@ export function createAgentChatService(args: { } runtime.eventMapperState = createDroidSdkEventMapperState(); + const droidPermissionMode = resolveSessionDroidPermissionModeOrNull(managed.session); + const droidInteractionMode = resolveDroidSdkInteractionMode(managed.session); const droidHarnessPrompt = isPersonalSession(managed.session) ? resolvePersonalSystemPrompt(managed.session) : buildCodingAgentSystemPrompt({ cwd: managed.laneWorktreePath, - mode: resolveDroidSdkInteractionMode(managed.session) === "spec" ? "planning" : "coding", + mode: droidInteractionMode === "spec" ? "planning" : "coding", permissionMode: toHarnessPermissionMode(managed.session.permissionMode), interactive: true, runtime: "droid-sdk", adeSkillRoots: adePromptAgentSkillRoots({ cwd: managed.laneWorktreePath }), + sessionActivityGuidance: buildSessionActivityGuidance( + managed.session, + droidPermissionMode !== null + && droidPermissionMode !== "read-only" + && droidPermissionMode !== "agi" + && droidInteractionMode !== "spec", + ), }); const sdkInput = [ droidHarnessPrompt, @@ -46236,6 +46431,7 @@ export function createAgentChatService(args: { collaborationMode: resolveCodexInstructionCollaborationMode(managed.session), linearDirective: resolveSessionLinearDirective(managed.session.id), spawnGuidance: spawnSelfReportOpts(managed.session), + sessionActivityGuidance: buildCodexSessionActivityGuidance(managed), }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), @@ -46532,6 +46728,7 @@ export function createAgentChatService(args: { const clearUserTurnMarkers = (): void => { if (messageClearsAttentionMarkers(args.metadata)) { sessionService.clearTurnStartMarkers(args.sessionId); + sessionService.clearSessionActivity(args.sessionId); } }; if (options?.routeActiveToSteer && routableText && canRouteActiveSendToSteer(managed)) { @@ -47563,6 +47760,7 @@ export function createAgentChatService(args: { } markersCleared = true; sessionService.clearTurnStartMarkers(args.sessionId); + sessionService.clearSessionActivity(args.sessionId); }; const result = await steerWithOptions(args, { onAcceptedDispatch: clearAcceptedUserMarkers, @@ -49315,6 +49513,7 @@ export function createAgentChatService(args: { collaborationMode: resolveCodexInstructionCollaborationMode(managed.session), linearDirective: resolveSessionLinearDirective(managed.session.id), spawnGuidance: spawnSelfReportOpts(managed.session), + sessionActivityGuidance: buildCodexSessionActivityGuidance(managed), }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), @@ -50050,6 +50249,12 @@ export function createAgentChatService(args: { : hasPersistedCodexServiceTier ? persisted?.codexServiceTier ?? null : undefined; + const liveCodexRuntime = provider === "codex" + && liveSession?.status === "active" + && liveManaged?.runtime?.kind === "codex" + ? liveManaged.runtime + : null; + const codexEffectiveCollaborationMode = liveCodexRuntime?.effectiveCollaborationMode ?? null; const claudeTag = provider === "claude" ? getClaudeSessionPointerForChat(row.id)?.tags[0] ?? null : undefined; @@ -50170,6 +50375,10 @@ export function createAgentChatService(args: { reasoningEffort: liveSession?.reasoningEffort ?? persisted?.reasoningEffort ?? null, fastMode: (liveSession?.fastMode ?? persisted?.fastMode) === true, ...(codexServiceTier !== undefined ? { codexServiceTier } : {}), + ...(codexEffectiveCollaborationMode ? { codexEffectiveCollaborationMode } : {}), + ...(liveCodexRuntime && codexEffectiveCollaborationMode === null + ? { codexEffectiveCollaborationModeWasCleared: true } + : {}), executionMode: liveSession?.executionMode ?? persisted?.executionMode ?? null, interactionMode: liveSession?.interactionMode ?? persisted?.interactionMode ?? null, ...(liveSession?.claudePermissionMode || persisted?.claudePermissionMode @@ -52086,15 +52295,17 @@ export function createAgentChatService(args: { * (an unwritable Codex stdin, say) leaves the card and the markers alone, * because the question really is still open. * - * Columns cleared, by `sessionService.clearTurnStartMarkers`: - * `attention_requested_at`, `attention_message`, `attention_source`, - * `last_turn_failed_at`, plus the settle-lifecycle clear-on-activity. + * `sessionService.clearTurnStartMarkers` clears the attention and failure + * columns plus the settle-lifecycle clear-on-activity. The separate + * `sessionService.clearSessionActivity` clears `activity_status_json`; that + * report belongs to the turn and must not be cleared by ordinary PTY input. * `pending_input_item_id` is NOT written here — it is owned by the card * stores and their `pending_input_resolved` receipts. */ const respondToInput = async (args: AgentChatRespondToInputArgs): Promise => { await deliverInputResponse(args); sessionService.clearTurnStartMarkers(args.sessionId); + sessionService.clearSessionActivity(args.sessionId); }; /** @@ -56514,6 +56725,7 @@ export function createAgentChatService(args: { session: managed.session, collaborationMode: resolveCodexInstructionCollaborationMode(managed.session), spawnGuidance: spawnSelfReportOpts(managed.session), + sessionActivityGuidance: buildCodexSessionActivityGuidance(managed), }), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), diff --git a/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts b/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts index 64a02d2f24..0f5b157501 100644 --- a/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts +++ b/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts @@ -165,6 +165,7 @@ function createHarness(messages: Array>) { setHeadShaEnd: vi.fn(), setLastOutputPreview: vi.fn(), clearTurnStartMarkers: vi.fn(), + clearSessionActivity: vi.fn(), markLastTurnFailed: vi.fn(), clearLastTurnFailed: vi.fn(), setSummary: vi.fn(), diff --git a/apps/desktop/src/main/services/chat/claudePlanMode.test.ts b/apps/desktop/src/main/services/chat/claudePlanMode.test.ts index c9e6d4e9f9..a19ed0172b 100644 --- a/apps/desktop/src/main/services/chat/claudePlanMode.test.ts +++ b/apps/desktop/src/main/services/chat/claudePlanMode.test.ts @@ -142,6 +142,30 @@ describe("isSessionInPlanMode", () => { claudePermissionMode: "bypassPermissions", }))).toBe(false); }); + + it("captures Plan intent before Claude query option normalization", async () => { + const fs = await import("node:fs"); + const path = await import("node:path"); + const source = fs.readFileSync(path.join(__dirname, "agentChatService.ts"), "utf8"); + const builderStart = source.indexOf("const buildClaudeQueryOptions = ("); + const planSnapshot = source.indexOf( + "const hadPlanIntentAtOptionBuildStart = isSessionInPlanMode(managed.session);", + builderStart, + ); + const nativeModeNormalization = source.indexOf( + "const claudePermissionMode = resolveSessionClaudePermissionMode(", + builderStart, + ); + const activityGate = source.indexOf( + "!hadPlanIntentAtOptionBuildStart && managed.session.interactionMode !== \"plan\"", + builderStart, + ); + + expect(builderStart).toBeGreaterThanOrEqual(0); + expect(planSnapshot).toBeGreaterThan(builderStart); + expect(planSnapshot).toBeLessThan(nativeModeNormalization); + expect(activityGate).toBeGreaterThan(nativeModeNormalization); + }); }); describe("persistence round-trip", () => { it("restores the pre-plan mode after the session is rehydrated mid-plan", () => { diff --git a/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts b/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts index d1aa8f2295..b9419ae5b3 100644 --- a/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts +++ b/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts @@ -167,6 +167,7 @@ function createHarness(messages: Array>) { setHeadShaEnd: vi.fn(), setLastOutputPreview: vi.fn(), clearTurnStartMarkers: vi.fn(), + clearSessionActivity: vi.fn(), markLastTurnFailed: vi.fn(), clearLastTurnFailed: vi.fn(), setSummary: vi.fn(), diff --git a/apps/desktop/src/main/services/chat/claudeSubagentResultGate.test.ts b/apps/desktop/src/main/services/chat/claudeSubagentResultGate.test.ts index 000c646537..917348629c 100644 --- a/apps/desktop/src/main/services/chat/claudeSubagentResultGate.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSubagentResultGate.test.ts @@ -167,6 +167,7 @@ function createHarness(messages: Array>, holdOpen = fals setHeadShaEnd: vi.fn(), setLastOutputPreview: vi.fn(), clearTurnStartMarkers: vi.fn(), + clearSessionActivity: vi.fn(), markLastTurnFailed: vi.fn(), clearLastTurnFailed: vi.fn(), setSummary: vi.fn(), diff --git a/apps/desktop/src/main/services/chat/claudeTaskTodos.test.ts b/apps/desktop/src/main/services/chat/claudeTaskTodos.test.ts index e964a5427c..1e2be79da0 100644 --- a/apps/desktop/src/main/services/chat/claudeTaskTodos.test.ts +++ b/apps/desktop/src/main/services/chat/claudeTaskTodos.test.ts @@ -152,6 +152,7 @@ function createHarness(messages: Array>) { setHeadShaEnd: vi.fn(), setLastOutputPreview: vi.fn(), clearTurnStartMarkers: vi.fn(), + clearSessionActivity: vi.fn(), markLastTurnFailed: vi.fn(), clearLastTurnFailed: vi.fn(), setSummary: vi.fn(), diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts index 886350fb13..841325ec82 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts @@ -95,6 +95,34 @@ class FakeSdkChild extends EventEmitter { } } +class GatedInitSdkChild extends FakeSdkChild { + private markInitSent!: () => void; + readonly initSent = new Promise((resolve) => { + this.markInitSent = resolve; + }); + initRequest: { requestId: string; payload?: unknown } | null = null; + + override send(message: { type?: string; requestId?: string; payload?: unknown }): boolean { + if (message.type === "init" && message.requestId) { + this.sent.push(message); + this.initRequest = { requestId: message.requestId, payload: message.payload }; + this.markInitSent(); + return true; + } + return super.send(message); + } + + completeInit(): void { + if (!this.initRequest) throw new Error("Cursor init was not sent."); + this.emit("message", { + type: "response", + requestId: this.initRequest.requestId, + ok: true, + result: { agentId: "agent-1" }, + }); + } +} + class DelayedExitChild extends FakeSdkChild { override send(message: { type?: string; requestId?: string }): boolean { if (message.type === "init" && message.requestId) { @@ -730,6 +758,33 @@ describe("Cursor SDK pool paths", () => { expect(env.ADE_CURSOR_SDK_STATE_ROOT).toBe("/repo/.ade/cache/cursor-sdk/hash/state"); }); + it("passes only the explicitly authorized ADE runtime socket for activity reports", () => { + const env = buildCursorSdkWorkerEnv({ + baseEnv: { + PATH: "/usr/bin", + ADE_HOME: "/Users/admin/.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + ADE_RUNTIME_SOCKET_PATH: "/Users/admin/.ade/sock/ade.sock", + ADE_RPC_SOCKET_PATH: "/Users/admin/.ade/sock/ade.sock", + ADE_RPC_URL: "/Users/admin/.ade/sock/ade.sock", + }, + userHomeDir: "/Users/admin", + stateRoot: "/repo/.ade/cache/cursor-sdk/hash/state", + socketPath: "/tmp/ade-cursor-sdk/socket.sock", + workspacePath: "/repo/.ade/worktrees/lane", + sessionId: "session-1", + activityRuntimeSocketPath: "/Users/admin/.ade-beta/sock/ade.sock", + }); + + expect(env).toMatchObject({ + ADE_RPC_URL: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_RPC_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_RUNTIME_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + }); + expect(env.ADE_HOME).toBeUndefined(); + expect(env.ADE_PACKAGE_CHANNEL).toBeUndefined(); + }); + it("rebuilds packaged NODE_PATH for forked workers launched outside the ADE CLI wrapper", () => { const resourcesRoot = makeTempDir("ade-packaged-resources-"); const cliBinDir = path.join(resourcesRoot, "ade-cli", "bin"); @@ -775,6 +830,8 @@ describe("Cursor SDK pool paths", () => { ADE_PACKAGE_CHANNEL: "beta", ADE_HOME: "/Users/admin/.ade-beta", ADE_RUNTIME_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_RPC_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_RPC_URL: "/Users/admin/.ade-beta/sock/ade.sock", ADE_CLI_ENTRY_PATH: stableEntry, ADE_CLI_BIN_DIR: betaBinDir, ADE_CLI_PATH: betaCommand, @@ -789,6 +846,8 @@ describe("Cursor SDK pool paths", () => { expect(env.ADE_CLI_ENTRY_PATH).toBeUndefined(); expect(env.ADE_PACKAGE_CHANNEL).toBeUndefined(); expect(env.ADE_HOME).toBeUndefined(); + expect(env.ADE_RPC_URL).toBeUndefined(); + expect(env.ADE_RPC_SOCKET_PATH).toBeUndefined(); expect(env.ADE_RUNTIME_SOCKET_PATH).toBeUndefined(); expect(env.ADE_CLI_BIN_DIR).toBe(betaBinDir); expect(env.ADE_CLI_PATH).toBe(betaCommand); @@ -832,6 +891,137 @@ describe("Cursor SDK pool paths", () => { expect(child.disposeCount).toBe(1); }); + it("rejects a shared initialization with different skill roots without interrupting its owner", async () => { + const firstChild = new GatedInitSdkChild(); + const secondChild = new FakeSdkChild(); + forkMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild); + const poolKey = `test-skill-roots:${Date.now()}:${Math.random()}`; + const args = { + poolKey, + projectRoot: path.join(os.tmpdir(), "ade-project"), + workspacePath: path.join(os.tmpdir(), "ade-workspace"), + modelSdkId: "cursor-model", + sessionId: "session-1", + policy: { ...TEST_POLICY }, + }; + + const firstPending = acquireCursorSdkConnection({ ...args, agentSkillDirs: ["/skills/first"] }); + await firstChild.initSent; + const secondPending = acquireCursorSdkConnection({ ...args, agentSkillDirs: ["/skills/second"] }); + expect(forkMock).toHaveBeenCalledTimes(1); + expect(firstChild.initRequest?.payload).toMatchObject({ agentSkillDirs: ["/skills/first"] }); + + firstChild.completeInit(); + const first = await firstPending; + expect(first.pooled.process).toBe(firstChild); + await expect(secondPending).rejects.toThrow("active with different launch capabilities"); + expect(firstChild.disposeCount).toBe(0); + + releaseCursorSdkConnection(poolKey, first.generation); + const second = await acquireCursorSdkConnection({ ...args, agentSkillDirs: ["/skills/second"] }); + + expect(second.pooled.process).toBe(secondChild); + expect(forkMock).toHaveBeenCalledTimes(2); + expect(secondChild.sent.find((message) => (message as { type?: string }).type === "init")) + .toMatchObject({ payload: { agentSkillDirs: ["/skills/second"] } }); + expect(firstChild.disposeCount).toBe(1); + releaseCursorSdkConnection(poolKey, second.generation); + }); + + it("starts a new worker with requested skill roots after the leased worker exits unexpectedly", async () => { + const firstChild = new FakeSdkChild(); + const secondChild = new FakeSdkChild(); + forkMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild); + const poolKey = `test-skill-roots-exit:${Date.now()}:${Math.random()}`; + const args = { + poolKey, + projectRoot: path.join(os.tmpdir(), "ade-project"), + workspacePath: path.join(os.tmpdir(), "ade-workspace"), + modelSdkId: "cursor-model", + sessionId: "session-1", + policy: { ...TEST_POLICY }, + }; + + const first = await acquireCursorSdkConnection({ ...args, agentSkillDirs: ["/skills/first"] }); + firstChild.finishExit(1, null); + const second = await acquireCursorSdkConnection({ ...args, agentSkillDirs: ["/skills/second"] }); + + expect(second.pooled.process).toBe(secondChild); + expect(forkMock).toHaveBeenCalledTimes(2); + expect(secondChild.sent.find((message) => (message as { type?: string }).type === "init")) + .toMatchObject({ payload: { agentSkillDirs: ["/skills/second"] } }); + releaseCursorSdkConnection(poolKey, first.generation); + releaseCursorSdkConnection(poolKey, second.generation); + }); + + it("replaces a live worker when its authorized ADE runtime socket changes", async () => { + const firstChild = new FakeSdkChild(); + const secondChild = new FakeSdkChild(); + forkMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild); + const poolKey = `test-activity-socket:${Date.now()}:${Math.random()}`; + const args = { + poolKey, + projectRoot: path.join(os.tmpdir(), "ade-project"), + workspacePath: path.join(os.tmpdir(), "ade-workspace"), + modelSdkId: "cursor-model", + sessionId: "session-1", + policy: { ...TEST_POLICY }, + activityRuntimeSocketPath: "/runtime/alpha.sock", + }; + + const first = await acquireCursorSdkConnection(args); + const secondPending = acquireCursorSdkConnection({ + ...args, + activityRuntimeSocketPath: "/runtime/beta.sock", + }); + await expect(secondPending).rejects.toThrow("active with different launch capabilities"); + expect(forkMock).toHaveBeenCalledTimes(1); + expect(firstChild.disposeCount).toBe(0); + + releaseCursorSdkConnection(poolKey, first.generation); + const second = await acquireCursorSdkConnection({ + ...args, + activityRuntimeSocketPath: "/runtime/beta.sock", + }); + + expect(second.pooled).not.toBe(first.pooled); + expect(forkMock).toHaveBeenCalledTimes(2); + releaseCursorSdkConnection(poolKey, second.generation); + }); + + it.skipIf(process.platform === "linux")("reuses a live worker for equivalent case-insensitive runtime socket paths", async () => { + const child = new FakeSdkChild(); + forkMock.mockReturnValue(child); + const poolKey = `test-equivalent-activity-socket:${Date.now()}:${Math.random()}`; + const runtimeSocketPath = process.platform === "win32" + ? "C:\\runtime\\alpha.sock" + : "/runtime/alpha.sock"; + const equivalentRuntimeSocketPath = process.platform === "win32" + ? "c:/RUNTIME/ALPHA.SOCK" + : "/RUNTIME/ALPHA.SOCK"; + const args = { + poolKey, + projectRoot: path.join(os.tmpdir(), "ade-project"), + workspacePath: path.join(os.tmpdir(), "ade-workspace"), + modelSdkId: "cursor-model", + sessionId: "session-1", + policy: { ...TEST_POLICY }, + activityRuntimeSocketPath: runtimeSocketPath, + }; + + const first = await acquireCursorSdkConnection(args); + const equivalent = await acquireCursorSdkConnection({ + ...args, + activityRuntimeSocketPath: equivalentRuntimeSocketPath, + }); + + expect(equivalent.pooled).toBe(first.pooled); + expect(equivalent.generation).toBe(first.generation); + expect(forkMock).toHaveBeenCalledTimes(1); + releaseCursorSdkConnection(poolKey, first.generation); + releaseCursorSdkConnection(poolKey, equivalent.generation); + }); + it("evicts a poisoned worker even while another lease is still held", async () => { const child = new FakeSdkChild(); forkMock.mockReturnValue(child); diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.ts index afe0c5a6c0..abd998b6da 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Logger } from "../logging/logger"; +import { buildAdeRuntimeSocketEnv } from "../../../shared/adeCliGuidance"; import { buildPackagedRuntimeNodeModulePaths } from "../runtime/packagedNodePath"; import { pathKey } from "../shared/pathCompare"; import { CURSOR_SDK_KILL_ESCALATION_MS, CURSOR_SDK_ONESHOT_POLICY } from "./cursorSdkPolicy"; @@ -99,6 +100,8 @@ type CursorSdkPoolEntry = { idleTimer: ReturnType | null; /** The `local.dirs` this worker was launched with; see `sameSkillDirs`. */ agentSkillDirs: string[]; + /** Exact ADE runtime socket allowed for this worker's activity reports. */ + activityRuntimeSocketPath: string | null; }; const pools = new Map(); @@ -174,6 +177,11 @@ function hashKey(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 16); } +function activityRuntimeSocketKey(value: string | null | undefined): string | null { + const socketPath = value?.trim(); + return socketPath ? pathKey(socketPath) : null; +} + function resolveWorkerPath(): string { const candidates = [ path.join(moduleDir, "cursorSdkWorker.cjs"), @@ -497,6 +505,7 @@ export function buildCursorSdkWorkerEnv(args: { socketPath: string; workspacePath: string; sessionId: string; + activityRuntimeSocketPath?: string | null; }): NodeJS.ProcessEnv { const baseEnv = args.baseEnv ?? process.env; const env: NodeJS.ProcessEnv = { @@ -510,6 +519,16 @@ export function buildCursorSdkWorkerEnv(args: { ADE_CURSOR_SDK_STATE_ROOT: args.stateRoot, }; applyCurrentAdeCliEnv(env, baseEnv); + // Keep the worker's RPC target out of inherited channel configuration. The + // ADE CLI has several socket selectors, and its chat commands prefer RPC_URL + // / RPC_SOCKET_PATH over RUNTIME_SOCKET_PATH. + delete env.ADE_RPC_URL; + delete env.ADE_RPC_SOCKET_PATH; + delete env.ADE_RUNTIME_SOCKET_PATH; + const activityRuntimeSocketPath = args.activityRuntimeSocketPath?.trim(); + if (activityRuntimeSocketPath) { + Object.assign(env, buildAdeRuntimeSocketEnv(activityRuntimeSocketPath)); + } delete env.ADE_CLI_ENTRY_PATH; return env; } @@ -535,21 +554,31 @@ export async function acquireCursorSdkConnection(args: { * fallback for a session that gets none. */ agentSkillDirs?: string[]; + /** The exact ADE runtime this SDK worker may target for activity reports. */ + activityRuntimeSocketPath?: string | null; cleanupStateRoot?: boolean; logger?: Logger; }): Promise<{ pooled: CursorSdkPooled; generation: number }> { for (let staleInitRetries = 0; ; staleInitRetries += 1) { const existing = pools.get(args.poolKey); if (existing && isCursorSdkPooledAlive(existing.pooled)) { - // A live worker cannot pick up a different `local.dirs` without a - // restart. Reuse only when the skill roots it was launched with still - // match; otherwise replace it so the new roots are not silently ignored. - if (sameSkillDirs(existing.agentSkillDirs, args.agentSkillDirs)) { + // A live worker cannot pick up different skill roots or an ADE runtime + // socket without a restart. Reuse only when both launch capabilities + // still match; never replace a worker while one of its callers holds a lease. + const activityRuntimeSocketPath = args.activityRuntimeSocketPath?.trim() || null; + if ( + sameSkillDirs(existing.agentSkillDirs, args.agentSkillDirs) + && activityRuntimeSocketKey(existing.activityRuntimeSocketPath) === activityRuntimeSocketKey(activityRuntimeSocketPath) + ) { clearCursorSdkIdleTimer(existing); existing.ref += 1; return { pooled: existing.pooled, generation: existing.generation }; } - disposeCursorSdkPoolEntry(args.poolKey, existing); + if (existing.ref > 0) { + throw new Error( + "Cursor SDK worker is active with different launch capabilities. Release its current lease before changing skill roots or activity scope.", + ); + } } if (existing) disposeCursorSdkPoolEntry(args.poolKey, existing); await waitForDepartingCursorSdkWorker(args.poolKey); @@ -566,7 +595,8 @@ export async function acquireCursorSdkConnection(args: { const pooled = await init; const entry = pools.get(args.poolKey); - const live = entry?.pooled === pooled && isCursorSdkPooledAlive(pooled); + const live = entry?.pooled === pooled + && isCursorSdkPooledAlive(pooled); if (!entry || !live) { if (initOwner) { throw new Error("Cursor SDK worker was disposed during initialization."); @@ -576,6 +606,14 @@ export async function acquireCursorSdkConnection(args: { } continue; } + if ( + !sameSkillDirs(entry.agentSkillDirs, args.agentSkillDirs) + || activityRuntimeSocketKey(entry.activityRuntimeSocketPath) !== activityRuntimeSocketKey(args.activityRuntimeSocketPath) + ) { + throw new Error( + "Cursor SDK worker is active with different launch capabilities. Release its current lease before changing skill roots or activity scope.", + ); + } if (!initOwner) entry.ref += 1; return { pooled: entry.pooled, generation: entry.generation }; } @@ -608,6 +646,7 @@ async function createCursorSdkConnection(args: Parameters { expect(env).not.toHaveProperty("NOT_AN_ENV"); }); + + it("passes only the explicit per-chat activity scope through the ADE environment boundary", () => { + const env = buildPiWorkerEnvironment({ + PATH: "/bin", + ADE_CLI_PATH: "/inherited/ade", + ADE_CHAT_SESSION_ID: "other-chat", + ADE_DEFAULT_ROLE: "cto", + ADE_RUNTIME_SOCKET_PATH: "/other/runtime.sock", + ADE_RPC_SOCKET_PATH: "/other/rpc.sock", + ADE_RPC_URL: "/other/url.sock", + ADE_ACTIVITY_SESSION_ID: "terminal-row", + ADE_BROWSER_ACTOR_TOKEN: "browser-token", + ADE_PARENT_CHAT_SESSION_ID: "parent-chat", + ADE_PROJECT_ROOT: "/project", + }, undefined, { + cliPath: "/resolved/ade", + chatSessionId: "chat-1", + runtimeSocketPath: "/runtime/ade.sock", + }); + + expect(env).toMatchObject({ + PATH: "/bin", + ADE_CLI_PATH: "/resolved/ade", + ADE_CHAT_SESSION_ID: "chat-1", + ADE_DEFAULT_ROLE: "agent", + ADE_RPC_URL: "/runtime/ade.sock", + ADE_RPC_SOCKET_PATH: "/runtime/ade.sock", + ADE_RUNTIME_SOCKET_PATH: "/runtime/ade.sock", + }); + expect(env).not.toHaveProperty("ADE_ACTIVITY_SESSION_ID"); + expect(env).not.toHaveProperty("ADE_BROWSER_ACTOR_TOKEN"); + expect(env).not.toHaveProperty("ADE_PARENT_CHAT_SESSION_ID"); + expect(env).not.toHaveProperty("ADE_PROJECT_ROOT"); + }); }); diff --git a/apps/desktop/src/main/services/chat/piSdkEnvironment.ts b/apps/desktop/src/main/services/chat/piSdkEnvironment.ts index 617f70dec8..4b3fd0c0e7 100644 --- a/apps/desktop/src/main/services/chat/piSdkEnvironment.ts +++ b/apps/desktop/src/main/services/chat/piSdkEnvironment.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { buildAdeRuntimeSocketEnv } from "../../../shared/adeCliGuidance"; const PI_STANDARD_ENVIRONMENT_KEYS = [ "PATH", "Path", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", @@ -17,6 +18,13 @@ const PI_STANDARD_ENVIRONMENT_KEYS = [ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u; +/** The narrow ADE CLI identity Pi needs for session-scoped activity reports. */ +export type PiWorkerActivityScope = { + cliPath: string; + chatSessionId: string; + runtimeSocketPath?: string; +}; + function isBlockedPiEnvironmentName(name: string): boolean { return name.toUpperCase().startsWith("ADE_"); } @@ -97,6 +105,7 @@ function declaredPiEnvironmentNames(agentDir: string | undefined): Set { export function buildPiWorkerEnvironment( source: NodeJS.ProcessEnv, agentDir?: string | null, + activityScope?: PiWorkerActivityScope | null, ): NodeJS.ProcessEnv { const allowed = new Set(PI_STANDARD_ENVIRONMENT_KEYS); for (const name of declaredPiEnvironmentNames(agentDir ?? source.PI_CODING_AGENT_DIR)) { @@ -106,5 +115,16 @@ export function buildPiWorkerEnvironment( for (const key of allowed) { if (typeof source[key] === "string") result[key] = source[key]; } + const cliPath = activityScope?.cliPath.trim(); + const chatSessionId = activityScope?.chatSessionId.trim(); + if (cliPath && chatSessionId) { + result.ADE_CLI_PATH = cliPath; + result.ADE_CHAT_SESSION_ID = chatSessionId; + result.ADE_DEFAULT_ROLE = "agent"; + const runtimeSocketPath = activityScope?.runtimeSocketPath?.trim(); + if (runtimeSocketPath) { + Object.assign(result, buildAdeRuntimeSocketEnv(runtimeSocketPath)); + } + } return result; } diff --git a/apps/desktop/src/main/services/chat/piSdkPool.test.ts b/apps/desktop/src/main/services/chat/piSdkPool.test.ts new file mode 100644 index 0000000000..f555603a9d --- /dev/null +++ b/apps/desktop/src/main/services/chat/piSdkPool.test.ts @@ -0,0 +1,146 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + acquirePiSdkConnection, + releasePiSdkConnection, +} from "./piSdkPool"; +import { PI_SDK_PROTOCOL_VERSION } from "./piSdkProtocol"; + +const forkMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + fork: (...args: unknown[]) => forkMock(...args), +})); + +class FakePiWorker extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + pid = 4242; + exitCode: number | null = null; + killed = false; + connected = true; + disposeCount = 0; + private exited = false; + + send(message: { type?: string; requestId?: string }): boolean { + if (message.type === "init" && message.requestId) { + queueMicrotask(() => this.emit("message", { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + type: "response", + requestId: message.requestId, + ok: true, + result: { + protocolVersion: PI_SDK_PROTOCOL_VERSION, + packageRoot: "/pi", + packageEntry: "/pi/index.js", + version: null, + sessionFile: null, + sessionId: null, + currentModel: null, + thinkingLevel: null, + availableModels: [], + }, + })); + } + if (message.type === "dispose") { + this.disposeCount += 1; + queueMicrotask(() => this.finishExit(0)); + } + return true; + } + + finishExit(code: number): void { + if (this.exited) return; + this.exited = true; + this.exitCode = code; + this.connected = false; + this.emit("exit", code, null); + } + + kill(): boolean { + this.killed = true; + this.finishExit(1); + return true; + } +} + +afterEach(() => { + forkMock.mockReset(); +}); + +describe("Pi SDK worker activity scope pooling", () => { + it.skipIf(process.platform === "linux")("reuses a worker for equivalent activity executable and socket paths", async () => { + const worker = new FakePiWorker(); + forkMock.mockReturnValue(worker); + const poolKey = `pi-equivalent-activity-scope:${Date.now()}:${Math.random()}`; + const cliPath = process.platform === "win32" ? "C:\\ADE\\bin\\ade.exe" : "/ADE/bin/ade"; + const equivalentCliPath = process.platform === "win32" ? "c:/ade/BIN/ADE.exe" : "/ade/BIN/ADE"; + const runtimeSocketPath = process.platform === "win32" + ? "C:\\ADE\\runtime\\ade.sock" + : "/ADE/runtime/ade.sock"; + const equivalentRuntimeSocketPath = process.platform === "win32" + ? "c:/ade/RUNTIME/ade.sock" + : "/ade/RUNTIME/ADE.sock"; + const args = { + poolKey, + packageRoot: "/pi", + packageEntry: "/pi/index.js", + cwd: "/workspace", + agentDir: "/agent", + activityScope: { cliPath, chatSessionId: "chat-1", runtimeSocketPath }, + }; + + const first = await acquirePiSdkConnection(args); + const equivalent = await acquirePiSdkConnection({ + ...args, + activityScope: { + cliPath: equivalentCliPath, + chatSessionId: "chat-1", + runtimeSocketPath: equivalentRuntimeSocketPath, + }, + }); + + expect(equivalent.pooled).toBe(first.pooled); + expect(equivalent.generation).toBe(first.generation); + expect(forkMock).toHaveBeenCalledTimes(1); + releasePiSdkConnection(poolKey, first.generation); + releasePiSdkConnection(poolKey, equivalent.generation); + }); + + it("reuses a matching activity scope and replaces a worker when its scope changes", async () => { + const firstWorker = new FakePiWorker(); + const replacementWorker = new FakePiWorker(); + forkMock.mockReturnValueOnce(firstWorker).mockReturnValueOnce(replacementWorker); + const poolKey = `pi-activity-scope:${Date.now()}:${Math.random()}`; + const args = { + poolKey, + packageRoot: "/pi", + packageEntry: "/pi/index.js", + cwd: "/workspace", + agentDir: "/agent", + activityScope: { + cliPath: "/ade/bin/ade", + chatSessionId: "chat-1", + runtimeSocketPath: "/runtime/alpha.sock", + }, + }; + + const first = await acquirePiSdkConnection(args); + const sameScope = await acquirePiSdkConnection({ ...args, activityScope: { ...args.activityScope } }); + expect(sameScope.pooled).toBe(first.pooled); + expect(sameScope.generation).toBe(first.generation); + expect(forkMock).toHaveBeenCalledTimes(1); + + const replacement = await acquirePiSdkConnection({ + ...args, + activityScope: { ...args.activityScope, runtimeSocketPath: "/runtime/beta.sock" }, + }); + expect(replacement.pooled).not.toBe(first.pooled); + expect(replacement.generation).not.toBe(first.generation); + expect(firstWorker.disposeCount).toBe(1); + expect(firstWorker.exitCode).toBe(0); + expect(forkMock).toHaveBeenCalledTimes(2); + + releasePiSdkConnection(poolKey, replacement.generation); + }); +}); diff --git a/apps/desktop/src/main/services/chat/piSdkPool.ts b/apps/desktop/src/main/services/chat/piSdkPool.ts index e43f417237..4acb7cbc7f 100644 --- a/apps/desktop/src/main/services/chat/piSdkPool.ts +++ b/apps/desktop/src/main/services/chat/piSdkPool.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Logger } from "../logging/logger"; +import { pathKey } from "../shared/pathCompare"; import { terminateChildProcessTree } from "../shared/utils"; import { PI_SDK_PROTOCOL_VERSION, @@ -25,7 +26,7 @@ import { type PiSdkWorkerInit, type PiSdkWorkerRequest, } from "./piSdkProtocol"; -import { buildPiWorkerEnvironment } from "./piSdkEnvironment"; +import { buildPiWorkerEnvironment, type PiWorkerActivityScope } from "./piSdkEnvironment"; export type PiSdkBridge = { onEvent: ((event: JsonValue) => void) | null; @@ -120,6 +121,8 @@ export type AcquirePiSdkConnectionArgs = PiSdkPackageLocation & { approvalTools?: string[]; /** Usually process.env; never put auth.json or API keys in this payload. */ baseEnv?: NodeJS.ProcessEnv; + /** Passed only when this chat can run the session-scoped ADE activity command. */ + activityScope?: PiWorkerActivityScope | null; logger?: Logger; }; @@ -130,11 +133,17 @@ type PendingRpc = { timer: NodeJS.Timeout | null; }; -type PoolEntry = { ref: number; generation: number; pooled: PiSdkPooled }; +type PoolEntry = { + ref: number; + generation: number; + pooled: PiSdkPooled; + activityScope: PiWorkerActivityScope | null; +}; let generationCounter = 0; const pools = new Map(); const pendingInits = new Map>(); +const departingWorkers = new Map>(); const STALE_INIT_RETRY_LIMIT = 2; const DISPOSE_GRACE_MS = 1_500; const REQUEST_TIMEOUT_MS: Partial> = { @@ -178,6 +187,42 @@ function applyReady(pooled: PiSdkPooled, value: PiSdkReady): void { pooled.availableModels = value.availableModels; } +function normalizedActivityScope(scope: PiWorkerActivityScope | null | undefined): PiWorkerActivityScope | null { + if (!scope) return null; + return { + cliPath: pathKey(scope.cliPath), + chatSessionId: scope.chatSessionId, + ...(scope.runtimeSocketPath ? { runtimeSocketPath: pathKey(scope.runtimeSocketPath) } : {}), + }; +} + +function sameActivityScope( + left: PiWorkerActivityScope | null | undefined, + right: PiWorkerActivityScope | null | undefined, +): boolean { + const normalizedLeft = normalizedActivityScope(left); + const normalizedRight = normalizedActivityScope(right); + return normalizedLeft?.cliPath === normalizedRight?.cliPath + && normalizedLeft?.chatSessionId === normalizedRight?.chatSessionId + && normalizedLeft?.runtimeSocketPath === normalizedRight?.runtimeSocketPath; +} + +function disposePiSdkPoolEntry(poolKey: string, entry: PoolEntry): Promise { + if (pools.get(poolKey) === entry) pools.delete(poolKey); + const departing = departingWorkers.get(poolKey); + if (departing) return departing; + entry.pooled.dispose(); + const exit = entry.pooled.waitForExit().finally(() => { + if (departingWorkers.get(poolKey) === exit) departingWorkers.delete(poolKey); + }); + departingWorkers.set(poolKey, exit); + return exit; +} + +async function waitForDepartingPiSdkWorker(poolKey: string): Promise { + await departingWorkers.get(poolKey); +} + export async function acquirePiSdkConnection( args: AcquirePiSdkConnectionArgs, ): Promise<{ pooled: PiSdkPooled; generation: number }> { @@ -185,13 +230,16 @@ export async function acquirePiSdkConnection( for (let retries = 0; ; retries += 1) { const existing = pools.get(args.poolKey); if (existing && isAlive(existing.pooled)) { - existing.ref += 1; - return { pooled: existing.pooled, generation: existing.generation }; + if (sameActivityScope(existing.activityScope, args.activityScope)) { + existing.ref += 1; + return { pooled: existing.pooled, generation: existing.generation }; + } + await disposePiSdkPoolEntry(args.poolKey, existing); } - if (existing) { - pools.delete(args.poolKey); - existing.pooled.dispose(); + if (existing && !isAlive(existing.pooled)) { + await disposePiSdkPoolEntry(args.poolKey, existing); } + await waitForDepartingPiSdkWorker(args.poolKey); let owner = false; let init = pendingInits.get(args.poolKey); @@ -202,7 +250,8 @@ export async function acquirePiSdkConnection( } const pooled = await init; const entry = pools.get(args.poolKey); - if (!entry || entry.pooled !== pooled || !isAlive(pooled)) { + if (!entry || entry.pooled !== pooled || !isAlive(pooled) + || !sameActivityScope(entry.activityScope, args.activityScope)) { if (owner) throw new Error("Pi SDK worker was disposed during initialization."); if (retries >= STALE_INIT_RETRY_LIMIT) throw new Error("Pi SDK worker initialization did not settle after retries."); continue; @@ -249,7 +298,7 @@ function createPiSdkConnection(args: AcquirePiSdkConnectionArgs): Promise { }); describe("createAdeCliService", () => { + it("does not trust an inherited ADE_CLI_PATH when ADE resolved no command", () => { + const root = makeTempRoot(); + const untrustedCommand = path.join(root, "untrusted-ade"); + writeExecutable(untrustedCommand); + const service = createAdeCliService({ + isPackaged: true, + resourcesPath: path.join(root, "empty-resources"), + userDataPath: path.join(root, "user-data"), + appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"), + logger: logger() as any, + }); + + expect(service.resolved.commandPath).toBeNull(); + expect(service.agentEnv({ ADE_CLI_PATH: untrustedCommand }).ADE_CLI_PATH).toBeUndefined(); + }); + + it("clears stale CLI resolver variables and PATH fallback when no bundled CLI resolves", () => { + const root = makeTempRoot(); + const inheritedBinDir = path.join(root, "old-ade", "bin"); + const inheritedEntryPath = path.join(root, "old-ade", "cli.cjs"); + const fallbackPath = path.join(root, "system-bin"); + const inheritedPosixCommandPath = path.join(inheritedBinDir, "ade"); + const inheritedWindowsCommandPath = path.join(inheritedBinDir, "ade.cmd"); + writeExecutable(inheritedPosixCommandPath); + writeExecutable(inheritedWindowsCommandPath, "@echo off\r\nexit /b 0\r\n"); + fs.mkdirSync(path.dirname(inheritedEntryPath), { recursive: true }); + fs.writeFileSync(inheritedEntryPath, "console.log('old ade')\n"); + fs.mkdirSync(fallbackPath, { recursive: true }); + expect(fs.existsSync(inheritedPosixCommandPath)).toBe(true); + expect(fs.existsSync(inheritedWindowsCommandPath)).toBe(true); + + const previousPathEntries = Object.entries(process.env) + .filter(([key]) => key.toLowerCase() === "path"); + const previousCliEnv = new Map( + ["ADE_CLI_PATH", "ADE_CLI_BIN_DIR", "ADE_CLI_ENTRY_PATH"] + .map((key) => [key, process.env[key]] as const), + ); + const platforms: NodeJS.Platform[] = originalPlatform === "win32" + ? ["win32"] + : [originalPlatform, "win32"]; + + try { + for (const platform of platforms) { + setPlatform(platform); + const envPathKey = platform === "win32" ? "Path" : "PATH"; + const delimiter = platform === "win32" ? ";" : path.delimiter; + const inheritedPath = `${inheritedBinDir}${delimiter}${fallbackPath}`; + const inheritedCommandPath = platform === "win32" + ? inheritedWindowsCommandPath + : inheritedPosixCommandPath; + const service = createAdeCliService({ + isPackaged: true, + resourcesPath: path.join(root, `missing-resources-${platform}`), + userDataPath: path.join(root, "user-data"), + appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"), + logger: logger() as any, + }); + expect(service.resolved.commandPath).toBeNull(); + + const inheritedEnv: NodeJS.ProcessEnv = { + [envPathKey]: inheritedPath, + ADE_CLI_PATH: inheritedCommandPath, + ADE_CLI_BIN_DIR: inheritedBinDir, + ADE_CLI_ENTRY_PATH: inheritedEntryPath, + }; + const agentEnv = service.agentEnv(inheritedEnv); + expect(agentEnv.ADE_CLI_PATH).toBeUndefined(); + expect(agentEnv.ADE_CLI_BIN_DIR).toBeUndefined(); + expect(agentEnv.ADE_CLI_ENTRY_PATH).toBeUndefined(); + expect(agentEnv[envPathKey]?.split(delimiter)).toEqual([fallbackPath]); + + for (const key of Object.keys(process.env)) { + if (key.toLowerCase() === "path") delete process.env[key]; + } + process.env[envPathKey] = inheritedPath; + process.env.ADE_CLI_PATH = inheritedCommandPath; + process.env.ADE_CLI_BIN_DIR = inheritedBinDir; + process.env.ADE_CLI_ENTRY_PATH = inheritedEntryPath; + + service.applyToProcessEnv(); + expect(process.env.ADE_CLI_PATH).toBeUndefined(); + expect(process.env.ADE_CLI_BIN_DIR).toBeUndefined(); + expect(process.env.ADE_CLI_ENTRY_PATH).toBeUndefined(); + expect(process.env[envPathKey]?.split(delimiter)).toEqual([fallbackPath]); + } + } finally { + for (const key of Object.keys(process.env)) { + if (key.toLowerCase() === "path") delete process.env[key]; + } + for (const [key, value] of previousPathEntries) process.env[key] = value; + for (const [key, value] of previousCliEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + setPlatform(originalPlatform); + } + }); + it("uses packaged ade-cli/bin when the bundled wrapper exists", () => { const root = makeTempRoot(); const resourcesPath = path.join(root, "Resources"); diff --git a/apps/desktop/src/main/services/cli/adeCliService.ts b/apps/desktop/src/main/services/cli/adeCliService.ts index f75e80945c..d9b0d7958c 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.ts @@ -11,6 +11,7 @@ import { import { cleanupLegacyAdeSkills } from "../skills/legacySkillCleanupService"; import type { Logger } from "../logging/logger"; import { spawnAsync } from "../shared/utils"; +import { pathsEqual } from "../shared/pathCompare"; import { getPathEnvValue, setPathEnvValue, @@ -150,17 +151,24 @@ function splitPathEntries(value: string | null | undefined): string[] { return (value ?? "").split(pathDelimiter()).map((entry) => entry.trim()).filter(Boolean); } +function pathEntriesEqual(entry: string, directory: string): boolean { + try { + return pathsEqual(path.resolve(entry), path.resolve(directory)); + } catch { + return false; + } +} + function pathContainsDir(pathValue: string | null | undefined, dir: string | null): boolean { if (!dir) return false; - const resolved = process.platform === "win32" ? path.resolve(dir).toLowerCase() : path.resolve(dir); - return splitPathEntries(pathValue).some((entry) => { - try { - const candidate = process.platform === "win32" ? path.resolve(entry).toLowerCase() : path.resolve(entry); - return candidate === resolved; - } catch { - return false; - } - }); + return splitPathEntries(pathValue).some((entry) => pathEntriesEqual(entry, dir)); +} + +function removePathDir(pathValue: string | null | undefined, dir: string | null | undefined): string | undefined { + const trimmedDir = dir?.trim(); + if (pathValue == null || !trimmedDir) return pathValue ?? undefined; + const remaining = splitPathEntries(pathValue).filter((entry) => !pathEntriesEqual(entry, trimmedDir)); + return remaining.join(pathDelimiter()); } function prependPathDir(pathValue: string | null | undefined, dir: string | null): string | undefined { @@ -449,6 +457,19 @@ function resolveCliPaths(args: CreateAdeCliServiceArgs, commandName: string): Re }; } + // A packaged app must only advertise a CLI shipped with that app. Falling + // back to a development checkout can make a broken package appear healthy + // and hand agents a command path that does not exist on the user's machine. + if (args.isPackaged) { + return { + commandPath: null, + binDir: null, + installerPath: null, + cliJsPath: null, + source: "missing", + }; + } + const devCli = resolveDevCliEntry(args.devRepoRoot); if (devCli) { const shim = writeDevShim({ @@ -610,10 +631,23 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { const agentEnv = (baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => { const next: NodeJS.ProcessEnv = { ...baseEnv }; - const nextPath = prependPathDir(getPathEnvValue(next), resolved.binDir); - if (nextPath) setPathEnvValue(next, nextPath); - if (resolved.commandPath) next.ADE_CLI_PATH = resolved.commandPath; - if (resolved.binDir) next.ADE_CLI_BIN_DIR = resolved.binDir; + if (resolved.commandPath) { + const nextPath = prependPathDir(getPathEnvValue(next), resolved.binDir); + if (nextPath) setPathEnvValue(next, nextPath); + next.ADE_CLI_PATH = resolved.commandPath; + if (resolved.binDir) next.ADE_CLI_BIN_DIR = resolved.binDir; + } else { + // A missing bundled CLI must not inherit an older CLI location through + // explicit resolver variables or PATH. Otherwise a downstream shell can + // silently select a different channel/version after ADE cleared only the + // direct executable path. + const inheritedBinDir = next.ADE_CLI_BIN_DIR; + const pathWithoutInheritedCli = removePathDir(getPathEnvValue(next), inheritedBinDir); + if (pathWithoutInheritedCli !== undefined) setPathEnvValue(next, pathWithoutInheritedCli); + delete next.ADE_CLI_PATH; + delete next.ADE_CLI_BIN_DIR; + delete next.ADE_CLI_ENTRY_PATH; + } next[ADE_AGENT_SKILLS_DIRS_ENV] = prependAgentSkillsRoot(next[ADE_AGENT_SKILLS_DIRS_ENV], bundledAgentSkillsRoot); if (bundledAgentSkillsRoot) { next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV] = bundledAgentSkillsRoot; @@ -626,9 +660,18 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { const applyToProcessEnv = (): void => { const next = agentEnv(process.env); const nextPath = getPathEnvValue(next); - if (nextPath) setPathEnvValue(process.env, nextPath); + if (nextPath !== undefined) setPathEnvValue(process.env, nextPath); + else { + for (const key of Object.keys(process.env)) { + if (key.toLowerCase() === "path") delete process.env[key]; + } + } if (next.ADE_CLI_PATH) process.env.ADE_CLI_PATH = next.ADE_CLI_PATH; + else delete process.env.ADE_CLI_PATH; if (next.ADE_CLI_BIN_DIR) process.env.ADE_CLI_BIN_DIR = next.ADE_CLI_BIN_DIR; + else delete process.env.ADE_CLI_BIN_DIR; + if (next.ADE_CLI_ENTRY_PATH) process.env.ADE_CLI_ENTRY_PATH = next.ADE_CLI_ENTRY_PATH; + else delete process.env.ADE_CLI_ENTRY_PATH; if (next[ADE_AGENT_SKILLS_DIRS_ENV]) process.env[ADE_AGENT_SKILLS_DIRS_ENV] = next[ADE_AGENT_SKILLS_DIRS_ENV]; if (next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]) { process.env[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV] = next[ADE_BUNDLED_AGENT_SKILLS_DIR_ENV]; diff --git a/apps/desktop/src/main/services/opencode/openCodeAdeInstructions.ts b/apps/desktop/src/main/services/opencode/openCodeAdeInstructions.ts index 281e02d194..2f4deb0f68 100644 Binary files a/apps/desktop/src/main/services/opencode/openCodeAdeInstructions.ts and b/apps/desktop/src/main/services/opencode/openCodeAdeInstructions.ts differ diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts index 2da51aa462..67d7340277 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts @@ -845,6 +845,35 @@ describe("ADE instructions for the tracked OpenCode CLI", () => { expect(plan).not.toContain("Autonomous mode."); }); + it("includes tracked CLI activity only when ADE supplies shell guidance", () => { + const activityGuidance = 'Use "$ADE_CLI_PATH" chat activity testing for this session.'; + const withActivity = buildOpenCodeAdeInstructions({ + laneWorktreePath: lane, + permissionMode: "edit", + sessionActivityGuidance: activityGuidance, + }); + const withoutActivity = buildOpenCodeAdeInstructions({ laneWorktreePath: lane, permissionMode: "plan" }); + + expect(withActivity).toContain("## Session activity"); + expect(withActivity).toContain(activityGuidance); + expect(withoutActivity).not.toContain("## Session activity"); + }); + + it("keeps activity and non-activity instruction files separate", () => { + const basePath = openCodeAdeInstructionsPath({ + projectRoot, + laneWorktreePath: lane, + permissionMode: "edit", + }); + const activityPath = openCodeAdeInstructionsPath({ + projectRoot, + laneWorktreePath: lane, + permissionMode: "edit", + sessionActivityEnabled: true, + }); + expect(activityPath).not.toBe(basePath); + }); + it("writes into ADE's own cache, never the lane worktree or a shared temp dir", () => { const written = ensureOpenCodeAdeInstructionsFile({ projectRoot, laneWorktreePath: lane, permissionMode: "edit" }); @@ -888,7 +917,7 @@ describe("ADE instructions for the tracked OpenCode CLI", () => { laneWorktreePath: "/repo/lane with spaces/*/weird?", permissionMode: "edit", })); - expect(name).toMatch(/^ade-[0-9a-f]{16}\.md$/); + expect(name).toMatch(/^ade-[0-9a-f]{16}(?:-activity)?\.md$/); }); it("does not claim an ADE permission tier when the user owns the config", () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index c8e23eb52d..52cbe7191f 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import type { IPty } from "node-pty"; import type * as TerminalSessionSignals from "../../utils/terminalSessionSignals"; import { buildOpenCodeReplayResumeCommand as buildCanonicalOpenCodeReplayResumeCommand } from "../../../shared/cliLaunch"; +import { canonicalSessionState } from "../../../shared/sessionCanonicalState"; import { parseCommandLine } from "../../../shared/shell"; import { isPtySendPreDeliveryError } from "../../../shared/types"; import { expectNoJargon } from "../../../test/jargonGuard"; @@ -425,6 +426,8 @@ function createHarness(overrides: { canPerform: ReturnType; } | null; getAdeCliAgentEnv?: (env?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + runtimeSocketPath?: string | null; + sessionActivityReportingEnabled?: boolean; projectConfigService?: { get: ReturnType; }; @@ -497,6 +500,20 @@ function createHarness(overrides: { session.lastOutputAt = at; if (opts?.clearSettled !== false) session.settledAt = null; }), + setSessionActivity: vi.fn((sessionId: string, value: string | null) => { + const session = sessionStore.get(sessionId); + if (!session) return false; + session.activityStatus = value === null + ? null + : { value, source: "agent", updatedAt: new Date().toISOString() }; + return true; + }), + clearSessionActivity: vi.fn((sessionId: string) => { + const session = sessionStore.get(sessionId); + if (!session) return false; + session.activityStatus = null; + return true; + }), settleSession: vi.fn((sessionId: string, opts?: { settledAt?: string }) => { const session = sessionStore.get(sessionId); if (!session) return false; @@ -572,6 +589,10 @@ function createHarness(overrides: { ...(overrides.aiIntegrationService ? { aiIntegrationService: overrides.aiIntegrationService as any } : {}), ...(overrides.diskPressureMonitor !== undefined ? { diskPressureMonitor: overrides.diskPressureMonitor as any } : {}), ...(overrides.getAdeCliAgentEnv ? { getAdeCliAgentEnv: overrides.getAdeCliAgentEnv } : {}), + ...(overrides.runtimeSocketPath !== undefined ? { runtimeSocketPath: overrides.runtimeSocketPath } : {}), + ...(overrides.sessionActivityReportingEnabled !== undefined + ? { sessionActivityReportingEnabled: overrides.sessionActivityReportingEnabled } + : {}), ...(overrides.projectConfigService ? { projectConfigService: overrides.projectConfigService as any } : {}), ...(overrides.browserActorCapabilityIssuer ? { browserActorCapabilityIssuer: overrides.browserActorCapabilityIssuer } @@ -1580,6 +1601,78 @@ describe("ptyService", () => { expect(config.permission).toEqual({ edit: "allow" }); }); + it("enables OpenCode activity guidance only with ADE CLI and a writable launch mode", async () => { + const { service, loadPty } = createHarness({ + runtimeSocketPath: "/runtime/beta.sock", + getAdeCliAgentEnv: (env = {}) => ({ + ...env, + ADE_CLI_PATH: "/runtime/ade", + ADE_RUNTIME_SOCKET_PATH: "/runtime/stable.sock", + ADE_RPC_SOCKET_PATH: "/runtime/stable.sock", + ADE_RPC_URL: "/runtime/stable.sock", + }), + }); + + const created = await service.create({ + laneId: "lane-1", + title: "OpenCode activity", + cols: 80, + rows: 24, + toolType: "opencode", + tracked: true, + runtimeCliLaunch: { provider: "opencode", permissionMode: "full-auto" }, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const opts = ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + const config = JSON.parse(opts?.env?.OPENCODE_CONFIG_CONTENT ?? "{}") as { instructions?: string[] }; + const instructions = fs.readFileSync(config.instructions![0]!, "utf8"); + + expect(opts?.env).toMatchObject({ + ADE_CLI_PATH: "/runtime/ade", + ADE_RPC_URL: "/runtime/beta.sock", + ADE_RPC_SOCKET_PATH: "/runtime/beta.sock", + ADE_RUNTIME_SOCKET_PATH: "/runtime/beta.sock", + ADE_CHAT_SESSION_ID: created.sessionId, + ADE_ACTIVITY_SESSION_ID: created.sessionId, + }); + expect(config.instructions?.[0]).toMatch(/-activity\.md$/); + expect(instructions).toContain('"$ADE_CLI_PATH" chat activity testing'); + expect(instructions).toContain("ADE scopes this command to the tracked terminal row"); + }); + + it("withholds activity guidance when the runtime cannot accept RPC reports", async () => { + const { service, loadPty } = createHarness({ + runtimeSocketPath: "/runtime/unserved.sock", + sessionActivityReportingEnabled: false, + getAdeCliAgentEnv: (env = {}) => ({ + ...env, + ADE_CLI_PATH: "/runtime/ade", + ADE_RUNTIME_SOCKET_PATH: "/runtime/stable.sock", + ADE_RPC_SOCKET_PATH: "/runtime/stable.sock", + ADE_RPC_URL: "/runtime/stable.sock", + }), + }); + + await service.create({ + laneId: "lane-1", + title: "Embedded OpenCode", + cols: 80, + rows: 24, + toolType: "opencode", + tracked: true, + runtimeCliLaunch: { provider: "opencode", permissionMode: "full-auto" }, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const opts = ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + const config = JSON.parse(opts?.env?.OPENCODE_CONFIG_CONTENT ?? "{}") as { instructions?: string[] }; + const instructions = fs.readFileSync(config.instructions![0]!, "utf8"); + + expect(opts?.env).not.toHaveProperty("ADE_ACTIVITY_SESSION_ID"); + expect(instructions).not.toContain("chat activity testing"); + }); + it("resumes an OpenCode session with the permission mode it was launched under", async () => { const { service, loadPty, sessionService } = createHarness(); @@ -7070,6 +7163,41 @@ describe("ptyService", () => { expect(enriched[0]).toMatchObject({ id: sessionId, runtimeState: "running", extra: "data" }); }); + it("does not infer card statuses from TUI text and preserves explicit ADE asks", async () => { + const { service, mockPty, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + tracked: true, + }); + + mockPty._emitter.emit( + "data", + "⏸ plan mode on\nDo you want to proceed? (y/n)\n", + ); + + const row = sessionService.get(sessionId); + const [fromTuiText] = service.enrichSessions([row] as any); + expect(fromTuiText).not.toHaveProperty("chatActivityMode"); + expect(fromTuiText).not.toHaveProperty("attentionSource"); + expect(fromTuiText.runtimeState).toBe("running"); + expect(canonicalSessionState(fromTuiText).phase).toBe("running"); + + row.attentionRequestedAt = new Date().toISOString(); + row.attentionMessage = "What should I do next?"; + expect(service.setSessionRuntimeState(sessionId, "waiting-input")).toBe(true); + + const [fromExplicitAsk] = service.enrichSessions([row] as any); + expect(fromExplicitAsk).toMatchObject({ + attentionRequestedAt: row.attentionRequestedAt, + runtimeState: "waiting-input", + }); + expect(canonicalSessionState(fromExplicitAsk).phase).toBe("needs_you"); + }); + it("overlays live PTY attachment when a persisted row drifted to ended", async () => { const { service, sessionService } = createHarness(); const { ptyId, sessionId } = await service.create({ @@ -9458,6 +9586,20 @@ describe("ptyService", () => { .filter((s) => (args.laneId ? s.laneId === args.laneId : true)) .slice(0, args.limit ?? all.length); }), + setSessionActivity: vi.fn((sessionId: string, value: string | null) => { + const session = sessionStore.get(sessionId); + if (!session) return false; + session.activityStatus = value === null + ? null + : { value, source: "agent", updatedAt: new Date().toISOString() }; + return true; + }), + clearSessionActivity: vi.fn((sessionId: string) => { + const session = sessionStore.get(sessionId); + if (!session) return false; + session.activityStatus = null; + return true; + }), setChatSessionId: vi.fn((sessionId: string, chatSessionId: string | null) => { const s = sessionStore.get(sessionId); if (s) s.chatSessionId = chatSessionId; diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 95150213b7..c6b78e0baa 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -29,6 +29,7 @@ import { import { runGit } from "../git/git"; import { resolveOpenCodeBinaryPath } from "../opencode/openCodeBinaryManager"; import { ensureOpenCodeAdeInstructionsFile } from "../opencode/openCodeAdeInstructions"; +import { SESSION_ACTIVITY_SESSION_ID_ENV } from "../../../shared/sessionActivity"; import { acquirePiSessionLease, piSessionCreationLeaseTarget, @@ -113,6 +114,7 @@ import { import { CURSOR_CLI_EXECUTABLES } from "../../../shared/providerCliExecutables"; import { buildOpenCodeReplayResumeLaunchCommand, + buildTrackedCliSessionActivityGuidance, buildTrackedCliLaunchCommand, buildTrackedCliResumeLaunchCommand, claudeArgsResumeExistingSession, @@ -136,6 +138,7 @@ import { resolveWindowsShellLaunchFields, resolveWindowsShellKind, } from "../../../shared/cliLaunch"; +import { buildAdeRuntimeSocketEnv } from "../../../shared/adeCliGuidance"; import { resolveTrackedCliPreset } from "../chat/harnessPresetLaunch"; import { commandArrayToLine, @@ -156,14 +159,6 @@ import { summarizeTerminalSession } from "../../utils/sessionSummary"; import { derivePreviewFromChunk, type PreviewCursorState } from "../../utils/terminalPreview"; import { claudeConfigHome, codexConfigHome, factoryConfigHome, kimiCodeConfigHome } from "../shared/providerConfigHomes"; import { checkKimiWindowsPrerequisites } from "../ai/acpExecutables"; -import { - clearTuiWaitingInput, - createTuiMarkerState, - scanTuiMarkers, - tuiActivityFromState, - type TerminalTuiActivity, - type TuiMarkerState, -} from "../../utils/terminalTuiMarkers"; import { buildOpenCodeReplayResumeCommand, buildTrackedCliResumeCommand, @@ -204,6 +199,7 @@ function normalizeStartupCommandDelayMs(value: unknown): number { export function materializeRuntimeCliLaunch( runtimeCliLaunch: NonNullable, laneWorktreePath: string, + options: { sessionActivityReportingEnabled?: boolean } = {}, ): TrackedCliLaunchCommand { const provider = String(runtimeCliLaunch.provider); if (!isLaunchProfile(provider) || provider === "shell") { @@ -231,6 +227,7 @@ export function materializeRuntimeCliLaunch( ...(trackedPreset.model ? { model: trackedPreset.model } : {}), } : {}), + sessionActivityReportingEnabled: options.sessionActivityReportingEnabled, }); } @@ -313,7 +310,6 @@ const AGENT_CLI_LINE_SUBMIT_KEY = "\r"; * new, outranks it. An explicit settle is a standing instruction, not a * timestamp to be beaten by the next repaint. */ -const NEVER_WAITING = Number.POSITIVE_INFINITY; const AGENT_CLI_SUBMIT_DELAY_MS = 25; const CODEX_CLI_PASTE_SUBMIT_DELAY_MS = 180; const CURSOR_CLI_PASTE_SUBMIT_DELAY_MS = 500; @@ -766,8 +762,6 @@ type PtyEntry = { previewCursor: PreviewCursorState | null; latestPreviewLine: string | null; lastPreviewWritten: string | null; - /** Per-provider TUI marker scan state; null for shells and unknown CLIs. */ - tuiMarkers: TuiMarkerState | null; toolTypeHint: TerminalToolType | null; resumeCommand: string | null; resumeCommandIsFallback: boolean; @@ -2181,6 +2175,8 @@ export function createPtyService({ getLaneRuntimeEnv, getSessionLinearEnv, getAdeCliAgentEnv, + runtimeSocketPath, + sessionActivityReportingEnabled, logger, broadcastData, broadcastExit, @@ -2208,6 +2204,10 @@ export function createPtyService({ */ getSessionLinearEnv?: (args: { sessionId: string; chatSessionId: string | null }) => Record | null; getAdeCliAgentEnv?: (baseEnv?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + /** Exact RPC endpoint served by this runtime; absent endpoints disable activity reporting. */ + runtimeSocketPath?: string | null; + /** Embedded runtimes without a served RPC socket cannot accept agent activity reports. */ + sessionActivityReportingEnabled?: boolean; logger: Logger; broadcastData: (ev: PtyDataEvent) => void; broadcastExit: (ev: PtyExitEvent) => void; @@ -5600,29 +5600,6 @@ export function createPtyService({ * payloads can contain newlines anywhere but never end in one (they end with * the paste-end sequence), so multi-line prompt text does not count. */ - /** - * The row fields a TUI-derived activity contributes, as one spread. - * - * `attentionSource: "provider_structured"` is a MISLABEL and known to be one: - * this activity comes from regex-scanning a PTY stream for prompt shapes, - * while that value is supposed to mean the provider told us it is blocked. - * It cannot simply be dropped, because it is currently load-bearing — - * `canonicalSessionState` derives the `needs_you` phase from - * `pendingInputItemId | attentionRequestedAt | provider_structured` and - * ignores `runtimeState: "waiting-input"` entirely, so removing the label - * would take the badge away AND make the row unsettleable (`SessionStatusSlot` - * only offers Settle on a needs_you row it can dismiss). Fixing it properly - * means teaching the canonical layer a heuristic waiting tier, which is a - * shared-contract change across all five surfaces rather than a rename here. - */ - const tuiRowOverlay = (activity: TerminalTuiActivity) => ( - activity === "planning" - ? { chatActivityMode: "planning" as const } - : activity === "waiting-input" - ? { attentionSource: "provider_structured" as const } - : {} - ); - const isTurnSubmitWrite = (data: string): boolean => data.endsWith("\r") || data.endsWith("\n"); @@ -5648,8 +5625,6 @@ export function createPtyService({ } entry.lastUserInputAt = Date.now(); entry.userInputGeneration += 1; - // Whatever prompt the TUI was blocked on, the user just answered it. - clearTuiWaitingInput(entry.tuiMarkers); if (entry.tracked && isTrackedAgentCliToolType(entry.toolTypeHint)) { clearTrackedCliTurnStartMarkers(entry.sessionId); entry.attentionRequested = false; @@ -5662,6 +5637,16 @@ export function createPtyService({ } }; + const clearCommittedCliActivity = (entry: PtyEntry, data: string): void => { + if ( + entry.tracked + && isTrackedAgentCliToolType(entry.toolTypeHint) + && isTurnSubmitWrite(data) + ) { + sessionService.clearSessionActivity(entry.sessionId); + } + }; + const service = { async ensureResumeTargets(sessionIds: string[]): Promise { const uniqueSessionIds = Array.from(new Set( @@ -5712,7 +5697,10 @@ export function createPtyService({ const { cols, rows } = clampDims(args.cols, args.rows); const runtimeCliLaunch = args.runtimeCliLaunch; const materializedRuntimeLaunch = runtimeCliLaunch - ? materializeRuntimeCliLaunch(runtimeCliLaunch, worktreePath) + ? materializeRuntimeCliLaunch(runtimeCliLaunch, worktreePath, { + sessionActivityReportingEnabled: + sessionActivityReportingEnabled !== false && Boolean(runtimeSocketPath?.trim()), + }) : null; let effectiveArgs: PtyCreateArgs = materializedRuntimeLaunch ? { @@ -6094,6 +6082,16 @@ export function createPtyService({ ); if (isTrackedAgentCliToolType(toolTypeHint)) { launchEnv.ADE_DEFAULT_ROLE = "agent"; + const activitySocketPath = sessionActivityReportingEnabled !== false + ? runtimeSocketPath?.trim() || null + : null; + if (tracked && activitySocketPath) { + launchEnv[SESSION_ACTIVITY_SESSION_ID_ENV] = sessionId; + Object.assign(launchEnv, buildAdeRuntimeSocketEnv(activitySocketPath)); + } + else delete launchEnv[SESSION_ACTIVITY_SESSION_ID_ENV]; + } else { + delete launchEnv[SESSION_ACTIVITY_SESSION_ID_ENV]; } launchEnv = withResolvedCliLaunchPath(launchEnv, { includeInteractiveShell: Boolean(directCommand || startupCommand), @@ -6319,13 +6317,24 @@ export function createPtyService({ // assignment silently dropped the ADE contract on exactly the resume // path this exists to cover. if (isOpenCodeToolType(toolTypeHint)) { + const openCodePermissionMode = args.runtimeCliLaunch?.permissionMode + ?? initialResumeMetadata?.launch?.permissionMode + ?? existingSession?.resumeMetadata?.launch?.permissionMode + ?? null; + const sessionActivityGuidance = tracked + && sessionActivityReportingEnabled !== false + && runtimeSocketPath?.trim() + && launchEnv.ADE_CLI_PATH?.trim() + ? buildTrackedCliSessionActivityGuidance({ + provider: "opencode", + permissionMode: openCodePermissionMode, + }) + : null; const instructionsPath = ensureOpenCodeAdeInstructionsFile({ projectRoot, laneWorktreePath: worktreePath, - permissionMode: args.runtimeCliLaunch?.permissionMode - ?? initialResumeMetadata?.launch?.permissionMode - ?? existingSession?.resumeMetadata?.launch?.permissionMode - ?? null, + permissionMode: openCodePermissionMode, + sessionActivityGuidance, }); const withInstructions = withOpenCodeAdeInstructions( { env: launchEnv as Record, startupCommand }, @@ -6494,11 +6503,6 @@ export function createPtyService({ previewCursor: null, latestPreviewLine: null, lastPreviewWritten: null, - // PTY-backed agent CLIs only. Chat-backed rows get richer states from - // the chat projection, and shells have no TUI to read. - tuiMarkers: tracked && isTrackedAgentCliToolType(toolTypeHint) - ? createTuiMarkerState(toolTypeHint) - : null, toolTypeHint, resumeCommand: initialResumeCommand, resumeCommandIsFallback: Boolean(initialResumeCommand), @@ -6683,11 +6687,6 @@ export function createPtyService({ updatePreviewThrottled(entry, data); enqueuePtyData(entry, { ptyId, sessionId, data }); - // Richer CLI states ride the same chunk the OSC 133 scan below reads: - // one bounded pass, no extra buffering, and nothing at all for shells - // and unrecognized CLIs (they have no marker pack). - if (entry.tuiMarkers) scanTuiMarkers(entry.tuiMarkers, { chunk: data }); - const prevState = runtimeStates.get(sessionId)?.state ?? "running"; const markerState = runtimeStateFromOsc133Chunk(data, prevState); const runtimeState = markerState === prevState && prevState === "idle" && data.length > 0 @@ -7303,6 +7302,9 @@ export function createPtyService({ // Wait until launch succeeds before clearing the previous turn's state. clearTrackedCliTurnStartMarkers(sessionId); if ((resumeFlightCreated && Boolean(openCodeReplayLaunch)) || promptAtLaunch) { + // This prompt was accepted as part of the launched command, so there + // will be no Enter write to clear its prior activity report. + sessionService.clearSessionActivity(sessionId); return buildSessionActionResult(created, { resumed: true, reusedExistingRuntime: false }); } @@ -7365,6 +7367,7 @@ export function createPtyService({ try { markPtyUserInput(entry, data); entry.pty.write(data); + clearCommittedCliActivity(entry, data); tryCliUserTitleFromWrite(entry, data); setRuntimeState(entry.sessionId, "running"); scheduleIdleTransition(entry.sessionId); @@ -7696,6 +7699,7 @@ export function createPtyService({ try { markPtyUserInput(entry, args.data); entry.pty.write(args.data); + clearCommittedCliActivity(entry, args.data); tryCliUserTitleFromWrite(entry, args.data); setRuntimeState(entry.sessionId, "running"); scheduleIdleTransition(entry.sessionId); @@ -7787,6 +7791,7 @@ export function createPtyService({ try { markPtyUserInput(entry, data); entry.pty.write(data); + clearCommittedCliActivity(entry, data); tryCliUserTitleFromWrite(entry, data); setRuntimeState(entry.sessionId, "running"); scheduleIdleTransition(entry.sessionId); @@ -8157,21 +8162,15 @@ export function createPtyService({ : idlePersistedChatRuntime ? "idle" : computeRuntimeState(row.id, fallbackStatus); - // The turn anchor and the TUI-derived states are emitted HERE, the one + // The turn anchor and host-derived lifecycle are emitted HERE, the one // chokepoint desktop, lane snapshots, web and iOS all read, so every - // surface tells the same story about a CLI session. + // surface tells the same story about a CLI session. PTY text is not + // parsed into semantic card states: host lifecycle drives liveness, + // while an explicit ADE request can mark the CLI as needing input. const liveEntry = live && !isDetachedFromThisRuntime ? live[1] : null; const runningSince = liveEntry && runtimeState === "running" ? runtimeStates.get(row.id)?.runningSince ?? null : null; - const settledAtMs = row.settledAt ? Date.parse(row.settledAt) : Number.NaN; - const tuiActivity = liveEntry - ? tuiActivityFromState(liveEntry.tuiMarkers, { - waitingFloorMs: row.settleOverride === "settled" - ? NEVER_WAITING - : Number.isFinite(settledAtMs) ? settledAtMs : null, - }) - : null; return { ...row, ...(live @@ -8192,8 +8191,7 @@ export function createPtyService({ ...(isPersistedChatToolType(row.toolType ?? null) ? {} : { currentTurnStartedAt: runningSince ? new Date(runningSince).toISOString() : null }), - ...tuiRowOverlay(tuiActivity), - runtimeState: tuiActivity === "waiting-input" ? "waiting-input" : runtimeState, + runtimeState, chatSessionId: live ? terminalChatSessions.get(row.id) ?? live[1].chatSessionId ?? row.chatSessionId ?? null : terminalChatSessions.get(row.id) ?? row.chatSessionId ?? null, diff --git a/apps/desktop/src/main/services/sessions/chatSessionProjection.test.ts b/apps/desktop/src/main/services/sessions/chatSessionProjection.test.ts index 6186015116..3cbc31ae19 100644 --- a/apps/desktop/src/main/services/sessions/chatSessionProjection.test.ts +++ b/apps/desktop/src/main/services/sessions/chatSessionProjection.test.ts @@ -84,6 +84,7 @@ describe("chatSessionProjection", () => { it("projects current plan mode without changing idle chat lifecycle", () => { const projected = projectChatOntoSession(session(), chat({ + provider: "claude", interactionMode: "plan", permissionMode: "plan", })); @@ -93,6 +94,78 @@ describe("chatSessionProjection", () => { expect(projected.activeBackgroundTaskCount).toBe(0); }); + it("projects Planning from exact provider-reported current modes", () => { + const planningChats: Array> = [ + { provider: "claude", interactionMode: "plan" }, + { provider: "codex", codexEffectiveCollaborationMode: "plan" }, + { provider: "cursor", cursorModeId: "plan" }, + { provider: "cursor", cursorModeId: "plan", cursorModeSnapshot: { currentModeId: "agent", availableModeIds: ["agent", "plan"] } }, + { provider: "cursor", cursorModeSnapshot: { + currentModeId: "plan", + availableModeIds: ["agent", "plan"], + } }, + { provider: "droid", interactionMode: "plan" }, + { provider: "opencode", opencodePermissionMode: "plan" }, + { provider: "qwen", acpConfigSnapshot: { currentModeId: "plan" } }, + { provider: "kimi", acpConfigSnapshot: { + configOptions: [{ + id: "mode", + name: "Mode", + type: "select", + currentValue: "plan", + options: [{ value: "default", label: "Default" }, { value: "plan", label: "Plan" }], + }], + } }, + { provider: "copilot", acpConfigSnapshot: { + currentModeId: "https://agentclientprotocol.com/protocol/session-modes#plan", + } }, + ]; + + for (const currentMode of planningChats) { + expect(projectChatOntoSession(session(), chat(currentMode)).chatActivityMode) + .toBe("planning"); + } + }); + + it("does not infer Planning from legacy permission labels, available modes, or Grok", () => { + const nonPlanningChats: Array> = [ + { provider: "codex", permissionMode: "plan", interactionMode: "plan" }, + { provider: "codex", codexEffectiveCollaborationMode: "default", permissionMode: "plan" }, + { + provider: "codex", + codexConfigSource: "flags", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + }, + { + provider: "codex", + codexConfigSource: "config-toml", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + }, + { provider: "cursor", cursorModeId: "agent", interactionMode: "plan" }, + { provider: "cursor", cursorModeId: "agent", cursorModeSnapshot: { currentModeId: "plan", availableModeIds: ["agent", "plan"] } }, + { provider: "cursor", cursorModeId: null, cursorModeSnapshot: { currentModeId: "plan", availableModeIds: ["agent", "plan"] } }, + { provider: "cursor", cursorModeIdWasCleared: true, cursorModeSnapshot: { currentModeId: "plan", availableModeIds: ["agent", "plan"] } }, + { provider: "droid", droidPermissionMode: "read-only" }, + { provider: "droid", permissionMode: "plan" }, + { provider: "opencode", permissionMode: "plan", interactionMode: "plan" }, + { provider: "qwen", acpConfigSnapshot: { + currentModeId: "default", + availableModeIds: ["default", "plan"], + } }, + { provider: "kimi", acpConfigSnapshot: { + configOptions: [{ id: "mode", name: "Mode", type: "boolean", currentValue: true }], + } }, + { provider: "grok", acpConfigSnapshot: { currentModeId: "plan" } }, + ]; + + for (const currentMode of nonPlanningChats) { + expect(projectChatOntoSession(session(), chat(currentMode)).chatActivityMode) + .toBeNull(); + } + }); + it("projects authoritative background count and the next armed wake", () => { const projected = projectChatOntoSession(session(), chat({ activeBackgroundTaskCount: 2, diff --git a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts index 78967dfbea..3e5e8e35af 100644 --- a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts +++ b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts @@ -45,6 +45,56 @@ export function isChatToolType(toolType: string | null | undefined): boolean { return normalized === "cursor" || normalized.endsWith("-chat"); } +const COPILOT_PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan"; + +function acpCurrentModeId(chat: AgentChatSessionSummary): string | null { + const value = chat.acpConfigSnapshot?.currentModeId + ?? chat.acpConfigSnapshot?.configOptions?.find((option) => option.id === "mode")?.currentValue; + // ACP config values can also be booleans or numbers; only strings are mode ids. + return typeof value === "string" ? value : null; +} + +/** + * Read Planning only from the provider's current structured mode. Permission + * labels and prompt text are not evidence that a provider is in plan mode. + */ +function chatIsPlanning(chat: AgentChatSessionSummary): boolean { + switch (chat.provider) { + case "claude": + return chat.interactionMode === "plan"; + case "codex": + // Permission policy does not prove Plan. The Codex turn path stamps the + // collaboration mode only after the app-server accepts turn/start. + return chat.codexEffectiveCollaborationMode === "plan"; + case "cursor": { + // A provider mode snapshot can lag the explicit session mode during a + // transition. Only fall back to it when the host has not reported an id. + if (chat.cursorModeIdWasCleared || chat.cursorModeId !== undefined) { + return chat.cursorModeId === "plan"; + } + return chat.cursorModeSnapshot?.currentModeId === "plan"; + } + case "droid": + // read-only is Droid's permission posture. The SDK's native Spec mode is + // represented by the explicit interactionMode ADE sends to the SDK. + return chat.interactionMode === "plan"; + case "opencode": + return chat.opencodePermissionMode === "plan"; + case "qwen": + case "kimi": { + return acpCurrentModeId(chat) === "plan"; + } + case "copilot": + return acpCurrentModeId(chat) === COPILOT_PLAN_MODE_ID; + // Grok launches with native plan mode disabled because that mode hangs + // external hosts. Pi has no provider-native current-mode signal. + case "grok": + case "pi": + default: + return false; + } +} + /** * Persisted chat rows stay "running" so they remain resumable across provider * restarts. If chat-state projection is unavailable, treat that storage state @@ -90,7 +140,7 @@ export function projectChatOntoSession( nextWakeAt: chat.nextWakeAt, usageLimitParkedUntil: chat.usageLimitParkedUntil ?? null, usageLimitResume: chat.usageLimitResume ?? null, - chatActivityMode: chat.interactionMode === "plan" ? "planning" : null, + chatActivityMode: chatIsPlanning(chat) ? "planning" : null, activeBackgroundTaskCount: chat.activeBackgroundTaskCount ?? 0, ...(chat.backgroundWork ? { backgroundWork: chat.backgroundWork } : {}), ...(chat.backgroundWorkSince ? { backgroundWorkSince: chat.backgroundWorkSince } : {}), diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 8659e08d40..1a6091205d 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { gzipSync } from "node:zlib"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { openKvDb } from "../state/kvDb"; import { createSessionService } from "./sessionService"; @@ -59,6 +59,7 @@ function insertProjectGraph(db: Awaited>) { const activeDisposers: Array<() => Promise> = []; afterEach(async () => { + vi.useRealTimers(); while (activeDisposers.length > 0) { const dispose = activeDisposers.pop(); if (dispose) await dispose(); @@ -1589,7 +1590,7 @@ describe("sessionService resume metadata", () => { })); }); - it("normalizes status and attention text and clears turn-start markers", async () => { + it("normalizes status and attention text and clears turn-start markers separately from agent activity", async () => { const projectRoot = makeProjectRoot("ade-session-service-markers-"); const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); activeDisposers.push(async () => db.close()); @@ -1629,6 +1630,27 @@ describe("sessionService resume metadata", () => { service.setStatusNote("session-markers", " "); expect(service.get("session-markers")?.statusNote).toBeNull(); + const changedEvents: string[] = []; + service.onChanged((event) => changedEvents.push(`${event.reason}:${event.sessionId}`)); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-17T00:30:00.000Z")); + expect(service.setSessionActivity("session-markers", "testing")).toBe(true); + const activityReport = service.get("session-markers")?.activityStatus; + const activityChangedAt = service.get("session-markers")?.activityStatusChangedAt; + expect(activityReport).toMatchObject({ value: "testing", source: "agent" }); + expect(Date.parse(activityReport?.updatedAt ?? "")).not.toBeNaN(); + expect(activityChangedAt).toBe(activityReport?.updatedAt); + expect(db.get<{ activityStatusJson: string; activityStatusChangedAt: string }>( + "select activity_status_json as activityStatusJson, activity_status_changed_at as activityStatusChangedAt from terminal_sessions where id = ?", + ["session-markers"], + )).toEqual({ + activityStatusJson: JSON.stringify(activityReport), + activityStatusChangedAt: activityReport?.updatedAt, + }); + expect(changedEvents).toEqual(["meta-updated:session-markers"]); + expect(() => service.setSessionActivity("session-markers", "inventing")) + .toThrow(/supported activity value or null/i); + await service.settleSession("session-markers", { settledAt: "2026-03-17T01:00:00.000Z", outcome: "Completed fixes and waiting for release review now", @@ -1666,7 +1688,62 @@ describe("sessionService resume metadata", () => { attentionRequestedAt: null, attentionMessage: null, lastTurnFailedAt: null, + activityStatus: expect.objectContaining({ value: "testing" }), })); + const eventCountBeforeActivityClear = changedEvents.length; + vi.setSystemTime(new Date("2026-03-17T00:30:01.000Z")); + service.clearSessionActivity("session-markers"); + expect(service.get("session-markers")?.activityStatus).toBeNull(); + const clearChangedAt = service.get("session-markers")?.activityStatusChangedAt; + expect(Date.parse(clearChangedAt ?? "")).toBeGreaterThan(Date.parse(activityChangedAt ?? "")); + expect(db.get<{ activityStatusJson: string | null; activityStatusChangedAt: string }>( + "select activity_status_json as activityStatusJson, activity_status_changed_at as activityStatusChangedAt from terminal_sessions where id = ?", + ["session-markers"], + )).toEqual({ activityStatusJson: null, activityStatusChangedAt: clearChangedAt }); + expect(changedEvents).toHaveLength(eventCountBeforeActivityClear + 1); + expect(changedEvents.at(-1)).toBe("meta-updated:session-markers"); + vi.useRealTimers(); + }); + + it("keeps chat activity on the chat row and attached CLI activity on its terminal row", async () => { + const projectRoot = makeProjectRoot("ade-session-activity-projection-"); + const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); + activeDisposers.push(async () => db.close()); + insertProjectGraph(db); + const service = createSessionService({ db }); + service.create({ + sessionId: "chat-owner", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Chat owner", + startedAt: "2026-03-17T00:10:00.000Z", + transcriptPath: "/tmp/chat-owner.log", + toolType: "codex-chat", + chatSessionId: "chat-owner", + }); + service.create({ + sessionId: "attached-terminal", + laneId: "lane-1", + ptyId: "pty-attached", + tracked: true, + title: "Attached terminal", + startedAt: "2026-03-17T00:11:00.000Z", + transcriptPath: "/tmp/attached-terminal.log", + toolType: "codex", + chatSessionId: "chat-owner", + }); + + expect(service.setSessionActivity("chat-owner", "planning")).toBe(true); + expect(service.setSessionActivity("attached-terminal", "testing")).toBe(true); + expect(service.get("chat-owner")?.activityStatus?.value).toBe("planning"); + expect(service.get("attached-terminal")?.activityStatus?.value).toBe("testing"); + expect(service.getByChatSessionId("chat-owner")?.id).toBe("chat-owner"); + expect(service.getByChatSessionId("chat-owner")?.activityStatus?.value).toBe("planning"); + + service.clearSessionActivity("attached-terminal"); + expect(service.get("attached-terminal")?.activityStatus).toBeNull(); + expect(service.get("chat-owner")?.activityStatus?.value).toBe("planning"); }); it("lets PTY callers preserve agent settlement while ordinary output clears it", async () => { diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 2a6b8475e1..71d00ed86f 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -23,6 +23,7 @@ import type { UpdateSessionMetaArgs, } from "../../../shared/types"; import { normalizeSessionStatusNote } from "../../../shared/sessionStatusNote"; +import { isSessionActivityValue, normalizeSessionActivityReport } from "../../../shared/sessionActivity"; import { isTrackedAgentCliToolType, parseSessionSettleOverride, @@ -60,6 +61,8 @@ type SessionRow = { archivedAt: string | null; settledAt: string | null; statusNote: string | null; + activityStatusJson: string | null; + activityStatusChangedAt: string | null; attentionRequestedAt: string | null; attentionMessage: string | null; attentionSource: string | null; @@ -132,6 +135,8 @@ const SESSION_COLUMNS = ` s.archived_at as archivedAt, s.settled_at as settledAt, s.status_note as statusNote, + s.activity_status_json as activityStatusJson, + s.activity_status_changed_at as activityStatusChangedAt, s.attention_requested_at as attentionRequestedAt, s.attention_message as attentionMessage, s.attention_source as attentionSource, @@ -481,6 +486,26 @@ export function createSessionService({ return true; }; + const writeSessionActivity = (sessionId: string, value: unknown): boolean => { + if (value !== null && !isSessionActivityValue(value)) { + throw new Error("setSessionActivity requires a supported activity value or null."); + } + return mutateSessionMeta(sessionId, (id) => { + const changedAt = new Date().toISOString(); + const report = value === null + ? null + : { + value, + source: "agent" as const, + updatedAt: changedAt, + }; + db.run( + "update terminal_sessions set activity_status_json = ?, activity_status_changed_at = ? where id = ?", + [report ? JSON.stringify(report) : null, changedAt, id], + ); + }); + }; + /** * Move a session row back to `running`, restricted to the given scope. * @@ -693,6 +718,8 @@ export function createSessionService({ }; const mapRow = (row: SessionRow) => { + const { activityStatusJson, ...summaryRow } = row; + const activityStatus = normalizeSessionActivityReport(activityStatusJson); const toolType = inferToolTypeFromResumeCommand( normalizeToolType(row.toolType), row.resumeCommand ?? null, @@ -706,7 +733,7 @@ export function createSessionService({ } } return { - ...row, + ...summaryRow, tracked: row.tracked === 1, pinned: row.pinned === 1, manuallyNamed: row.manuallyNamed === 1, @@ -720,6 +747,8 @@ export function createSessionService({ archivedAt: row.archivedAt ?? null, settledAt: normalizeIsoTimestamp(row.settledAt), statusNote: normalizeSessionStatusNote(row.statusNote), + activityStatus, + activityStatusChangedAt: normalizeIsoTimestamp(row.activityStatusChangedAt) ?? activityStatus?.updatedAt ?? null, attentionRequestedAt: normalizeIsoTimestamp(row.attentionRequestedAt), attentionMessage: normalizeOptionalText(row.attentionMessage, 500), attentionSource: normalizeAttentionSource(row.attentionSource), @@ -1450,6 +1479,24 @@ export function createSessionService({ return mapRow(row) as TerminalSessionDetail; }, + /** Resolve the chat's own row, falling back to its newest attached terminal for legacy chats. */ + getByChatSessionId(chatSessionId: string): TerminalSessionSummary | null { + const trimmedId = typeof chatSessionId === "string" ? chatSessionId.trim() : ""; + if (!trimmedId) return null; + const row = db.get( + ` + select ${SESSION_COLUMNS} + from terminal_sessions s + join lanes l on l.id = s.lane_id + where s.chat_session_id = ? + order by case when s.id = ? then 0 else 1 end, s.started_at desc + limit 1 + `, + [trimmedId, trimmedId], + ); + return row ? mapRow(row) as TerminalSessionSummary : null; + }, + updateMeta(args: UpdateSessionMetaArgs): TerminalSessionSummary | null { const sessionId = typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; if (!sessionId) return null; @@ -2179,6 +2226,11 @@ export function createSessionService({ }); }, + /** Store one host-timestamped, fixed-value activity report for this session. */ + setSessionActivity(sessionId: string, value: unknown): boolean { + return writeSessionActivity(sessionId, value); + }, + getStatusNoteUpdatedAt(sessionId: string): string | null { return statusNoteUpdatedAtById.get(sessionId.trim()) ?? null; }, @@ -2246,6 +2298,11 @@ export function createSessionService({ }); }, + /** Clear an agent activity report at a real turn boundary, not on PTY keystrokes. */ + clearSessionActivity(sessionId: string): boolean { + return writeSessionActivity(sessionId, null); + }, + clearTurnStartMarkers(sessionId: string): boolean { const changed = mutateSessionMeta(sessionId, (id) => { writeSettleLifecycle({ diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index fb121fca10..37799b00c0 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2375,6 +2375,8 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { archived_at text, settled_at text, status_note text, + activity_status_json text, + activity_status_changed_at text, attention_requested_at text, attention_message text, attention_source text, @@ -2404,6 +2406,8 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { safeAddColumn(db, "alter table terminal_sessions add column archived_at text"); safeAddColumn(db, "alter table terminal_sessions add column settled_at text"); safeAddColumn(db, "alter table terminal_sessions add column status_note text"); + safeAddColumn(db, "alter table terminal_sessions add column activity_status_json text"); + safeAddColumn(db, "alter table terminal_sessions add column activity_status_changed_at text"); safeAddColumn(db, "alter table terminal_sessions add column attention_requested_at text"); safeAddColumn(db, "alter table terminal_sessions add column attention_message text"); safeAddColumn(db, "alter table terminal_sessions add column attention_source text"); diff --git a/apps/desktop/src/main/utils/terminalTuiMarkers.test.ts b/apps/desktop/src/main/utils/terminalTuiMarkers.test.ts deleted file mode 100644 index 4ce67caf09..0000000000 --- a/apps/desktop/src/main/utils/terminalTuiMarkers.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - clearTuiWaitingInput, - createTuiMarkerState, - scanTuiMarkers, - tuiActivityFromState, -} from "./terminalTuiMarkers"; - -const T0 = 1_000_000; - -describe("terminalTuiMarkers", () => { - it("allocates no state for untracked tools so unknown CLIs keep today's behavior", () => { - expect(createTuiMarkerState("shell" as never)).toBeNull(); - expect(createTuiMarkerState(null)).toBeNull(); - expect(scanTuiMarkers(null, { chunk: "Do you want to proceed? (y/n)\n", nowMs: T0 })).toBeNull(); - expect(tuiActivityFromState(null)).toBeNull(); - }); - - it("reports planning while the Claude plan-mode footer keeps repainting, and decays after the TTL", () => { - const state = createTuiMarkerState("claude"); - expect(state, "claude must have a marker pack").toBeTruthy(); - expect(scanTuiMarkers(state, { chunk: "⏸ plan mode on\n", nowMs: T0 })).toBe("planning"); - // Still believed shortly after the last repaint… - expect(tuiActivityFromState(state, { nowMs: T0 + 30_000 })).toBe("planning"); - // …but a footer that stopped saying it expires on its own. - expect(tuiActivityFromState(state, { nowMs: T0 + 61_000 })).toBeNull(); - }); - - it("latches waiting-input on an approval prompt and holds it through silence without re-stamping", () => { - const state = createTuiMarkerState("claude"); - expect(scanTuiMarkers(state, { chunk: "Do you want to proceed?\n❯ 1. Yes\n 2. No\n", nowMs: T0 })).toBe("waiting-input"); - const firstStamp = state!.waitingSince; - expect(firstStamp).toBe(T0); - // The TUI repaints the same prompt every frame; the latch must stay at the - // ORIGINAL edge so a later user settle can outrank it. - scanTuiMarkers(state, { chunk: "Do you want to proceed?\n❯ 1. Yes\n", nowMs: T0 + 5_000 }); - expect(state!.waitingSince).toBe(firstStamp); - // A settle floor newer than the latch silences it; a floor older does not. - expect(tuiActivityFromState(state, { nowMs: T0 + 10_000, waitingFloorMs: T0 })).toBeNull(); - expect(tuiActivityFromState(state, { nowMs: T0 + 10_000, waitingFloorMs: T0 - 1 })).toBe("waiting-input"); - }); - - it("releases the waiting latch when a working marker paints after the prompt", () => { - const state = createTuiMarkerState("claude"); - expect(scanTuiMarkers(state, { chunk: "Do you want to run this command? (y/n)\n", nowMs: T0 })).toBe("waiting-input"); - expect(scanTuiMarkers(state, { chunk: "Running… esc to interrupt\n", nowMs: T0 + 2_000 })).toBeNull(); - expect(state!.waitingSince).toBeNull(); - }); - - it("resolves prompt-vs-spinner in one window by position: the later marker wins", () => { - const state = createTuiMarkerState("codex"); - // Prompt then spinner → the agent already resumed working. - expect(scanTuiMarkers(state, { chunk: "Allow command? (y/n)\nWorking (esc to interrupt)\n", nowMs: T0 })).toBeNull(); - // Spinner then prompt → the prompt is the current truth. - const second = createTuiMarkerState("codex"); - expect(scanTuiMarkers(second, { chunk: "esc to interrupt\nAllow command? (y/n)\n", nowMs: T0 })).toBe("waiting-input"); - expect(second!.waitingSince).toBe(T0); - }); - - it("clears the latch AND the carry when the user types, so the answered prompt cannot re-latch", () => { - const state = createTuiMarkerState("claude"); - scanTuiMarkers(state, { chunk: "Do you want to proceed? (y/n)\n", nowMs: T0 }); - expect(state!.waitingSince).toBe(T0); - clearTuiWaitingInput(state); - expect(state!.waitingSince).toBeNull(); - expect(state!.carry).toBe(""); - // Next frame with no prompt stays clear. - expect(scanTuiMarkers(state, { chunk: "some plain output\n", nowMs: T0 + 1_000 })).toBeNull(); - }); - - it("matches a marker split across two PTY chunks via the carry", () => { - const state = createTuiMarkerState("claude"); - expect(scanTuiMarkers(state, { chunk: "Do you want to pro", nowMs: T0 })).toBeNull(); - expect(scanTuiMarkers(state, { chunk: "ceed? (y/n)\n", nowMs: T0 + 100 })).toBe("waiting-input"); - expect(state!.waitingSince).toBe(T0 + 100); - }); - - it("only latches an end-anchored yes/no prompt, not the same characters quoted mid-sentence", () => { - const state = createTuiMarkerState("droid"); - expect(scanTuiMarkers(state, { chunk: "answer with (y/n) when asked later\n", nowMs: T0 })).toBeNull(); - expect(state!.waitingSince).toBeNull(); - expect(scanTuiMarkers(state, { chunk: "Apply this change? (y/n) ", nowMs: T0 + 1_000 })).toBe("waiting-input"); - }); - - it("bounds a false-positive latch with the 30-minute TTL", () => { - const state = createTuiMarkerState("claude"); - scanTuiMarkers(state, { chunk: "Do you want to proceed? (y/n)\n", nowMs: T0 }); - expect(tuiActivityFromState(state, { nowMs: T0 + 29 * 60_000 })).toBe("waiting-input"); - expect(tuiActivityFromState(state, { nowMs: T0 + 31 * 60_000 })).toBeNull(); - expect(state!.waitingSince, "TTL expiry must not mutate the stamp").toBe(T0); - }); - - it("scans both ends of an oversized burst so a prompt at its head still latches", () => { - const state = createTuiMarkerState("claude"); - const burst = `Do you want to proceed? (y/n)\n${"x".repeat(40_000)}\nquiet tail\n`; - expect(scanTuiMarkers(state, { chunk: burst, nowMs: T0 })).toBe("waiting-input"); - // But a burst that ENDS working resolves to working by the position rule. - const resumed = createTuiMarkerState("claude"); - const burst2 = `Do you want to proceed? (y/n)\n${"x".repeat(40_000)}\nesc to interrupt\n`; - expect(scanTuiMarkers(resumed, { chunk: burst2, nowMs: T0 })).toBeNull(); - expect(resumed!.waitingSince).toBeNull(); - }); - - it("recognizes per-provider planning vocabulary (codex read-only, opencode plan agent, droid spec mode)", () => { - expect(scanTuiMarkers(createTuiMarkerState("codex"), { chunk: "read-only mode\n", nowMs: T0 })).toBe("planning"); - expect(scanTuiMarkers(createTuiMarkerState("opencode"), { chunk: "agent: plan\n", nowMs: T0 })).toBe("planning"); - expect(scanTuiMarkers(createTuiMarkerState("droid"), { chunk: "spec mode\n", nowMs: T0 })).toBe("planning"); - // Waiting outranks planning when both are believed. - const both = createTuiMarkerState("claude"); - scanTuiMarkers(both, { chunk: "⏸ plan mode on\n", nowMs: T0 }); - expect(scanTuiMarkers(both, { chunk: "Ready to code?\n", nowMs: T0 + 1_000 })).toBe("waiting-input"); - }); -}); diff --git a/apps/desktop/src/main/utils/terminalTuiMarkers.ts b/apps/desktop/src/main/utils/terminalTuiMarkers.ts deleted file mode 100644 index deb3c2689e..0000000000 --- a/apps/desktop/src/main/utils/terminalTuiMarkers.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type { TerminalResumeProvider, TerminalToolType } from "../../shared/types"; -import { stripAnsi } from "./ansiStrip"; -import { providerFromTool } from "./terminalSessionSignals"; - -/** - * Richer CLI session states, read off the PTY stream. - * - * Today a tracked CLI only has two states: "running" while OSC 133 prompt - * markers or fresh output say so, "idle" after 12 s of silence - * (`runtimeStateFromOsc133Chunk` + the idle timer in `ptyService`). Chat - * sessions have a richer vocabulary — planning, waiting on you, failed — and - * the UI already renders all of it. What was missing is a producer for CLIs. - * - * These packs are that producer: a small set of per-provider TUI markers, - * scanned incrementally over each PTY chunk, mapped onto the SAME - * `chatActivityMode` / `runtimeState` fields chat already uses so no surface - * needs new rendering code. - * - * Two shapes of marker, because TUIs emit two shapes of evidence: - * - * PLANNING is a *footer* state. Claude Code repaints `⏸ plan mode on` on - * essentially every frame, so it is sticky-with-decay: re-observed constantly - * while true, and expiring on its own once the footer stops saying it. No - * explicit "left plan mode" event is needed (and none is reliably printed). - * - * WAITING-INPUT is an *event*. The approval prompt is painted once and then - * sits there; silence afterwards is the session waiting, not the session - * finishing. So it latches and is cleared only by evidence that work resumed: - * a "working" marker (`esc to interrupt`) or the user typing. A session that - * exits reports nothing at all — its entry is no longer live. - * - * FAILED needs no markers here: a nonzero exit already lands as - * `status: "failed"` via `statusFromExit`, which `canonicalSessionState` reads - * as the `failed` phase. - * - * Cost matters — this runs on every PTY chunk for every tracked session — so a - * provider with no pack (a plain shell, an unrecognized CLI) allocates nothing - * and does no scanning at all, and the ones that do scan see a bounded window. - */ -export type TerminalTuiActivity = "planning" | "waiting-input" | null; - -/** Scan window: chunk tail plus a small carry, so a marker split across chunks still matches. */ -const MAX_CHUNK_SCAN_CHARS = 8_000; -const CARRY_CHARS = 512; - -/** - * How long a planning footer stays believed after it was last painted. Long - * enough to survive a quiet stretch mid-frame, short enough that a session that - * left plan mode without saying so stops claiming it. - */ -const PLANNING_TTL_MS = 60_000; - -/** - * How long a waiting prompt stays believed. - * - * The latch is deliberately cleared only by evidence (a working marker, or the - * user typing), and that is right for a real prompt — but a FALSE positive has - * no such evidence coming, and without a bound it pins the session at - * "waiting-input" across every surface forever. The window is much longer than - * planning's because a human genuinely can leave an approval prompt sitting for - * a while; it exists to bound a mistake, not to time out a user. - */ -const WAITING_TTL_MS = 30 * 60_000; - -type MarkerPack = { - /** Footer/banner evidence that the agent is in a plan-only mode. */ - planning: readonly RegExp[]; - /** Prompt evidence that the agent stopped and is waiting on the human. */ - waitingInput: readonly RegExp[]; - /** Evidence that work is actively running — releases the waiting-input latch. */ - working: readonly RegExp[]; -}; - -/** Shared across packs: option-list and yes/no prompt shapes every TUI borrows. */ -// Anchored toward the end of its line: a real prompt ENDS with its `(y/n)`, -// while the same characters quoted mid-sentence ("answer with (y/n) when -// asked", a diff hunk, a man page) are the false positives this used to latch -// on. Trailing space/cursor-ish punctuation is allowed because a TUI paints a -// caret after the prompt. -const YES_NO_PROMPT = /(?:\((?:y\/n|yes\/no)\)|\[(?:y\/n|yes\/no)\])[\s:>?\u2588\u258e]*(?:$|\n)/i; -const NUMBERED_YES_OPTION = /❯\s*1\.\s*Yes\b/; -const ESC_TO_INTERRUPT = /\besc(?:ape)? to interrupt\b/i; - -const CLAUDE_PACK: MarkerPack = { - // The footer Claude Code repaints while plan mode is on: `⏸ plan mode on`. - planning: [/\bplan mode on\b/i], - waitingInput: [ - /\bDo you want to (?:proceed|continue|make this edit|create|run|allow)\b/i, - /\bWould you like to (?:proceed|continue)\b/i, - /\bReady to code\?/i, - NUMBERED_YES_OPTION, - YES_NO_PROMPT, - ], - working: [ESC_TO_INTERRUPT], -}; - -const CODEX_PACK: MarkerPack = { - planning: [ - /\bplan mode\b/i, - /\bread-only mode\b/i, - ], - waitingInput: [ - /\bAllow (?:command|Codex)\b[^\n]{0,40}\?/i, - /\bDo you want to (?:allow|approve|run)\b/i, - /\bapprove this (?:command|edit)\b/i, - NUMBERED_YES_OPTION, - YES_NO_PROMPT, - ], - working: [ESC_TO_INTERRUPT, /\bWorking\b[^\n]{0,20}\(esc/i], -}; - -/** Best-effort: cursor-agent's prompt vocabulary is close to Claude Code's. */ -const CURSOR_PACK: MarkerPack = { - planning: [/\bplan mode\b/i, /\bask mode\b/i], - waitingInput: [NUMBERED_YES_OPTION, YES_NO_PROMPT, /\bapprove\b[^\n]{0,30}\?/i], - working: [ESC_TO_INTERRUPT], -}; - -/** Best-effort: opencode surfaces its agent name and a permission prompt. */ -const OPENCODE_PACK: MarkerPack = { - planning: [/\bplan mode\b/i, /\bagent:\s*plan\b/i], - waitingInput: [ - /\bpermission (?:request|required)\b/i, - /\ballow this (?:action|command|edit)\b/i, - NUMBERED_YES_OPTION, - YES_NO_PROMPT, - ], - working: [ESC_TO_INTERRUPT], -}; - -/** Best-effort: droid calls its plan-only mode "spec mode". */ -const DROID_PACK: MarkerPack = { - planning: [/\bspec mode\b/i, /\bplan mode\b/i], - waitingInput: [NUMBERED_YES_OPTION, YES_NO_PROMPT, /\bapprove\b[^\n]{0,30}\?/i], - working: [ESC_TO_INTERRUPT], -}; - -/** Pi's interactive CLI uses the same compact approval/plan vocabulary as its RPC surface. */ -const PI_PACK: MarkerPack = { - planning: [/\bplan mode\b/i, /\bplanning\b/i], - waitingInput: [NUMBERED_YES_OPTION, YES_NO_PROMPT, /\bapprove\b[^\n]{0,30}\?/i], - working: [ESC_TO_INTERRUPT], -}; - -/** - * Generic pack for the ACP CLIs. Only the interrupt hint is claimed: it is the - * one footer every one of them prints, and a guessed prompt pattern would latch - * a row as "waiting on you" that nobody is waiting on. - */ -const ACP_PACK: MarkerPack = { - planning: [], - waitingInput: [NUMBERED_YES_OPTION, YES_NO_PROMPT], - working: [ESC_TO_INTERRUPT], -}; - -const PACKS: Record = { - claude: CLAUDE_PACK, - codex: CODEX_PACK, - cursor: CURSOR_PACK, - droid: DROID_PACK, - opencode: OPENCODE_PACK, - pi: PI_PACK, - // ACP providers. ADE reads their turn state off the protocol, not off the - // TUI, so a tracked terminal for one of them gets the generic pack rather - // than invented regexes for footers nobody has measured. - qwen: ACP_PACK, - kimi: ACP_PACK, - grok: ACP_PACK, - copilot: ACP_PACK, -}; - -export type TuiMarkerState = { - pack: MarkerPack; - /** Tail of the last scan window, so a marker split across chunks still matches. */ - carry: string; - /** When the planning footer was last observed; null means never. */ - planningSeenAt: number | null; - /** - * When a waiting prompt was last seen, or null when nothing is pending. - * A timestamp rather than a flag so callers can ignore a latch older than an - * explicit settle — the same "strictly newer than" rule snooze uses for - * errors, and what keeps a false positive from making a row unsettleable. - */ - waitingSince: number | null; -}; - -/** - * Null for anything without a pack — a plain shell, an unknown CLI. Callers - * skip all marker work when this is null, which is what makes unrecognized - * providers degrade to exactly today's working/idle behavior. - */ -export function createTuiMarkerState(toolType: TerminalToolType | null | undefined): TuiMarkerState | null { - const provider = providerFromTool(toolType); - if (!provider) return null; - return { pack: PACKS[provider], carry: "", planningSeenAt: null, waitingSince: null }; -} - -const GLOBAL_PATTERNS = new WeakMap(); - -function globalPattern(pattern: RegExp): RegExp { - const cached = GLOBAL_PATTERNS.get(pattern); - if (cached) return cached; - const next = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`); - GLOBAL_PATTERNS.set(pattern, next); - return next; -} - -/** - * Index of the LAST match in the window, or -1. Position is what decides - * whether the agent is waiting or working: a window routinely contains both the - * prompt (carried over, or painted earlier in the same repaint) and the spinner - * that replaced it, and only the later one is the current truth. - */ -function lastMatchIndex(patterns: readonly RegExp[], text: string): number { - let best = -1; - for (const pattern of patterns) { - const scanner = globalPattern(pattern); - scanner.lastIndex = 0; - for (;;) { - const match = scanner.exec(text); - if (!match) break; - if (match.index > best) best = match.index; - if (scanner.lastIndex === match.index) scanner.lastIndex += 1; - } - } - return best; -} - -/** The user typed: whatever the agent was waiting for, it has it now. */ -export function clearTuiWaitingInput(state: TuiMarkerState | null): void { - if (!state) return; - state.waitingSince = null; - // The carry holds the tail of the last window, and a prompt short enough to - // fit in it would be re-matched by the very next chunk and re-latch the state - // the user just answered. It has served its purpose (joining a marker split - // across chunks); dropping it here costs nothing and closes that loop. - state.carry = ""; -} - -export function tuiActivityFromState( - state: TuiMarkerState | null, - opts: { - nowMs?: number; - /** - * Ignore a waiting latch no newer than this (the row's `settledAt`). A - * session the user declared done stays done until the TUI paints a NEW - * prompt. - */ - waitingFloorMs?: number | null; - } = {}, -): TerminalTuiActivity { - if (!state) return null; - const nowMs = opts.nowMs ?? Date.now(); - const floor = opts.waitingFloorMs ?? null; - // Waiting on the human outranks planning: it is the actionable one. - if ( - state.waitingSince != null - && nowMs - state.waitingSince < WAITING_TTL_MS - && (floor == null || state.waitingSince > floor) - ) return "waiting-input"; - if (state.planningSeenAt != null && nowMs - state.planningSeenAt < PLANNING_TTL_MS) return "planning"; - return null; -} - -/** - * Fold one PTY chunk into the marker state and return the resulting activity. - * Bounded work: one ANSI strip and a handful of regexes over at most - * 2 × MAX_CHUNK_SCAN_CHARS + CARRY_CHARS characters. - */ -export function scanTuiMarkers( - state: TuiMarkerState | null, - args: { chunk: string; nowMs?: number }, -): TerminalTuiActivity { - const nowMs = args.nowMs ?? Date.now(); - if (!state) return null; - const chunk = args.chunk ?? ""; - if (!chunk) return tuiActivityFromState(state, { nowMs }); - - // An oversized chunk is a burst of frames, and the interesting markers sit at - // BOTH ends of it: the prompt the burst opened with (which the tail-only - // window dropped entirely) and whatever state it came to rest in. Scanning - // head and tail keeps the work bounded while `lastMatchIndex`'s position rule - // still resolves them in order, because the head slice precedes the tail one. - const scanned = chunk.length > MAX_CHUNK_SCAN_CHARS * 2 - ? `${chunk.slice(0, MAX_CHUNK_SCAN_CHARS)}\n${chunk.slice(-MAX_CHUNK_SCAN_CHARS)}` - : chunk; - const window = `${state.carry}${scanned}`; - const text = stripAnsi(window); - state.carry = window.slice(-CARRY_CHARS); - - if (lastMatchIndex(state.pack.planning, text) >= 0) { - state.planningSeenAt = nowMs; - } - const waitingAt = lastMatchIndex(state.pack.waitingInput, text); - const workingAt = lastMatchIndex(state.pack.working, text); - if (waitingAt >= 0 && waitingAt > workingAt) { - // EDGE only. A TUI repaints its prompt on essentially every frame, so - // re-stamping per sighting walks `waitingSince` forward forever — past any - // `settledAt` the user sets, which is precisely what made a settle refuse to - // stick. The latch is armed once and re-armed only after something clears - // it: a working marker below, or the user typing (`clearTuiWaitingInput`). - if (state.waitingSince == null) state.waitingSince = nowMs; - } else if (workingAt >= 0 && state.waitingSince != null) { - // The prompt was answered (or withdrawn) and the agent is running again. - state.waitingSince = null; - } - - return tuiActivityFromState(state, { nowMs }); -} diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index 709e929804..268efb4b31 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -511,6 +511,34 @@ describe("SessionCard lineage", () => { expect(statusRow.querySelector("[data-session-status]")).toBeTruthy(); }); + it("keeps agent activity and its provenance available while a Kanban row is hovered", () => { + vi.useFakeTimers(); + const reportUpdatedAt = "2026-09-22T11:59:30.000Z"; + const props = { lane, isSelected: false, onSelect: vi.fn(), onContextMenu: vi.fn() }; + const { container } = render( + , + ); + + expect(container.querySelector("[data-session-status]")?.getAttribute("data-session-status")) + .toBe("Testing"); + fireEvent.mouseEnter(container.querySelector("[data-session-row]") as HTMLElement); + act(() => { vi.advanceTimersByTime(SESSION_HOVER_CARD_DELAY_MS + 10); }); + + const statusRow = screen.getByTestId("session-hover-status"); + const hoveredStatus = statusRow.querySelector("[data-session-status]"); + expect(hoveredStatus?.getAttribute("data-session-status")).toBe("Testing"); + expect(hoveredStatus?.getAttribute("title")).toContain("Agent-reported activity"); + expect(hoveredStatus?.getAttribute("title")).toContain(new Date(reportUpdatedAt).toLocaleString()); + }); + it("keeps the status word on the row face by default", () => { const props = { lane, isSelected: false, onSelect: vi.fn(), onContextMenu: vi.fn() }; const { container } = render(); @@ -1458,6 +1486,82 @@ describe("SessionCard status vocabulary", () => { expect(status.textContent).not.toContain("12m"); }); + it("shows one agent-reported activity label on the card", () => { + const { container } = render( + , + ); + + const status = container.querySelector("[data-session-status]")!; + expect(status.getAttribute("data-session-status")).toBe("Testing"); + expect(status.getAttribute("data-session-status-source")).toBe("agent"); + expect(status.textContent).not.toContain("Working"); + }); + + it("keeps Needs you as the only status when input is pending", () => { + const { container } = render( + , + ); + + expect(container.querySelector("[data-session-status]")?.getAttribute("data-session-status")) + .toBe("Needs you"); + expect(screen.queryByText("Testing")).toBeNull(); + }); + + it("shows the activity detail once on a Kanban card where the column carries the parent state", () => { + const { container } = render( + , + ); + + const status = container.querySelector("[data-session-status]"); + expect(status?.getAttribute("data-session-status")).toBe("Testing"); + expect(container.querySelectorAll("[data-session-status]")).toHaveLength(1); + expect(screen.queryByText("Working")).toBeNull(); + }); + it("names background work and times it when the foreground turn is idle", () => { // Regression: this row used to read as a bare "Working" with no duration, // which is indistinguishable from a live turn that has stalled — it claimed diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 264c4c3aea..1bcafab2c2 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -938,15 +938,12 @@ export const SessionCard = React.memo(function SessionCard({ (PR state, red for a broken exit). */ const lanePrList = lanePr ? (lanePrs.length > 0 ? lanePrs : [lanePr]) : []; const hoverRows: SessionHoverCardRow[] = []; - /* Board rows only. The row face gave the status word up to its column (see - `suppressStatusLabel`), and that word is not pure duplication: the column - files "Failed" and "Ended" together under Done, and "Stale 4h" next to - "Working 14s" under Working. Rather than lose the distinction, it lands - here — first, because "what is this actually doing" is the question the - card is opened to answer. - - `SessionStatusLabel` renders it, not a re-derived string: one hue and one - glyph per state, resolved in exactly one place. */ + /* Board rows put the parent phase in the column. At rest, a distinct activity + detail stays on the card face. Once hover actions hide that face label, the + hover card carries the detail and its provenance; when there is no detail, + it carries the full phase so Stale / Failed are not lost. `SessionStatusLabel` + renders both from the shared presentation, with one hue and glyph per + visible status. */ if (suppressStatusLabel && presentation) { hoverRows.push({ id: "status", @@ -1159,7 +1156,7 @@ export const SessionCard = React.memo(function SessionCard({ (it is what a settled row already uses), so suppression needs no new branch inside the slot — and the hover action cluster, which is a separate layer, keeps working exactly as it does on a list row. */ - presentation={suppressStatusLabel ? null : presentation} + presentation={suppressStatusLabel && !presentation?.activityDetail ? null : presentation} /* Deliberately not `sessionActivityInstant`: the slot's stamp answers "when did this finish", so a still-running row shows how long it has been going rather than how long since its last token. */ diff --git a/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx b/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx index 44595a668f..95868429eb 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionStatusLabel.tsx @@ -1,10 +1,15 @@ import React from "react"; import { Alarm, + Bug, CheckCircle, Circle, CircleDashed, Clock, + Code, + Eye, + Flask, + MagnifyingGlass, NotePencil, Moon, } from "@phosphor-icons/react"; @@ -25,6 +30,16 @@ function StatusGlyph({ glyph }: { glyph: SessionStatusGlyph }) { return ; case "planning": return ; + case "implementing": + return ; + case "testing": + return ; + case "reviewing": + return ; + case "debugging": + return ; + case "monitoring": + return ; case "waiting": return ; case "done": @@ -123,6 +138,13 @@ export function SessionStatusLabel({ ? `Next run ${new Date(wakeAt).toLocaleString()}` : undefined; }, [futureAt, waiting]); + const activityReportTitle = React.useMemo(() => { + if (presentation?.activitySource !== "agent" || !presentation.activityUpdatedAt) return undefined; + const updatedAt = Date.parse(presentation.activityUpdatedAt); + return Number.isFinite(updatedAt) + ? `Agent-reported activity · updated ${new Date(updatedAt).toLocaleString()}` + : "Agent-reported activity"; + }, [presentation?.activitySource, presentation?.activityUpdatedAt]); if (!presentation) { return ( @@ -138,6 +160,7 @@ export function SessionStatusLabel({ {/* Keep the ticker outside role=status so screen readers do not announce diff --git a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx index 473ab1e475..64dcffe91d 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx @@ -101,8 +101,8 @@ export function SessionStatusSlot({ canonicalPhase !== "needs_you" || isChatToolType(session.toolType) || Boolean(session.attentionRequestedAt) - // A CLI raised by TUI-marker detection rather than by a structured provider - // event: the read is a heuristic, so the row must always stay settleable. + // Structured provider attention can arrive without a chat tool type or + // ADE's explicit `ask`; keep its session card dismissible as well. || session.attentionSource === "provider_structured"; const canSettle = settled || (!isActivelyRunning && canDismissNeedsYou); diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 337debc75f..f51fe79984 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -4,6 +4,7 @@ import { buildCliIdentityResumeMetadata, buildPtyContinuationLaunchFields, buildOpenCodeReplayResumeLaunchCommand, + buildTrackedCliSessionActivityGuidance, buildTrackedCliLaunchCommand, buildTrackedCliResumeLaunchCommand, buildTrackedCliResumeCommand, @@ -696,6 +697,106 @@ describe("piSdkToolPolicyForPermissionMode", () => { }); }); +describe("tracked CLI activity guidance", () => { + it.each(["codex", "opencode"] as const)( + "offers activity reporting for %s outside Plan mode", + (provider) => { + const guidance = buildTrackedCliSessionActivityGuidance({ provider, permissionMode: "default" }); + expect(guidance).toContain('"$ADE_CLI_PATH" chat activity testing'); + expect(guidance).toContain("planning, implementing, testing, reviewing, debugging, monitoring"); + expect(guidance).toContain("ADE_ACTIVITY_SESSION_ID"); + expect(guidance).toContain("tracked terminal row"); + expect(buildTrackedCliSessionActivityGuidance({ provider, permissionMode: "plan" })).toBeNull(); + }, + ); + + it("omits activity reporting when the runtime has no RPC endpoint", () => { + expect(buildTrackedCliSessionActivityGuidance({ + provider: "codex", + permissionMode: "default", + sessionActivityReportingEnabled: false, + })).toBeNull(); + const launch = buildTrackedCliLaunchCommand({ + provider: "codex", + permissionMode: "default", + sessionActivityReportingEnabled: false, + }); + expect(JSON.stringify(launch)).not.toContain("chat activity testing"); + }); + + it("requires a write-capable Droid mode and Pi full-auto Bash", () => { + expect(buildTrackedCliSessionActivityGuidance({ + provider: "droid", + permissionMode: "default", + droidPermissionMode: "agi", + })).toBeNull(); + expect(buildTrackedCliSessionActivityGuidance({ + provider: "droid", + permissionMode: "default", + droidPermissionMode: "auto-medium", + })).not.toBeNull(); + expect(buildTrackedCliSessionActivityGuidance({ provider: "pi", permissionMode: "default" })).toBeNull(); + expect(buildTrackedCliSessionActivityGuidance({ provider: "pi", permissionMode: "full-auto" })).not.toBeNull(); + expect(buildTrackedCliSessionActivityGuidance({ provider: "pi", permissionMode: "edit" })).toBeNull(); + expect(buildTrackedCliSessionActivityGuidance({ provider: "pi", permissionMode: "config-toml" })).toBeNull(); + }); + + it("omits external permission configs and unverified CLI guidance paths", () => { + expect(buildTrackedCliSessionActivityGuidance({ provider: "codex", permissionMode: "config-toml" })).toBeNull(); + expect(buildTrackedCliSessionActivityGuidance({ provider: "opencode", permissionMode: "config-toml" })).toBeNull(); + for (const provider of ["qwen", "kimi", "grok", "copilot"] as const) { + expect(buildTrackedCliSessionActivityGuidance({ provider, permissionMode: "default" })).toBeNull(); + } + }); + + it("provides shell-specific tracked CLI guidance on Windows", () => { + const guidance = withProcessPlatform("win32", () => buildTrackedCliSessionActivityGuidance({ + provider: "codex", + permissionMode: "default", + })); + expect(guidance).toContain('In PowerShell, report activity with `& "$env:ADE_CLI_PATH" chat activity testing`'); + expect(guidance).toContain('In cmd.exe, use `"%ADE_CLI_PATH%" chat activity testing`'); + expect(guidance).toContain("In Git Bash, use `powershell.exe -NoProfile -Command '& \"$env:ADE_CLI_PATH\" chat activity testing'`"); + expect(guidance).toContain("if none matches, leave activity unchanged"); + }); + + it("omits guidance when Claude fallback or a blank Cursor launch cannot preserve it", () => { + expect(buildTrackedCliSessionActivityGuidance({ provider: "claude", permissionMode: "default" })).toBeNull(); + expect(buildTrackedCliSessionActivityGuidance({ provider: "cursor", permissionMode: "default" })).toBeNull(); + expect(buildTrackedCliSessionActivityGuidance({ + provider: "cursor", + permissionMode: "default", + hasInitialPrompt: true, + })).not.toBeNull(); + }); + + it("adds guidance through each verified CLI prompt channel and omits it in Plan", () => { + const claude = buildTrackedCliLaunchCommand({ provider: "claude", permissionMode: "default" }); + expect(claude.args.join("\n")).not.toContain("chat activity testing"); + + const codex = buildTrackedCliLaunchCommand({ provider: "codex", permissionMode: "default" }); + expect(codex.initialInput).toContain('"$ADE_CLI_PATH" chat activity testing'); + const codexPlan = buildTrackedCliLaunchCommand({ provider: "codex", permissionMode: "plan" }); + expect(codexPlan.initialInput).not.toContain("chat activity testing"); + + const pi = buildTrackedCliLaunchCommand({ provider: "pi", permissionMode: "default" }); + expect(pi.args.join("\n")).not.toContain("chat activity testing"); + const piFullAuto = buildTrackedCliLaunchCommand({ provider: "pi", permissionMode: "full-auto" }); + expect(piFullAuto.args.join("\n")).toContain('"$ADE_CLI_PATH" chat activity testing'); + const piEdit = buildTrackedCliLaunchCommand({ provider: "pi", permissionMode: "edit" }); + expect(piEdit.args.join("\n")).not.toContain("chat activity testing"); + + const cursorBlank = buildTrackedCliLaunchCommand({ provider: "cursor", permissionMode: "default" }); + expect(cursorBlank.initialInput).toBeUndefined(); + const cursorKickoff = buildTrackedCliLaunchCommand({ + provider: "cursor", + permissionMode: "default", + initialPrompt: "Fix the bug", + }); + expect(cursorKickoff.initialInput).toContain("ADE_ACTIVITY_SESSION_ID"); + }); +}); + describe("buildTrackedCliStartupCommand", () => { it("preserves Pi's native max thinking level", () => { expect(piThinkingFlags("max")).toEqual(["--thinking", "max"]); @@ -739,7 +840,7 @@ describe("buildTrackedCliStartupCommand", () => { ); }); - it("uses Claude's system-prompt hook for ADE guidance", () => { + it("keeps Claude's system-prompt guidance but omits unverified activity reporting", () => { const launch = buildTrackedCliLaunchCommand({ provider: "claude", permissionMode: "default", @@ -750,13 +851,16 @@ describe("buildTrackedCliStartupCommand", () => { "--session-id", "00000000-0000-0000-0000-000000000001", "--append-system-prompt", - ADE_CLI_AGENT_GUIDANCE, + expect.stringContaining(ADE_CLI_AGENT_GUIDANCE), "--permission-mode", "default", ])); expect(launch.startupCommand).not.toContain("--append-system-prompt"); expect(launch.args).toContain("--append-system-prompt"); - expect(launch.args).toContain(ADE_CLI_AGENT_GUIDANCE); + const promptIndex = launch.args.indexOf("--append-system-prompt"); + const prompt = launch.args[promptIndex + 1] ?? ""; + expect(prompt).toContain(ADE_CLI_AGENT_GUIDANCE); + expect(prompt).not.toContain('"$ADE_CLI_PATH" chat activity testing'); expect(launch.env?.[ADE_AGENT_SKILLS_DIRS_ENV]).toContain("agent-skills"); }); diff --git a/apps/desktop/src/renderer/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index 405d9263ff..e993888194 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.test.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import type { TerminalSessionSummary } from "../../shared/types"; import { effectiveSessionFilingBuckets, - runningSessionNeedsAttention, sanitizeTerminalInlineText, sessionNeedsChatTabHighlight, sessionStatusBucket, @@ -12,24 +11,17 @@ import { } from "./terminalAttention"; describe("terminalAttention", () => { - it("does not treat a plain shell prompt as awaiting user input", () => { - expect(runningSessionNeedsAttention("admin@Mac test-4-6a625aeb %")).toBe(false); - expect( - sessionStatusBucket({ - status: "running", - lastOutputPreview: "admin@Mac test-4-6a625aeb %", - }), - ).toBe("running"); - }); - - it("keeps prompt-text detection separate from lifecycle attention", () => { - expect(runningSessionNeedsAttention("Confirm continue? (y/n)")).toBe(true); - expect( - sessionStatusBucket({ + it("does not infer Needs you from prompt-looking terminal output", () => { + for (const lastOutputPreview of [ + "admin@Mac test-4-6a625aeb %", + "Confirm continue? (y/n)", + "Select an option: 1, 2, or 3", + ]) { + expect(sessionStatusBucket({ status: "running", - lastOutputPreview: "Confirm continue? (y/n)", - }), - ).toBe("running"); + lastOutputPreview, + })).toBe("running"); + } }); it("preserves an explicitly snoozed child when its chat parent is settled", () => { diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 34b75a9432..8c35142785 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -48,19 +48,6 @@ const CSI_REGEX = /\u001b\[[0-?]*[ -/]*[@-~]/g; const CHARSET_REGEX = /\u001b[\(\)][0-9A-Za-z]/g; const TWO_CHAR_ESC_REGEX = /\u001b(?:[@-Z\\-_]|[0-9=>])/g; -const NEEDS_INPUT_PATTERNS: RegExp[] = [ - /\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i, - /\b(?:press|hit)\b.{0,14}\b(?:enter|return|any key)\b/i, - /\b(?:select|choose|pick)\b.{0,28}\b(?:option|number|profile|item)\b/i, - /\b(?:confirm|continue|proceed|retry)\b.{0,24}\?/i, - /\((?:y\/n|yes\/no)\)/i, - /\[(?:y\/n|yes\/no)\]/i, - /\b(?:enter|type)\b.{0,24}:\s*$/i, - // Claude Code tool-approval / plan-mode prompts: "(Y)es / (N)o", "(Y)es, (N)o, (A)lways" - /\([Yy]\)\w*\s*.{0,12}\([Nn]\)\w*/, - /\ballow\b.{0,40}\?\s/i, -]; - const IDLE_ATTENTION_TOOL_TYPES = new Set([ "claude", "codex", @@ -120,12 +107,6 @@ export function sanitizeTerminalInlineText(raw: string | null | undefined, maxCh return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; } -export function runningSessionNeedsAttention(preview: string | null | undefined): boolean { - const text = sanitizeTerminalInlineText(preview, 280); - if (!text) return false; - return NEEDS_INPUT_PATTERNS.some((pattern) => pattern.test(text)); -} - function indicatorFromCounts(runningCount: number, needsAttentionCount: number): TerminalRunIndicatorState { if (runningCount <= 0) return "none"; if (needsAttentionCount > 0) return "running-needs-attention"; @@ -154,6 +135,8 @@ type SessionCanonicalUiInput = { */ usageLimitResume?: AgentChatUsageLimitResume | null; chatActivityMode?: TerminalSessionSummary["chatActivityMode"]; + activityStatus?: TerminalSessionSummary["activityStatus"]; + currentTurnStartedAt?: TerminalSessionSummary["currentTurnStartedAt"]; activeBackgroundTaskCount?: number; backgroundWork?: SessionBackgroundWork | null; nowMs?: number; @@ -181,6 +164,8 @@ export function canonicalInputFromSummary(session: TerminalSessionSummary): Sess nextWakeAt: session.nextWakeAt, usageLimitResume: session.usageLimitResume, chatActivityMode: session.chatActivityMode, + activityStatus: session.activityStatus, + currentTurnStartedAt: session.currentTurnStartedAt, activeBackgroundTaskCount: session.activeBackgroundTaskCount, backgroundWork: backgroundWorkFromSummary(session), }; @@ -267,6 +252,8 @@ export function sessionStatusDisplay( const state = sessionCanonicalUiState(session); return sessionStatusPresentation(state.phase, overlay, { chatActivityMode: session.chatActivityMode, + activityStatus: session.activityStatus, + currentTurnStartedAt: session.currentTurnStartedAt, liveness: state.liveness, backgroundWork: backgroundWorkFromSummary(session), nextWakeAt: session.nextWakeAt, @@ -391,6 +378,8 @@ export function sessionStatusDot( // the full status slot — otherwise a monitoring row's dot reads "Working". const presentation = sessionStatusPresentation(phase, overlay, { chatActivityMode: session.chatActivityMode, + activityStatus: session.activityStatus, + currentTurnStartedAt: session.currentTurnStartedAt, liveness: state.liveness, backgroundWork: backgroundWorkFromSummary(session), // Same forward as the full slot: without it a chat parked on a published diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index e0994c58f1..c21ab9d8fe 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -1156,7 +1156,12 @@ describe("createAdeWebAdapter", () => { "chat.getChatEventHistory", ]); fake.commandResults.set("lanes.list", [{ id: "lane-1" }]); - fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1" }]); + const activityStatus = { + value: "testing", + source: "agent", + updatedAt: "2026-09-23T08:00:00.000Z", + }; + fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1", activityStatus }]); fake.commandResults.set("prs.list", [{ id: "pr-1" }]); fake.commandResults.set("git.getChanges", { files: [{ path: "a.ts" }] }); fake.commandResults.set("chat.getChatEventHistory", { @@ -1171,7 +1176,11 @@ describe("createAdeWebAdapter", () => { adapter.bindProject(project, "project-1"); await expect(adapter.ade.lanes.list()).resolves.toEqual([{ id: "lane-1" }]); - await expect(adapter.ade.sessions.list()).resolves.toEqual([{ id: "session-1", ptyId: "pty-1" }]); + await expect(adapter.ade.sessions.list()).resolves.toEqual([{ + id: "session-1", + ptyId: "pty-1", + activityStatus, + }]); await expect(adapter.ade.prs.listAll()).resolves.toEqual([{ id: "pr-1" }]); await expect(adapter.ade.diff.getChanges({ laneId: "lane-1" } as never)).resolves.toEqual({ files: [{ path: "a.ts" }], diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index 28e6ba748b..ec75cd3ec7 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -2,7 +2,16 @@ import { MAX_STATUS_NOTE_CHARACTERS, STATUS_NOTE_GUIDELINE_WORDS } from "./sessi import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { adeBundledAgentSkills, buildAdeBootstrapGuidance, buildAdeCliAgentGuidance } from "./adeCliGuidance"; +import { SESSION_ACTIVITY_VALUES } from "./types/sessions"; +import { + adeBundledAgentSkills, + buildAdeBootstrapGuidance, + buildAdeCliAgentGuidance, + buildAdePosixTrackedCliActivityGuidance, + buildAdeRuntimeSocketEnv, + buildAdeSessionActivityGuidance, + buildAdeWindowsTrackedCliActivityGuidance, +} from "./adeCliGuidance"; describe("ADE CLI guidance", () => { it("now aliases the minimal bootstrap (the verbose always-on blob was removed)", () => { @@ -119,3 +128,58 @@ describe("Work board status guidance", () => { expect(guidance).toContain("You cannot settle or unsettle a session"); }); }); + +describe("ADE session activity guidance", () => { + it("has distinct POSIX and Windows tracked CLI guidance", () => { + const posix = buildAdePosixTrackedCliActivityGuidance(); + const windows = buildAdeWindowsTrackedCliActivityGuidance(); + + expect(posix).toContain('"$ADE_CLI_PATH" chat activity testing'); + expect(posix).toContain("ADE_ACTIVITY_SESSION_ID"); + expect(posix).not.toContain("PowerShell"); + expect(windows).toContain("PowerShell"); + expect(windows).toContain("cmd.exe"); + expect(windows).toContain("Git Bash"); + expect(windows).toContain(SESSION_ACTIVITY_VALUES.join(", ")); + }); + + it("pins all CLI socket selectors to one exact runtime", () => { + expect(buildAdeRuntimeSocketEnv(" /runtime/ade.sock ")).toEqual({ + ADE_RPC_URL: "/runtime/ade.sock", + ADE_RPC_SOCKET_PATH: "/runtime/ade.sock", + ADE_RUNTIME_SOCKET_PATH: "/runtime/ade.sock", + }); + expect(buildAdeRuntimeSocketEnv(" ")).toEqual({}); + }); + + it("targets a shared OpenCode server at the exact CLI and runtime for both shells", () => { + const posixGuidance = buildAdeSessionActivityGuidance({ + sessionId: "chat-1", + cliPath: "/Applications/ADE Beta.app/bin/ade", + shell: "posix", + target: { type: "inline", runtimeSocketPath: "/Users/admin/.ade-beta/sock/ade.sock" }, + }); + const powershellGuidance = buildAdeSessionActivityGuidance({ + sessionId: "chat-1", + cliPath: "C:\\Program Files\\ADE Beta\\ade.exe", + shell: "powershell", + target: { type: "inline", runtimeSocketPath: "C:\\Users\\admin\\.ade-beta\\sock\\ade.sock" }, + }); + + expect(posixGuidance).toContain( + "ADE_DEFAULT_ROLE='agent' ADE_CHAT_SESSION_ID='chat-1' ADE_RPC_URL='/Users/admin/.ade-beta/sock/ade.sock' ADE_RPC_SOCKET_PATH='/Users/admin/.ade-beta/sock/ade.sock' ADE_RUNTIME_SOCKET_PATH='/Users/admin/.ade-beta/sock/ade.sock' '/Applications/ADE Beta.app/bin/ade' chat activity testing --session 'chat-1'", + ); + expect(posixGuidance).toContain("chat activity clear --session 'chat-1'"); + expect(posixGuidance).not.toContain("$ADE_CLI_PATH"); + expect(powershellGuidance).toContain( + "$env:ADE_DEFAULT_ROLE = 'agent'; $env:ADE_CHAT_SESSION_ID = 'chat-1'; $env:ADE_RPC_URL = 'C:\\Users\\admin\\.ade-beta\\sock\\ade.sock'; $env:ADE_RPC_SOCKET_PATH = 'C:\\Users\\admin\\.ade-beta\\sock\\ade.sock'; $env:ADE_RUNTIME_SOCKET_PATH = 'C:\\Users\\admin\\.ade-beta\\sock\\ade.sock'; & 'C:\\Program Files\\ADE Beta\\ade.exe' chat activity testing --session 'chat-1'", + ); + expect(powershellGuidance).not.toContain("$env:ADE_CLI_PATH"); + expect(buildAdeSessionActivityGuidance({ + sessionId: "chat-1", + cliPath: "/Applications/ADE Beta.app/bin/ade", + shell: "posix", + target: { type: "inline", runtimeSocketPath: " " }, + })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index becf812e2d..22bb3e81c6 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -1,5 +1,6 @@ import { MAX_STATUS_NOTE_CHARACTERS, STATUS_NOTE_GUIDELINE_WORDS } from "./sessionStatusNote"; import { formatAdeAgentSkillRootsForPrompt, getAdeAgentSkillRootsForPrompt } from "./agentSkillRoots"; +import { SESSION_ACTIVITY_VALUES } from "./types/sessions"; /** * The bundled skill index every provider's prompt advertises. @@ -46,16 +47,125 @@ export const adeBundledAgentSkills = [ // reads it: the "Board and status" section of the ade-cli-control-plane skill. export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ "ADE control protocol for truthful Work status:", - `- Working: \`ade chat note "testing desktop auth fallback"\`; aim for ${STATUS_NOTE_GUIDELINE_WORDS} words or fewer — a guideline, not a hard limit. Notes truncate past ${MAX_STATUS_NOTE_CHARACTERS} characters, so a long note still beats no note.`, + `- Use \`ade chat note "testing desktop auth fallback"\` for a durable one-line summary (aim for ${STATUS_NOTE_GUIDELINE_WORDS} words or fewer; notes truncate past ${MAX_STATUS_NOTE_CHARACTERS} characters).`, '- Blocked on input: call `ade chat note ""`, then `ade chat ask ""`; a note alone can leave an idle row looking Done.', "- The next accepted user message clears the prior hand-raise. Re-note and re-ask before ending if still blocked.", '- Done: report it and leave `ade chat note ""`.', "- You cannot settle or unsettle a session; that is the user's call, or the automatic result of its PR merging.", "- Waiting a while? `ade session snooze --for ` hides the row without claiming done; a hand-raise wakes it.", - "- Keep `ade chat note` current as the work changes; do not wait until the end.", "- If the lane, branch, or chat name is wrong, rename it: `ade chat generate-names`, `ade chat update --title`, or `ade lanes rename`.", ].join("\n"); +/** + * Pin every ADE CLI socket selector to the same runtime. Different command + * entry points prefer different variables, so setting only one can route + * activity reports to a stale or unrelated runtime inherited from a preset. + */ +export function buildAdeRuntimeSocketEnv(runtimeSocketPath: string | null | undefined): Record { + const socketPath = runtimeSocketPath?.trim(); + return socketPath + ? { + ADE_RPC_URL: socketPath, + ADE_RPC_SOCKET_PATH: socketPath, + ADE_RUNTIME_SOCKET_PATH: socketPath, + } + : {}; +} + +export type AdeSessionActivityTarget = + | { type: "environment" } + /** OpenCode's shared server cannot receive this session's environment. */ + | { type: "inline"; runtimeSocketPath: string }; + +/** + * Agent-set activity is emitted only by provider call sites that have verified + * a shell/command tool path and the runtime-resolved ADE CLI executable. The + * CLI path is injected into ADE-managed provider processes as ADE_CLI_PATH; the + * explicit session id keeps the report scoped even for shared provider hosts. + */ +export function buildAdeSessionActivityGuidance(args: { + sessionId: string; + cliPath: string | null | undefined; + shell: "posix" | "powershell"; + target?: AdeSessionActivityTarget; +}): string | null { + const sessionId = args.sessionId.trim(); + if (!sessionId || !args.cliPath?.trim()) return null; + const cliPath = args.cliPath; + const target = args.target ?? { type: "environment" }; + const runtimeSocketPath = target.type === "inline" ? target.runtimeSocketPath.trim() || null : null; + if (target.type === "inline" && !runtimeSocketPath) return null; + + const quoteShellValue = (value: string): string => args.shell === "powershell" + ? `'${value.replace(/'/g, "''")}'` + : `'${value.replace(/'/g, "'\\''")}'`; + const safeSessionId = quoteShellValue(sessionId); + const safeCliPath = quoteShellValue(cliPath); + let safeRuntimeSocketPath: string | null = null; + if (runtimeSocketPath) { + safeRuntimeSocketPath = quoteShellValue(runtimeSocketPath); + } + const runtimeTargetEnv: Record = target.type === "inline" && runtimeSocketPath + ? { + ADE_DEFAULT_ROLE: "agent", + ADE_CHAT_SESSION_ID: sessionId, + ...buildAdeRuntimeSocketEnv(runtimeSocketPath), + } + : {}; + let runtimeTargetAssignments = ""; + if (target.type === "inline" && Object.keys(runtimeTargetEnv).length > 0) { + if (args.shell === "powershell") { + runtimeTargetAssignments = `${Object.entries(runtimeTargetEnv) + .map(([name, value]) => `$env:${name} = ${quoteShellValue(value)}`) + .join("; ")}; `; + } else { + runtimeTargetAssignments = `${Object.entries(runtimeTargetEnv) + .map(([name, value]) => `${name}=${quoteShellValue(value)}`) + .join(" ")} `; + } + } + let cliCommand: string; + if (target.type === "inline") { + cliCommand = args.shell === "powershell" ? `& ${safeCliPath}` : safeCliPath; + } else { + cliCommand = args.shell === "powershell" ? '& "$env:ADE_CLI_PATH"' : '"$ADE_CLI_PATH"'; + } + const command = (activity: "testing" | "clear"): string => { + const invoke = `${cliCommand} chat activity ${activity} --session ${safeSessionId}`; + if (target.type !== "inline" || !safeRuntimeSocketPath) return invoke; + return `${runtimeTargetAssignments}${invoke}`; + }; + return [ + `- Report session activity with \`${command("testing")}\`; clear it with \`${command("clear")}\`.`, + ` Choose one current state: ${SESSION_ACTIVITY_VALUES.join(", ")}.`, + ].join("\n"); +} + +/** + * Guidance for a tracked CLI PTY. ADE_ACTIVITY_SESSION_ID is the PTY row id; + * ADE_CHAT_SESSION_ID continues to identify its owning chat for every other + * ADE command. ADE_CLI_PATH is injected by ADE's runtime CLI resolver. + */ +export function buildAdeWindowsTrackedCliActivityGuidance(): string { + return [ + "Activity detail for this tracked ADE CLI session:", + "- When ADE_CLI_PATH and ADE_ACTIVITY_SESSION_ID are available, first identify your command shell. In PowerShell, report activity with `& \"$env:ADE_CLI_PATH\" chat activity testing` and clear it with `& \"$env:ADE_CLI_PATH\" chat activity clear`.", + " In cmd.exe, use `\"%ADE_CLI_PATH%\" chat activity testing` and `\"%ADE_CLI_PATH%\" chat activity clear`. Replace testing with one value from " + + `${SESSION_ACTIVITY_VALUES.join(", ")}.`, + " In Git Bash, use `powershell.exe -NoProfile -Command '& \"$env:ADE_CLI_PATH\" chat activity testing'` and clear with `powershell.exe -NoProfile -Command '& \"$env:ADE_CLI_PATH\" chat activity clear'`.", + " These commands use ADE_ACTIVITY_SESSION_ID to target this terminal row. Do not guess a shell or pass another session id; if none matches, leave activity unchanged.", + ].join("\n"); +} + +export function buildAdePosixTrackedCliActivityGuidance(): string { + const command = '"$ADE_CLI_PATH"'; + return [ + "Activity detail for this tracked ADE CLI session:", + `- When your command shell exposes ADE_CLI_PATH and ADE_ACTIVITY_SESSION_ID, report activity with \`${command} chat activity testing\`; clear it with \`${command} chat activity clear\`. Replace testing with one value from ${SESSION_ACTIVITY_VALUES.join(", ")}.`, + " ADE scopes this command to the tracked terminal row; do not pass another session id.", + ].join("\n"); +} + /** * @deprecated Superseded by {@link buildAdeBootstrapGuidance}. Kept as a thin alias so * existing call sites stay wired to the (now minimal) bootstrap. The previous ~1,000-token diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 66ad32c4dc..4e96e59192 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -27,7 +27,12 @@ import { getAgentSkillRootCandidates, joinAdeAgentSkillRoots, } from "./agentSkillRoots"; -import { buildAdeCliAgentGuidance, buildAdeCliInlineGuidance } from "./adeCliGuidance"; +import { + buildAdeCliAgentGuidance, + buildAdeCliInlineGuidance, + buildAdePosixTrackedCliActivityGuidance, + buildAdeWindowsTrackedCliActivityGuidance, +} from "./adeCliGuidance"; import { isProviderSlashCommandInput } from "./chatSlashCommands"; import { resolveClaudeCliModelAlias } from "./claudeCliModels"; import { grokSupervisionEnv } from "./grokSupervision"; @@ -696,7 +701,10 @@ export function codexComputerUseMcpFlags( ]; } -function workTabCliPreamblePrompt(skillRoots: readonly string[], hasInitialPrompt = false): string { +function workTabCliPreamblePrompt( + skillRoots: readonly string[], + hasInitialPrompt = false, +): string { const launchInstruction = hasInitialPrompt ? [ "ADE session guidance. Treat this as operating guidance for the CLI session", @@ -866,6 +874,8 @@ export function buildTrackedCliLaunchCommand(args: { * alias rewrite. */ preset?: TrackedCliPresetLaunch | null; + /** False when this runtime has no RPC endpoint that can accept agent reports. */ + sessionActivityReportingEnabled?: boolean; }): TrackedCliLaunchCommand { const permissionMode = args.permissionMode ?? "default"; validateLaunchProfilePermissionMode(args.provider, permissionMode); @@ -887,6 +897,13 @@ export function buildTrackedCliLaunchCommand(args: { const modelForLaunch = passthroughModelId ? normalizeCliFlagValue(args.model) : args.model; + const activityGuidance = buildTrackedCliSessionActivityGuidance({ + provider: args.provider, + permissionMode, + droidPermissionMode: args.droidPermissionMode, + hasInitialPrompt: Boolean(initialPrompt), + sessionActivityReportingEnabled: args.sessionActivityReportingEnabled, + }); if (args.provider === "claude") { const commandArgs: string[] = []; @@ -902,7 +919,9 @@ export function buildTrackedCliLaunchCommand(args: { } commandArgs.push(...claudeRuntimeEffortFlags(args.reasoningEffort)); commandArgs.push(...claudeSessionSettingsFlags(args.fastMode, args.reasoningEffort)); - const guidance = buildAdeCliAgentGuidance(skillRoots); + const guidance = [buildAdeCliAgentGuidance(skillRoots), activityGuidance] + .filter((part): part is string => Boolean(part)) + .join("\n\n"); commandArgs.push("--append-system-prompt", guidance); commandArgs.push(...permissionModeToClaudeFlag(permissionMode)); // Windows keeps the user's prompt off argv. ADE launches the bare word @@ -944,7 +963,7 @@ export function buildTrackedCliLaunchCommand(args: { const codexModel = passthroughModelId ? normalizeCliFlagValue(args.model) : resolveCodexCliModelForLaunch(args.model); - const initialInput = workTabCliPrompt(initialPrompt, skillRoots); + const initialInput = workTabCliPrompt(initialPrompt, skillRoots, activityGuidance); const commandArgs: string[] = [ "--no-alt-screen", ...modelToCliFlag(codexModel), @@ -980,7 +999,9 @@ export function buildTrackedCliLaunchCommand(args: { ...permissionModeToCursorFlags(permissionMode), ...modelToCliFlag(cursorModel), ]; - const initialInput = initialPrompt ? workTabCliPrompt(initialPrompt, skillRoots) : null; + const initialInput = initialPrompt + ? workTabCliPrompt(initialPrompt, skillRoots, activityGuidance) + : null; return { command: "cursor-agent", args: commandArgs, @@ -991,7 +1012,7 @@ export function buildTrackedCliLaunchCommand(args: { } if (args.provider === "droid") { - const prompt = workTabCliPrompt(initialPrompt, skillRoots); + const prompt = workTabCliPrompt(initialPrompt, skillRoots, activityGuidance); if (currentPlatform() === "win32") { // Windows Droid has to run through `powershell.exe -Command ` so the // settings JSON can be written to a temp file before droid starts, and @@ -1039,8 +1060,9 @@ export function buildTrackedCliLaunchCommand(args: { if (args.provider === "pi") { const guidance = [ buildAdeCliAgentGuidance(skillRoots), + activityGuidance, `ADE permission policy for this Pi session: ${permissionMode}. Pi has no supported native ADE permission flag, so follow this policy and the ADE guidance without bypassing it.`, - ].join("\n"); + ].filter((part): part is string => Boolean(part)).join("\n\n"); const commandArgs = [ ...modelToCliFlag(resolvePiCliModelForLaunch(modelForLaunch)), ...piThinkingFlags(args.reasoningEffort), @@ -1205,6 +1227,60 @@ export function buildTrackedCliLaunchCommand(args: { }; } +/** + * Expose agent-reported detail only when this CLI launch mode has an ADE + * guidance channel and a command tool ADE can rely on. The PTY host supplies + * ADE_CLI_PATH and ADE_CHAT_SESSION_ID for chat-scoped commands, plus + * ADE_ACTIVITY_SESSION_ID for activity reports, after this prompt is built. + */ +export function buildTrackedCliSessionActivityGuidance(args: { + provider: CliProvider; + permissionMode: AgentChatPermissionMode | null | undefined; + droidPermissionMode?: AgentChatDroidPermissionMode | null; + hasInitialPrompt?: boolean; + sessionActivityReportingEnabled?: boolean; +}): string | null { + if (args.sessionActivityReportingEnabled === false) return null; + const mode = args.permissionMode; + if (!mode || mode === "plan") return null; + + let supported = false; + switch (args.provider) { + case "claude": + // The shell fallback intentionally removes --append-system-prompt. Do + // not promise activity reporting until that launch path can carry it. + supported = false; + break; + case "codex": + case "opencode": + // config-toml deliberately delegates tool permission to external config. + supported = mode !== "config-toml"; + break; + case "cursor": + supported = args.hasInitialPrompt === true + && (mode === "default" || mode === "edit" || mode === "full-auto"); + break; + case "droid": { + const droidMode = args.droidPermissionMode ?? droidPermissionModeFromLegacyPermissionMode(mode); + supported = droidMode === "auto-low" || droidMode === "auto-medium" || droidMode === "auto-high"; + break; + } + case "pi": + // Pi's tracked CLI allowlist grants Bash only in full-auto mode. + supported = mode === "full-auto"; + break; + default: + // Qwen, Kimi, Grok, and Copilot do not yet have a verified tracked-CLI + // path that combines ADE's system guidance, scoped CLI access, and shell. + supported = false; + } + if (!supported) return null; + + return currentPlatform() === "win32" + ? buildAdeWindowsTrackedCliActivityGuidance() + : buildAdePosixTrackedCliActivityGuidance(); +} + /** * This module is shared with the renderer bundle, where `process` may be absent * entirely. Callers only ever compare against `"win32"`, so an unknown host @@ -1370,7 +1446,7 @@ function claudeSessionSettingsFlags( function workTabCliPrompt( initialPrompt: string | null, skillRoots: readonly string[], - additionalGuidance?: string, + additionalGuidance?: string | null, ): string { const preamble = workTabCliPreamblePrompt(skillRoots, Boolean(initialPrompt)); const withAdditionalGuidance = additionalGuidance diff --git a/apps/desktop/src/shared/sessionActivity.test.ts b/apps/desktop/src/shared/sessionActivity.test.ts new file mode 100644 index 0000000000..c41107d1de --- /dev/null +++ b/apps/desktop/src/shared/sessionActivity.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSessionActivityReport } from "./sessionActivity"; + +describe("normalizeSessionActivityReport", () => { + it("normalizes the host-stamped JSON atom", () => { + const report = normalizeSessionActivityReport(JSON.stringify({ + value: "testing", + source: "agent", + updatedAt: "2026-08-01T08:00:00-04:00", + ignored: "field", + })); + + expect(report?.value).toBe("testing"); + expect(report?.source).toBe("agent"); + expect(report?.updatedAt).toBe("2026-08-01T12:00:00.000Z"); + expect(report).not.toHaveProperty("ignored"); + }); + + it("rejects values outside the fixed activity list and malformed reports", () => { + for (const report of [ + { value: "coding", source: "agent", updatedAt: "2026-08-01T12:00:00Z" }, + { value: "testing", source: "model", updatedAt: "2026-08-01T12:00:00Z" }, + { value: "testing", source: "agent", updatedAt: "not-a-date" }, + "{broken", + null, + [], + ]) { + expect(normalizeSessionActivityReport(report)).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/shared/sessionActivity.ts b/apps/desktop/src/shared/sessionActivity.ts new file mode 100644 index 0000000000..65d009bd06 --- /dev/null +++ b/apps/desktop/src/shared/sessionActivity.ts @@ -0,0 +1,44 @@ +import { + SESSION_ACTIVITY_VALUES, + type SessionActivityReport, + type SessionActivityValue, +} from "./types/sessions"; + +const SESSION_ACTIVITY_VALUE_SET: ReadonlySet = new Set(SESSION_ACTIVITY_VALUES); + +/** Agent CLI target for activity reports; unlike ADE_CHAT_SESSION_ID this is a PTY row id. */ +export const SESSION_ACTIVITY_SESSION_ID_ENV = "ADE_ACTIVITY_SESSION_ID"; + +export function isSessionActivityValue(value: unknown): value is SessionActivityValue { + return typeof value === "string" && SESSION_ACTIVITY_VALUE_SET.has(value); +} + +function normalizeTimestamp(value: unknown): string | null { + if (typeof value !== "string" || !value.trim()) return null; + const millis = Date.parse(value); + return Number.isFinite(millis) ? new Date(millis).toISOString() : null; +} + +/** + * Parse the single persisted activity-status atom at an input boundary. + * Unknown values, sources, timestamps, or malformed JSON are treated as absent + * so an old or corrupt row cannot invent a card status. + */ +export function normalizeSessionActivityReport(value: unknown): SessionActivityReport | null { + let candidate = value; + if (typeof candidate === "string") { + try { + candidate = JSON.parse(candidate) as unknown; + } catch { + return null; + } + } + if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) return null; + + const record = candidate as Record; + if (!isSessionActivityValue(record.value) || record.source !== "agent") return null; + const updatedAt = normalizeTimestamp(record.updatedAt); + if (!updatedAt) return null; + + return { value: record.value, source: "agent", updatedAt }; +} diff --git a/apps/desktop/src/shared/sessionStatusPresentation.test.ts b/apps/desktop/src/shared/sessionStatusPresentation.test.ts index 6de0864abf..47bd031cdd 100644 --- a/apps/desktop/src/shared/sessionStatusPresentation.test.ts +++ b/apps/desktop/src/shared/sessionStatusPresentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { sessionElapsedAnchor, sessionElapsedLabel, sessionStatusPresentation } from "./sessionStatusPresentation"; import type { AgentChatUsageLimitResume } from "./types/chat"; +import type { SessionActivityReport } from "./types/sessions"; /** * `sessionElapsedAnchor` is the shared answer to "how long", read by the @@ -164,3 +165,50 @@ describe("sessionStatusPresentation usage-limit resume", () => { })?.label).toBe("Done"); }); }); + +describe("sessionStatusPresentation agent-reported activity", () => { + const report: SessionActivityReport = { + value: "testing", + source: "agent", + updatedAt: "2026-09-22T12:00:10.000Z", + }; + + it("shows one typed activity detail inside a running parent phase", () => { + expect(sessionStatusPresentation("running", {}, { + activityStatus: report, + currentTurnStartedAt: "2026-09-22T12:00:00.000Z", + })).toMatchObject({ + label: "Testing", + glyph: "testing", + tone: "blue", + activityDetail: true, + activitySource: "agent", + activityUpdatedAt: report.updatedAt, + }); + }); + + it("keeps Needs you ahead of an agent-reported activity", () => { + expect(sessionStatusPresentation("needs_you", {}, { + activityStatus: report, + })).toMatchObject({ label: "Needs you", glyph: "needs-you" }); + }); + + it("lets host-detected background monitoring outrank a stale turn report", () => { + expect(sessionStatusPresentation("running", {}, { + activityStatus: report, + liveness: "monitoring", + backgroundWork: { workingCount: 0, monitoringCount: 1 }, + currentTurnStartedAt: null, + })).toMatchObject({ label: "Monitoring", glyph: "monitoring" }); + }); + + it("ignores a report from an earlier turn and keeps a Done parent unchanged", () => { + expect(sessionStatusPresentation("running", {}, { + activityStatus: report, + currentTurnStartedAt: "2026-09-22T12:01:00.000Z", + })?.label).toBe("Working"); + expect(sessionStatusPresentation("idle", {}, { + activityStatus: report, + })?.label).toBe("Done"); + }); +}); diff --git a/apps/desktop/src/shared/sessionStatusPresentation.ts b/apps/desktop/src/shared/sessionStatusPresentation.ts index d2e047b588..d403133644 100644 --- a/apps/desktop/src/shared/sessionStatusPresentation.ts +++ b/apps/desktop/src/shared/sessionStatusPresentation.ts @@ -1,6 +1,7 @@ import { resolveUsageLimitResumeState } from "./chatAutoResume"; import { usageLimitResumeRowStatus } from "./usageLimitResumePresentation"; import type { AgentChatUsageLimitResume } from "./types/chat"; +import type { SessionActivityReport } from "./types/sessions"; import type { CanonicalSessionPhase, SessionBackgroundWork, @@ -60,6 +61,10 @@ export type SessionStatusGlyph = | "working" | "monitoring" | "planning" + | "implementing" + | "testing" + | "reviewing" + | "debugging" | "waiting" | "needs-you" | "done" @@ -88,6 +93,11 @@ export type SessionStatusPresentation = { * report). */ prominent: boolean; + /** This label is a finer activity detail inside the parent phase. */ + activityDetail?: boolean; + /** Present only for a typed status explicitly reported through ADE. */ + activitySource?: SessionActivityReport["source"]; + activityUpdatedAt?: string; }; /** Nested compact rows keep the word only for Needs you / Failed. */ @@ -145,6 +155,10 @@ export type SessionStatusOverlay = { export type SessionStatusActivityContext = { chatActivityMode?: "planning" | null; + /** Typed, host-stamped activity reported through ADE's CLI. */ + activityStatus?: SessionActivityReport | null; + /** Used to reject a report left over from an earlier foreground turn. */ + currentTurnStartedAt?: string | null; /** * Why the session is running, from `canonicalSessionState`. Absent (or * `"turn"`) means a live foreground turn and the plain "Working" copy. @@ -167,6 +181,29 @@ function countSuffix(count: number): string { return count > 1 ? ` ×${count}` : ""; } +const REPORTED_ACTIVITY_PRESENTATION: Record = { + planning: { label: "Planning", tone: "violet", glyph: "planning", showsElapsed: true, prominent: false, activityDetail: true }, + implementing: { label: "Implementing", tone: "blue", glyph: "implementing", showsElapsed: true, prominent: false, activityDetail: true }, + testing: { label: "Testing", tone: "blue", glyph: "testing", showsElapsed: true, prominent: false, activityDetail: true }, + reviewing: { label: "Reviewing", tone: "blue", glyph: "reviewing", showsElapsed: true, prominent: false, activityDetail: true }, + debugging: { label: "Debugging", tone: "blue", glyph: "debugging", showsElapsed: true, prominent: false, activityDetail: true }, + monitoring: { label: "Monitoring", tone: "blue", glyph: "monitoring", showsElapsed: true, prominent: false, activityDetail: true }, +}; + +function currentActivityReport( + report: SessionActivityReport | null | undefined, + currentTurnStartedAt: string | null | undefined, +): SessionActivityReport | null { + if (!report) return null; + const reportedAt = Date.parse(report.updatedAt); + if (!Number.isFinite(reportedAt)) return null; + if (currentTurnStartedAt) { + const turnStartedAt = Date.parse(currentTurnStartedAt); + if (Number.isFinite(turnStartedAt) && reportedAt < turnStartedAt) return null; + } + return report; +} + export function sessionStatusPresentation( phase: CanonicalSessionPhase, overlay: SessionStatusOverlay = {}, @@ -192,10 +229,29 @@ export function sessionStatusPresentation( return { label: "Woke", tone: "amber", glyph: "woke", showsElapsed: false, prominent: true }; } + const liveness = activity.liveness ?? "turn"; + + // A structured ADE report refines a live turn only. When the turn ends, + // host-observed background work (especially Monitoring) becomes the more + // current status and must not be hidden by the agent's last report. + // A structured ADE report never changes the phase; Needs you, snooze, and + // woke remain higher-priority signals. + // The turn timestamp is a second line of defence against stale data arriving + // from an older peer after a new accepted turn has already begun. + const reportedActivity = phase === "running" && liveness === "turn" + ? currentActivityReport(activity.activityStatus, activity.currentTurnStartedAt) + : null; + if (reportedActivity) { + return { + ...REPORTED_ACTIVITY_PRESENTATION[reportedActivity.value], + activitySource: reportedActivity.source, + activityUpdatedAt: reportedActivity.updatedAt, + }; + } + // Planning is a property of a LIVE TURN. A resting session promoted back to // `running` by its background work is not planning anything — its plan-mode // flag is just the mode the finished turn ran in. - const liveness = activity.liveness ?? "turn"; if (phase === "running" && liveness === "turn" && activity.chatActivityMode === "planning") { return { label: "Planning", @@ -203,6 +259,7 @@ export function sessionStatusPresentation( glyph: "planning", showsElapsed: true, prominent: false, + activityDetail: true, }; } @@ -232,6 +289,7 @@ export function sessionStatusPresentation( glyph: "monitoring", showsElapsed: true, prominent: false, + activityDetail: true, }; } return { @@ -240,6 +298,7 @@ export function sessionStatusPresentation( glyph: "working", showsElapsed: true, prominent: false, + activityDetail: true, }; } diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index c004053431..87d96a66fc 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -11,7 +11,7 @@ import type { FileDiff } from "./git"; import type { LaneGitHubIssue, LaneLinearIssue, SessionLinearIssueLink } from "./lanes"; import type { AdeRecoveryErrorCode } from "./recovery"; import type { SessionBackgroundWork } from "../sessionCanonicalState"; -import type { RuntimeProcessSummary } from "./sessions"; +import type { RuntimeProcessSummary, SessionActivityReport } from "./sessions"; import type { SubagentCapability } from "../subagentCapabilities"; import { providerDisplayLabel } from "../pendingInputLabels"; import type { AgentChatStopMode as CanonicalAgentChatStopMode } from "../chatStopModes"; @@ -252,6 +252,7 @@ export type AgentChatReloadClaudePluginsResult = { export type AgentChatCodexApprovalPolicy = "untrusted" | "on-request" | "on-failure" | "never"; export type AgentChatCodexSandbox = "read-only" | "workspace-write" | "danger-full-access"; export type AgentChatCodexConfigSource = "flags" | "config-toml"; +export type AgentChatCodexCollaborationMode = "default" | "plan"; export type AgentChatOpenCodePermissionMode = "plan" | "edit" | "full-auto" | "config-toml"; export type AgentChatDroidPermissionMode = "read-only" | "auto-low" | "auto-medium" | "auto-high" | "agi"; /** Public Droid-native permission values accepted by chat creation surfaces. */ @@ -1746,6 +1747,8 @@ export type AgentChatEvent = // and backward-compatible; a title-only emit carries none of them. permissionMode?: AgentChatPermissionMode; interactionMode?: AgentChatInteractionMode | null; + /** Accepted mode for the active Codex turn; null clears the prior turn's mode. */ + codexEffectiveCollaborationMode?: AgentChatCodexCollaborationMode | null; claudePermissionMode?: AgentChatClaudePermissionMode; codexApprovalPolicy?: AgentChatCodexApprovalPolicy; codexSandbox?: AgentChatCodexSandbox; @@ -2420,6 +2423,10 @@ export type AgentChatSessionSummary = { cursorCloudServiceTier?: CursorCloudServiceTier | null; /** Effective service tier reported by the Codex app-server, when known. */ codexServiceTier?: string | null; + /** Collaboration mode accepted with the active Codex app-server turn/start. */ + codexEffectiveCollaborationMode?: AgentChatCodexCollaborationMode; + /** True when a live Codex runtime confirms there is no accepted turn mode. */ + codexEffectiveCollaborationModeWasCleared?: boolean; executionMode?: AgentChatExecutionMode | null; permissionMode?: AgentChatPermissionMode; interactionMode?: AgentChatInteractionMode | null; @@ -2589,7 +2596,10 @@ export type AgentChatSessionSummary = { * Declared here rather than inferred at each call site so the action registry * and the CLI that formats the result cannot drift on the field's name or type. */ -export type AdeChatSessionSummaryActionResult = AgentChatSessionSummary & { timeZone: string }; +export type AdeChatSessionSummaryActionResult = AgentChatSessionSummary & { + timeZone: string; + activityStatus?: SessionActivityReport | null; +}; export type AgentChatTranscriptEntry = { role: "user" | "assistant"; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index f1fe1c789c..5ace5e37c3 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -20,6 +20,25 @@ import type { ModelId } from "./core"; import type { LaneLinearIssue } from "./lanes"; import type { SessionBackgroundWork } from "../sessionCanonicalState"; +/** Fixed agent-reported activity labels. These never change the parent phase. */ +export const SESSION_ACTIVITY_VALUES = [ + "planning", + "implementing", + "testing", + "reviewing", + "debugging", + "monitoring", +] as const; + +export type SessionActivityValue = (typeof SESSION_ACTIVITY_VALUES)[number]; + +/** One atomically persisted agent report for the session-card substatus. */ +export type SessionActivityReport = { + value: SessionActivityValue; + source: "agent"; + updatedAt: string; +}; + /** * One agent SDK process a session currently owns. * @@ -319,6 +338,10 @@ export type TerminalSessionSummary = { */ settledAt?: string | null; statusNote?: string | null; + /** Optional for older peers and project databases that predate this report. */ + activityStatus?: SessionActivityReport | null; + /** Host timestamp of the latest activity report set or explicit clear. */ + activityStatusChangedAt?: string | null; attentionRequestedAt?: string | null; attentionMessage?: string | null; /** Auditable owner of the current explicit attention declaration. */ diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 57a502c8ad..81627bbcf0 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -23,7 +23,7 @@ import type { ExternalSessionListArgs, ExternalSessionSummary, } from "./externalSessions"; -import type { PtySendToSessionResult, TerminalSessionSummary } from "./sessions"; +import type { PtySendToSessionResult, SessionActivityReport, TerminalSessionSummary } from "./sessions"; import type { PairedRuntimeSyncEnvelope } from "./pairedRuntime"; import type { LinearConnectionStatus } from "./linearSync"; import type { SyncHostConflictPublic, SyncHostReadinessSnapshot } from "./syncHostRecovery"; @@ -871,6 +871,10 @@ export type SyncRosterChat = { archived?: boolean; lastActivityAt?: string | null; preview?: string | null; // last-output preview, hard-truncated (~120 chars) + /** Latest lifecycle event, excluding agent activity reports. */ + lifecycleUpdatedAt?: string | null; + /** Host timestamp of the latest activity report set or explicit clear. */ + activityStatusChangedAt?: string | null; /** * Additive settled-lifecycle projection. Optional so current phones remain * compatible with older hosts and current hosts remain compatible with older @@ -878,6 +882,7 @@ export type SyncRosterChat = { */ settledAt?: string | null; statusNote?: string | null; + activityStatus?: SessionActivityReport | null; attentionRequestedAt?: string | null; attentionMessage?: string | null; lastTurnFailedAt?: string | null; diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 52d3cc239b..aa8d9b421c 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -158,6 +158,7 @@ E10000000000000000000037 /* WorkEventMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000037 /* WorkEventMapping.swift */; }; E10000000000000000000038 /* WorkStatusAndFormattingHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000038 /* WorkStatusAndFormattingHelpers.swift */; }; FB000000000000000000C1C1 /* WorkSessionCanonicalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C1C1 /* WorkSessionCanonicalState.swift */; }; + FB000000000000000000CA01 /* WorkSessionActivityPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000CA01 /* WorkSessionActivityPresentation.swift */; }; E10000000000000000000039 /* WorkChatSessionView+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000039 /* WorkChatSessionView+Timeline.swift */; }; E1000000000000000000003A /* WorkReasoningCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003A /* WorkReasoningCard.swift */; }; E10000000000000000000F0A /* WorkCardExpansion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000F0A /* WorkCardExpansion.swift */; }; @@ -512,6 +513,7 @@ D10000000000000000000037 /* WorkEventMapping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkEventMapping.swift; path = ADE/Views/Work/WorkEventMapping.swift; sourceTree = ""; }; D10000000000000000000038 /* WorkStatusAndFormattingHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkStatusAndFormattingHelpers.swift; path = ADE/Views/Work/WorkStatusAndFormattingHelpers.swift; sourceTree = ""; }; FA000000000000000000C1C1 /* WorkSessionCanonicalState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionCanonicalState.swift; path = ADE/Views/Work/WorkSessionCanonicalState.swift; sourceTree = ""; }; + FA000000000000000000CA01 /* WorkSessionActivityPresentation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionActivityPresentation.swift; path = ADE/Views/Work/WorkSessionActivityPresentation.swift; sourceTree = ""; }; D10000000000000000000039 /* WorkChatSessionView+Timeline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkChatSessionView+Timeline.swift"; path = "ADE/Views/Work/WorkChatSessionView+Timeline.swift"; sourceTree = ""; }; D1000000000000000000003A /* WorkReasoningCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkReasoningCard.swift"; path = "ADE/Views/Work/WorkReasoningCard.swift"; sourceTree = ""; }; D10000000000000000000F0A /* WorkCardExpansion.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkCardExpansion.swift"; path = "ADE/Views/Work/WorkCardExpansion.swift"; sourceTree = ""; }; @@ -1077,6 +1079,7 @@ D10000000000000000000037 /* WorkEventMapping.swift */, D10000000000000000000038 /* WorkStatusAndFormattingHelpers.swift */, FA000000000000000000C1C1 /* WorkSessionCanonicalState.swift */, + FA000000000000000000CA01 /* WorkSessionActivityPresentation.swift */, ); name = Work; sourceTree = ""; @@ -1896,6 +1899,7 @@ E10000000000000000000037 /* WorkEventMapping.swift in Sources */, E10000000000000000000038 /* WorkStatusAndFormattingHelpers.swift in Sources */, FB000000000000000000C1C1 /* WorkSessionCanonicalState.swift in Sources */, + FB000000000000000000CA01 /* WorkSessionActivityPresentation.swift in Sources */, H10000000000000000000001 /* CtoRootScreen.swift in Sources */, H10000000000000000000002 /* CtoSessionDestinationView.swift in Sources */, H10000000000000000000005 /* CtoIdentityEditor.swift in Sources */, diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index d22eed39da..015b39f600 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1121,6 +1121,11 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var codexApprovalPolicy: String? var codexSandbox: String? var codexConfigSource: String? + /// Collaboration mode accepted by the active Codex turn/start. Older hosts omit it. + var codexEffectiveCollaborationMode: String? = nil + /// True when the host explicitly confirms that no accepted turn mode remains. + /// Older hosts omit this marker, so an absent key is not a clear. + var codexEffectiveCollaborationModeWasCleared: Bool? = nil var opencodePermissionMode: String? var droidPermissionMode: String? var cursorModeSnapshot: RemoteJSONValue? @@ -1130,6 +1135,10 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { /// distinction for the current host. var cursorModeIdWasCleared: Bool? = nil var cursorConfigValues: [String: RemoteJSONValue]? + /// ACP's structured, provider-owned session modes. Older hosts omit it. + var acpConfigSnapshot: RemoteJSONValue? = nil + /// True when a live metadata event explicitly cleared the ACP snapshot. + var acpConfigSnapshotWasCleared: Bool? = nil /// Cursor Cloud agent id when this chat is a live cloud mirror. Older hosts omit it. var cursorCloudAgentId: String? = nil /// `"cloud"` or `"local"`. Older hosts omit it; when present it wins over a @@ -1146,6 +1155,8 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { /// omit this additive snapshot field. var claudeGoal: AgentChatClaudeGoal? = nil var status: String + /// Start of the currently active provider turn; nil when no turn is running. + var currentTurnStartedAt: String? = nil var idleSinceAt: String? var startedAt: String var endedAt: String? @@ -1220,12 +1231,16 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { && lhs.codexApprovalPolicy == rhs.codexApprovalPolicy && lhs.codexSandbox == rhs.codexSandbox && lhs.codexConfigSource == rhs.codexConfigSource + && lhs.codexEffectiveCollaborationMode == rhs.codexEffectiveCollaborationMode + && lhs.codexEffectiveCollaborationModeWasCleared == rhs.codexEffectiveCollaborationModeWasCleared && lhs.opencodePermissionMode == rhs.opencodePermissionMode && lhs.droidPermissionMode == rhs.droidPermissionMode && lhs.cursorModeId == rhs.cursorModeId && lhs.cursorModeIdWasCleared == rhs.cursorModeIdWasCleared && lhs.cursorModeSnapshot == rhs.cursorModeSnapshot && lhs.cursorConfigValues == rhs.cursorConfigValues + && lhs.acpConfigSnapshot == rhs.acpConfigSnapshot + && lhs.acpConfigSnapshotWasCleared == rhs.acpConfigSnapshotWasCleared && lhs.cursorCloudAgentId == rhs.cursorCloudAgentId && lhs.cursorRuntime == rhs.cursorRuntime && lhs.computerUse == rhs.computerUse @@ -1237,6 +1252,7 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { && lhs.automationRunId == rhs.automationRunId && lhs.capabilityMode == rhs.capabilityMode && lhs.status == rhs.status + && lhs.currentTurnStartedAt == rhs.currentTurnStartedAt && lhs.idleSinceAt == rhs.idleSinceAt && lhs.startedAt == rhs.startedAt && lhs.endedAt == rhs.endedAt @@ -1277,6 +1293,9 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { var codexApprovalPolicy: String? var codexSandbox: String? var codexConfigSource: String? + var codexEffectiveCollaborationMode: String? + /// Distinguishes an explicit null clear from an absent field in a partial event. + var codexEffectiveCollaborationModeWasCleared: Bool = false var opencodePermissionMode: String? var droidPermissionMode: String? var cursorModeId: String? @@ -1287,6 +1306,9 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { var cursorModeIdWasCleared: Bool = false var cursorModeSnapshot: RemoteJSONValue? var cursorConfigValues: [String: RemoteJSONValue]? + var acpConfigSnapshot: RemoteJSONValue? + /// Distinguishes an explicit null clear from an absent field in a partial event. + var acpConfigSnapshotWasCleared: Bool = false /// True when the event carried `cursorConfigValues: null` (an intentional /// clear the host emits to drop the cursor config) rather than omitting the /// key. Symmetric with `cursorModeIdWasCleared`: absent-key still means "no @@ -1311,11 +1333,13 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { case codexSandbox case codexSandboxMode case codexConfigSource + case codexEffectiveCollaborationMode case opencodePermissionMode case droidPermissionMode case cursorModeId case cursorModeSnapshot case cursorConfigValues + case acpConfigSnapshot case spawnKind case subagentTakeoverPromptShownAt case usageLimitResume @@ -1336,6 +1360,13 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { codexSandbox = try c.decodeIfPresent(String.self, forKey: .codexSandboxMode) } codexConfigSource = try c.decodeIfPresent(String.self, forKey: .codexConfigSource) + if c.contains(.codexEffectiveCollaborationMode) { + codexEffectiveCollaborationMode = try c.decodeIfPresent(String.self, forKey: .codexEffectiveCollaborationMode) + codexEffectiveCollaborationModeWasCleared = codexEffectiveCollaborationMode == nil + } else { + codexEffectiveCollaborationMode = nil + codexEffectiveCollaborationModeWasCleared = false + } opencodePermissionMode = try c.decodeIfPresent(String.self, forKey: .opencodePermissionMode) droidPermissionMode = try c.decodeIfPresent(String.self, forKey: .droidPermissionMode) // Distinguish `cursorModeId: null` (an intentional clear) from an absent @@ -1349,6 +1380,13 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { cursorModeIdWasCleared = false } cursorModeSnapshot = try c.decodeIfPresent(RemoteJSONValue.self, forKey: .cursorModeSnapshot) + if c.contains(.acpConfigSnapshot) { + acpConfigSnapshot = try c.decodeIfPresent(RemoteJSONValue.self, forKey: .acpConfigSnapshot) + acpConfigSnapshotWasCleared = acpConfigSnapshot == nil + } else { + acpConfigSnapshot = nil + acpConfigSnapshotWasCleared = false + } // Same null-vs-absent distinction as cursorModeId: decodeIfPresent collapses // `cursorConfigValues: null` (an explicit clear) into absent, so gate on // `contains` to record the clear. @@ -1385,6 +1423,8 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { || codexApprovalPolicy != nil || codexSandbox != nil || codexConfigSource != nil + || codexEffectiveCollaborationMode != nil + || codexEffectiveCollaborationModeWasCleared || opencodePermissionMode != nil || droidPermissionMode != nil || cursorModeId != nil @@ -1392,6 +1432,8 @@ struct AgentChatSessionMetaModeUpdate: Decodable, Equatable { || cursorModeSnapshot != nil || cursorConfigValues != nil || cursorConfigValuesWasCleared + || acpConfigSnapshot != nil + || acpConfigSnapshotWasCleared || spawnKind != nil || subagentTakeoverPromptShownAt != nil || subagentTakeoverPromptShownAtWasCleared @@ -1411,6 +1453,13 @@ extension AgentChatSessionSummary { if let v = update.codexApprovalPolicy { codexApprovalPolicy = v } if let v = update.codexSandbox { codexSandbox = v } if let v = update.codexConfigSource { codexConfigSource = v } + if let v = update.codexEffectiveCollaborationMode { + codexEffectiveCollaborationMode = v + codexEffectiveCollaborationModeWasCleared = false + } else if update.codexEffectiveCollaborationModeWasCleared { + codexEffectiveCollaborationMode = nil + codexEffectiveCollaborationModeWasCleared = true + } if let v = update.opencodePermissionMode { opencodePermissionMode = v } if let v = update.droidPermissionMode { droidPermissionMode = v } if let v = update.cursorModeId { @@ -1423,6 +1472,13 @@ extension AgentChatSessionSummary { cursorModeIdWasCleared = true } if let v = update.cursorModeSnapshot { cursorModeSnapshot = v } + if let v = update.acpConfigSnapshot { + acpConfigSnapshot = v + acpConfigSnapshotWasCleared = false + } else if update.acpConfigSnapshotWasCleared { + acpConfigSnapshot = nil + acpConfigSnapshotWasCleared = true + } if let v = update.cursorConfigValues { cursorConfigValues = v } else if update.cursorConfigValuesWasCleared { @@ -1473,6 +1529,13 @@ extension AgentChatSessionSummary { if let v = other.codexApprovalPolicy { codexApprovalPolicy = v } if let v = other.codexSandbox { codexSandbox = v } if let v = other.codexConfigSource { codexConfigSource = v } + if other.codexEffectiveCollaborationModeWasCleared == true { + codexEffectiveCollaborationMode = nil + codexEffectiveCollaborationModeWasCleared = true + } else if let v = other.codexEffectiveCollaborationMode { + codexEffectiveCollaborationMode = v + codexEffectiveCollaborationModeWasCleared = false + } if let v = other.opencodePermissionMode { opencodePermissionMode = v } if let v = other.droidPermissionMode { droidPermissionMode = v } if other.cursorModeIdWasCleared == true { @@ -1484,6 +1547,13 @@ extension AgentChatSessionSummary { } if let v = other.cursorModeSnapshot { cursorModeSnapshot = v } cursorConfigValues = other.cursorConfigValues + if other.acpConfigSnapshotWasCleared == true { + acpConfigSnapshot = nil + acpConfigSnapshotWasCleared = true + } else if let v = other.acpConfigSnapshot { + acpConfigSnapshot = v + acpConfigSnapshotWasCleared = false + } if let v = other.spawnKind { spawnKind = v } if let v = other.subagentTakeoverPromptShownAt { subagentTakeoverPromptShownAt = v } // Mirrored unconditionally, nil included: the cache is authoritative for the @@ -4632,6 +4702,15 @@ struct FilesSearchTextMatch: Codable, Identifiable, Equatable { var preview: String } +/// Agent-authored structured activity, kept separate from the parent session phase. +/// String fields keep newer host values forward-compatible; presentation only +/// displays the six ADE-supported values and the `agent` source. +struct SessionActivityReport: Codable, Equatable { + var value: String + var source: String + var updatedAt: String +} + struct TerminalSessionSummary: Codable, Identifiable, Equatable { var id: String var laneId: String @@ -4646,9 +4725,15 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { var status: String var startedAt: String var endedAt: String? + /// Latest terminal output timestamp (`terminal_sessions.last_output_at`). + /// Separate from `activityStatusChangedAt`, which tracks agent-authored detail. + var lastActivityAt: String? = nil var archivedAt: String? = nil var settledAt: String? = nil var statusNote: String? = nil + var activityStatus: SessionActivityReport? = nil + /// Host timestamp of the latest activity report set or explicit clear. + var activityStatusChangedAt: String? = nil var attentionRequestedAt: String? = nil var attentionMessage: String? = nil var attentionSource: String? = nil @@ -4731,9 +4816,12 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { && lhs.status == rhs.status && lhs.startedAt == rhs.startedAt && lhs.endedAt == rhs.endedAt + && lhs.lastActivityAt == rhs.lastActivityAt && lhs.archivedAt == rhs.archivedAt && lhs.settledAt == rhs.settledAt && lhs.statusNote == rhs.statusNote + && lhs.activityStatus == rhs.activityStatus + && lhs.activityStatusChangedAt == rhs.activityStatusChangedAt && lhs.attentionRequestedAt == rhs.attentionRequestedAt && lhs.attentionMessage == rhs.attentionMessage && lhs.attentionSource == rhs.attentionSource @@ -4786,9 +4874,12 @@ extension TerminalSessionSummary { case status case startedAt case endedAt + case lastActivityAt case archivedAt case settledAt case statusNote + case activityStatus + case activityStatusChangedAt case attentionRequestedAt case attentionMessage case attentionSource @@ -4834,9 +4925,12 @@ extension TerminalSessionSummary { status = try container.decode(String.self, forKey: .status) startedAt = try container.decode(String.self, forKey: .startedAt) endedAt = try container.decodeIfPresent(String.self, forKey: .endedAt) + lastActivityAt = try container.decodeIfPresent(String.self, forKey: .lastActivityAt) archivedAt = try container.decodeIfPresent(String.self, forKey: .archivedAt) settledAt = try container.decodeIfPresent(String.self, forKey: .settledAt) statusNote = try container.decodeIfPresent(String.self, forKey: .statusNote) + activityStatus = try container.decodeIfPresent(SessionActivityReport.self, forKey: .activityStatus) + activityStatusChangedAt = try container.decodeIfPresent(String.self, forKey: .activityStatusChangedAt) attentionRequestedAt = try container.decodeIfPresent(String.self, forKey: .attentionRequestedAt) attentionMessage = try container.decodeIfPresent(String.self, forKey: .attentionMessage) attentionSource = try container.decodeIfPresent(String.self, forKey: .attentionSource) diff --git a/apps/ios/ADE/Models/RemoteRosterModels.swift b/apps/ios/ADE/Models/RemoteRosterModels.swift index 24546fdc3f..564a8f6820 100644 --- a/apps/ios/ADE/Models/RemoteRosterModels.swift +++ b/apps/ios/ADE/Models/RemoteRosterModels.swift @@ -38,11 +38,16 @@ struct RemoteRosterChat: Codable, Equatable, Identifiable { var pinned: Bool? var archived: Bool? var lastActivityAt: String? + /// Latest lifecycle event, excluding agent-authored activity reports. + var lifecycleUpdatedAt: String? = nil var preview: String? // Additive settled-lifecycle projection. Defaults preserve decoding and // memberwise-call compatibility with hosts/builds that predate the fields. var settledAt: String? = nil var statusNote: String? = nil + var activityStatus: SessionActivityReport? = nil + /// Host timestamp of the latest activity report set or explicit clear. + var activityStatusChangedAt: String? = nil var attentionRequestedAt: String? = nil var attentionMessage: String? = nil var lastTurnFailedAt: String? = nil @@ -63,6 +68,119 @@ struct RemoteRosterChat: Codable, Equatable, Identifiable { var launchRail: [ChatLaunchRailSegment]? = nil } +extension RemoteRosterChat { + /// Lifecycle data and agent detail have independent clocks. Older hosts only + /// sent `lastActivityAt`, which could include a non-null activity report; drop + /// that report timestamp from the fallback when it is the value that won. + private var lifecycleFreshness: RemoteRosterTimestamp? { + if let lifecycleUpdatedAt = RemoteRosterTimestamp.parse(lifecycleUpdatedAt) { + return lifecycleUpdatedAt + } + guard let lastActivity = RemoteRosterTimestamp.parse(lastActivityAt) else { return nil } + if let activity = activityStatusFreshness, lastActivity.date <= activity.date { + return nil + } + return lastActivity + } + + private var activityStatusFreshness: RemoteRosterTimestamp? { + RemoteRosterTimestamp.parse(activityStatusChangedAt ?? activityStatus?.updatedAt) + } + + /// Merge a local roster snapshot without allowing activity detail freshness + /// to overwrite lifecycle fields. A clear carries `activityStatusChangedAt` + /// even though `activityStatus` is nil, so it wins over an older report. + func merging(local: RemoteRosterChat) -> RemoteRosterChat { + var merged = self + let localLifecycle = local.lifecycleFreshness + let remoteLifecycle = lifecycleFreshness + if let localLifecycle, + remoteLifecycle.map({ localLifecycle.date >= $0.date }) ?? true { + merged.status = local.status + merged.awaitingInput = local.awaitingInput ?? awaitingInput + merged.pinned = local.pinned ?? pinned + merged.archived = local.archived ?? archived + merged.title = nonEmptyRosterValue(local.title) ?? title + merged.preview = nonEmptyRosterValue(local.preview) ?? preview + merged.settledAt = local.settledAt + merged.statusNote = local.statusNote + merged.attentionRequestedAt = local.attentionRequestedAt + merged.attentionMessage = local.attentionMessage + merged.lastTurnFailedAt = local.lastTurnFailedAt + merged.exitCode = local.exitCode + merged.lifecycleUpdatedAt = local.lifecycleUpdatedAt ?? localLifecycle.timestamp + } + + let localActivity = local.activityStatusFreshness + let remoteActivity = activityStatusFreshness + if let localActivity, + remoteActivity.map({ localActivity.date >= $0.date }) ?? true { + merged.activityStatus = local.activityStatus + merged.activityStatusChangedAt = local.activityStatusChangedAt ?? local.activityStatus?.updatedAt + } + + merged.lastActivityAt = newestRosterTimestamp( + lastActivityAt, + local.lastActivityAt, + activityStatusChangedAt, + activityStatus?.updatedAt, + local.activityStatusChangedAt, + local.activityStatus?.updatedAt + ) ?? lastActivityAt ?? local.lastActivityAt + merged.provider = nonEmptyRosterValue(provider) ?? local.provider + merged.model = nonEmptyRosterValue(model) ?? local.model + merged.toolType = nonEmptyRosterValue(toolType) ?? local.toolType + merged.chatSessionId = nonEmptyRosterValue(chatSessionId) ?? local.chatSessionId + merged.identityKey = nonEmptyRosterValue(identityKey) ?? local.identityKey + merged.applyLocalSnoozeOverlay(local) + return merged + } +} + +private struct RemoteRosterTimestamp { + let timestamp: String + let date: Date + + static func parse(_ raw: String?) -> RemoteRosterTimestamp? { + guard let timestamp = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !timestamp.isEmpty else { + return nil + } + if let date = RemoteRosterTimestampFormatters.fractional.date(from: timestamp) { + return RemoteRosterTimestamp(timestamp: timestamp, date: date) + } + guard let date = RemoteRosterTimestampFormatters.wholeSeconds.date(from: timestamp) else { return nil } + return RemoteRosterTimestamp(timestamp: timestamp, date: date) + } +} + +private func nonEmptyRosterValue(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return value +} + +private func newestRosterTimestamp(_ values: String?...) -> String? { + var newest: RemoteRosterTimestamp? + for value in values { + guard let candidate = RemoteRosterTimestamp.parse(value) else { continue } + if newest == nil || candidate.date > newest!.date { newest = candidate } + } + return newest?.timestamp +} + +private enum RemoteRosterTimestampFormatters { + static let fractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static let wholeSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} + struct RemoteRosterLane: Codable, Equatable, Identifiable { var id: String var name: String @@ -520,6 +638,7 @@ extension RemoteRosterChat { func asTerminalSessionSummary(laneName: String) -> TerminalSessionSummary { let strings = sessionStatusStrings + let lifecycleTimestamp = lifecycleFreshness?.timestamp ?? "" return TerminalSessionSummary( id: id, laneId: laneId, @@ -532,11 +651,14 @@ extension RemoteRosterChat { toolType: toolType, title: (title?.isEmpty == false ? title! : "Untitled chat"), status: strings.status, - startedAt: lastActivityAt ?? "", - endedAt: status == .ended ? lastActivityAt : nil, - archivedAt: archived == true ? (lastActivityAt ?? "") : nil, + startedAt: lifecycleTimestamp, + endedAt: status == .ended ? lifecycleTimestamp : nil, + lastActivityAt: lifecycleTimestamp.isEmpty ? nil : lifecycleTimestamp, + archivedAt: archived == true ? lifecycleTimestamp : nil, settledAt: settledAt, statusNote: statusNote, + activityStatus: activityStatus, + activityStatusChangedAt: activityStatusChangedAt, attentionRequestedAt: attentionRequestedAt, attentionMessage: attentionMessage, lastTurnFailedAt: lastTurnFailedAt, diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index e18b941840..cc911146b3 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -236,6 +236,8 @@ create table if not exists terminal_sessions ( woke_at text, woke_reason text, chat_session_id text, + activity_status_json text, + activity_status_changed_at text, owner_process_started_at text, foreign key(lane_id) references lanes(id) ); diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 7131fc59d7..3fad1c0431 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -109,6 +109,9 @@ final class DatabaseService { let snoozedAt: String? let wokeAt: String? let wokeReason: String? + let activityStatus: SessionActivityReport? + let activityStatusChangedAt: String? + let lastActivityAt: String? } private struct ComputerUseArtifactRow { @@ -1098,8 +1101,8 @@ final class DatabaseService { exit_code, transcript_path, head_sha_start, head_sha_end, status, last_output_preview, last_output_at, summary, runtime_state, resume_command, resume_metadata_json, manually_named, chat_idle_since_at, chat_session_id, pending_input_item_id, archived_at, settled_at, status_note, attention_requested_at, attention_message, attention_source, last_turn_failed_at, - settle_override, settle_source, snoozed_until, snoozed_at, woke_at, woke_reason - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + settle_override, settle_source, snoozed_until, snoozed_at, woke_at, woke_reason, activity_status_json, activity_status_changed_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(id) do update set lane_id = excluded.lane_id, lane_name = excluded.lane_name, @@ -1138,7 +1141,9 @@ final class DatabaseService { snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, woke_at = excluded.woke_at, - woke_reason = excluded.woke_reason + woke_reason = excluded.woke_reason, + activity_status_json = excluded.activity_status_json, + activity_status_changed_at = excluded.activity_status_changed_at """) { statement in try bindText(session.id, to: statement, index: 1) try bindText(session.laneId, to: statement, index: 2) @@ -1189,7 +1194,11 @@ final class DatabaseService { } else { sqlite3_bind_null(statement, 17) } - try bindText(session.endedAt ?? session.startedAt, to: statement, index: 18) + if let lastActivityAt = session.lastActivityAt { + try bindText(lastActivityAt, to: statement, index: 18) + } else { + sqlite3_bind_null(statement, 18) + } if let summary = session.summary { try bindText(summary, to: statement, index: 19) } else { @@ -1283,6 +1292,12 @@ final class DatabaseService { } else { sqlite3_bind_null(statement, 39) } + try bindOptionalJson(session.activityStatus, to: statement, index: 40) + if let changedAt = session.activityStatusChangedAt ?? session.activityStatus?.updatedAt { + try bindText(changedAt, to: statement, index: 41) + } else { + sqlite3_bind_null(statement, 41) + } } } @@ -1899,7 +1914,8 @@ final class DatabaseService { s.head_sha_start, s.head_sha_end, s.last_output_preview, s.summary, s.runtime_state, s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at, s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.attention_source, s.last_turn_failed_at, - s.settle_override, s.settle_source, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason + s.settle_override, s.settle_source, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason, + s.activity_status_json, s.activity_status_changed_at, s.last_output_at from terminal_sessions s left join lanes l on l.id = s.lane_id where l.project_id = ? @@ -1924,7 +1940,8 @@ final class DatabaseService { s.head_sha_start, s.head_sha_end, s.last_output_preview, s.summary, s.runtime_state, s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at, s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.attention_source, s.last_turn_failed_at, - s.settle_override, s.settle_source, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason + s.settle_override, s.settle_source, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason, + s.activity_status_json, s.activity_status_changed_at, s.last_output_at from terminal_sessions s left join lanes l on l.id = s.lane_id where s.id = ? and (l.project_id = ? or l.id is null) @@ -1977,7 +1994,10 @@ final class DatabaseService { snoozedUntil: stringValue(statement, index: 34), snoozedAt: stringValue(statement, index: 35), wokeAt: stringValue(statement, index: 36), - wokeReason: stringValue(statement, index: 37) + wokeReason: stringValue(statement, index: 37), + activityStatus: decodeJson(stringValue(statement, index: 38), as: SessionActivityReport.self), + activityStatusChangedAt: stringValue(statement, index: 39), + lastActivityAt: stringValue(statement, index: 40) ) } @@ -1996,9 +2016,12 @@ final class DatabaseService { status: row.status, startedAt: row.startedAt, endedAt: row.endedAt, + lastActivityAt: row.lastActivityAt, archivedAt: row.archivedAt, settledAt: row.settledAt, statusNote: row.statusNote, + activityStatus: row.activityStatus, + activityStatusChangedAt: row.activityStatusChangedAt ?? row.activityStatus?.updatedAt, attentionRequestedAt: row.attentionRequestedAt, attentionMessage: row.attentionMessage, attentionSource: row.attentionSource, @@ -2909,6 +2932,16 @@ final class DatabaseService { columnName: "status_note", definition: "text" ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "activity_status_json", + definition: "text" + ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "activity_status_changed_at", + definition: "text" + ) try ensureColumn( tableName: "terminal_sessions", columnName: "attention_requested_at", diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index adc7a9c9ed..6c2aec7067 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -23412,6 +23412,16 @@ extension SyncService { pinned: session.pinned, archived: false, lastActivityAt: latestTimestamp( + session.lastActivityAt, + session.activityStatusChangedAt, + session.attentionRequestedAt, + session.settledAt, + session.lastTurnFailedAt, + session.endedAt, + session.startedAt + ), + lifecycleUpdatedAt: latestTimestamp( + session.lastActivityAt, session.attentionRequestedAt, session.settledAt, session.lastTurnFailedAt, @@ -23421,6 +23431,8 @@ extension SyncService { preview: session.lastOutputPreview, settledAt: session.settledAt, statusNote: session.statusNote, + activityStatus: session.activityStatus, + activityStatusChangedAt: session.activityStatusChangedAt ?? session.activityStatus?.updatedAt, attentionRequestedAt: session.attentionRequestedAt, attentionMessage: session.attentionMessage, lastTurnFailedAt: session.lastTurnFailedAt, @@ -23499,7 +23511,7 @@ extension SyncService { var chatIndexById = Dictionary(uniqueKeysWithValues: merged.chats.enumerated().map { ($0.element.id, $0.offset) }) for localChat in local.chats { if let index = chatIndexById[localChat.id] { - merged.chats[index] = mergedRosterChat(remote: merged.chats[index], local: localChat) + merged.chats[index] = merged.chats[index].merging(local: localChat) } else { chatIndexById[localChat.id] = merged.chats.count merged.chats.append(localChat) @@ -23513,40 +23525,6 @@ extension SyncService { return merged.excludingIdentityChats() } - private func mergedRosterChat(remote: RemoteRosterChat, local: RemoteRosterChat) -> RemoteRosterChat { - var merged = remote - let localIsAtLeastAsFresh = (local.lastActivityAt ?? "") >= (remote.lastActivityAt ?? "") - - if localIsAtLeastAsFresh { - merged.status = local.status - merged.awaitingInput = local.awaitingInput ?? remote.awaitingInput - merged.pinned = local.pinned ?? remote.pinned - merged.archived = local.archived ?? remote.archived - merged.lastActivityAt = nonEmptyRosterString(local.lastActivityAt) ?? remote.lastActivityAt - merged.title = nonEmptyRosterString(local.title) ?? remote.title - merged.preview = nonEmptyRosterString(local.preview) ?? remote.preview - merged.settledAt = local.settledAt - merged.statusNote = local.statusNote - merged.attentionRequestedAt = local.attentionRequestedAt - merged.attentionMessage = local.attentionMessage - merged.lastTurnFailedAt = local.lastTurnFailedAt - merged.exitCode = local.exitCode - } - - merged.provider = nonEmptyRosterString(remote.provider) ?? local.provider - merged.model = nonEmptyRosterString(remote.model) ?? local.model - merged.toolType = nonEmptyRosterString(remote.toolType) ?? local.toolType - merged.chatSessionId = nonEmptyRosterString(remote.chatSessionId) ?? local.chatSessionId - merged.identityKey = nonEmptyRosterString(remote.identityKey) ?? local.identityKey - merged.applyLocalSnoozeOverlay(local) - return merged - } - - private func nonEmptyRosterString(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } - return value - } - private func isRosterTopLevelToolType(_ toolType: String?) -> Bool { let raw = toolType? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/ADE/Shared/ActivityRowPresentation.swift b/apps/ios/ADE/Shared/ActivityRowPresentation.swift index df4aa94220..5df36483cc 100644 --- a/apps/ios/ADE/Shared/ActivityRowPresentation.swift +++ b/apps/ios/ADE/Shared/ActivityRowPresentation.swift @@ -52,7 +52,8 @@ public func activityStatusShoutsLabel(glyph: ActivityGlyph?, tone: ActivityTone) /// /// Shape carries the meaning as far as colour does — the five read apart at /// 9pt on a lock screen and under any colour-vision deficiency, which is why -/// none of them is a bare dot in a different hue. +/// none of them is a bare dot in a different hue. The activity-detail glyphs +/// refine Work-row capsules only; they do not change the coarser grouped counts. /// /// **What is shared and what is not.** The *glyph identity* is the contract /// (`ACTIVITY_STATE_GLYPHS` in `renderer/components/activity/activityPresentation.ts`); @@ -65,6 +66,11 @@ public func activityStatusShoutsLabel(glyph: ActivityGlyph?, tone: ActivityTone) public enum ActivityGlyph: String, Codable, Hashable, Sendable { case working case planning + case implementing + case testing + case reviewing + case debugging + case monitoring case waiting case needsYou case done @@ -83,6 +89,11 @@ public enum ActivityGlyph: String, Codable, Hashable, Sendable { // Same notepad the notch strip uses; `list.bullet.rectangle` lost its // rules below ~10pt and read as a smear. case .planning: return "note.text" + case .implementing: return "chevron.left.forwardslash.chevron.right" + case .testing: return "flask.fill" + case .reviewing: return "magnifyingglass" + case .debugging: return "ladybug.fill" + case .monitoring: return "eye.fill" case .waiting: return "hourglass" // A filled dot, not a bell. The bell said "notification"; the row is // not a notification, it is a state, and the strip/island read it diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index 93600058e5..5ec79189c0 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -625,7 +625,7 @@ struct HubScreen: View { var chatIndexById = Dictionary(uniqueKeysWithValues: merged.chats.enumerated().map { ($0.element.id, $0.offset) }) for localChat in local.chats { if let index = chatIndexById[localChat.id] { - merged.chats[index] = mergedHubChat(remote: merged.chats[index], local: localChat) + merged.chats[index] = merged.chats[index].merging(local: localChat) } else { chatIndexById[localChat.id] = merged.chats.count merged.chats.append(localChat) @@ -639,34 +639,6 @@ struct HubScreen: View { return merged.excludingIdentityChats() } - private func mergedHubChat(remote: RemoteRosterChat, local: RemoteRosterChat) -> RemoteRosterChat { - var merged = remote - let localIsAtLeastAsFresh = (local.lastActivityAt ?? "") >= (remote.lastActivityAt ?? "") - - if localIsAtLeastAsFresh { - merged.status = local.status - merged.awaitingInput = local.awaitingInput ?? remote.awaitingInput - merged.pinned = local.pinned ?? remote.pinned - merged.archived = local.archived ?? remote.archived - merged.lastActivityAt = nonEmpty(local.lastActivityAt) ?? remote.lastActivityAt - merged.title = nonEmpty(local.title) ?? remote.title - merged.preview = nonEmpty(local.preview) ?? remote.preview - } - - merged.provider = nonEmpty(remote.provider) ?? local.provider - merged.model = nonEmpty(remote.model) ?? local.model - merged.toolType = nonEmpty(remote.toolType) ?? local.toolType - merged.chatSessionId = nonEmpty(remote.chatSessionId) ?? local.chatSessionId - merged.identityKey = nonEmpty(remote.identityKey) ?? local.identityKey - merged.applyLocalSnoozeOverlay(local) - return merged - } - - private func nonEmpty(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } - return value - } - private func toggle(_ set: inout Set, _ id: String) { if set.contains(id) { set.remove(id) } else { set.insert(id) } } diff --git a/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift b/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift index eaf14110a1..d0b71380ee 100644 --- a/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift @@ -323,7 +323,7 @@ func workSessionDisplayTitle(session: TerminalSessionSummary, summary: AgentChat } func workSessionActivityTimestamp(session: TerminalSessionSummary, summary: AgentChatSessionSummary?) -> String { - summary?.lastActivityAt ?? session.chatIdleSinceAt ?? session.startedAt + summary?.lastActivityAt ?? session.lastActivityAt ?? session.chatIdleSinceAt ?? session.startedAt } func workSessionRuntimeLabel(session: TerminalSessionSummary) -> String { diff --git a/apps/ios/ADE/Views/Work/WorkSessionActivityPresentation.swift b/apps/ios/ADE/Views/Work/WorkSessionActivityPresentation.swift new file mode 100644 index 0000000000..619a633d8e --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkSessionActivityPresentation.swift @@ -0,0 +1,99 @@ +private let workCopilotPlanModeId = "https://agentclientprotocol.com/protocol/session-modes#plan" + +private func workCursorPlanningModeId(_ snapshot: RemoteJSONValue?) -> String? { + guard case .object(let object)? = snapshot, + case .string(let currentModeId)? = object["currentModeId"] + else { return nil } + return currentModeId +} + +/// Extract ACP's current mode using the same currentModeId → `mode` option +/// fallback as desktop. Non-string option values are not mode identifiers. +private func workAcpCurrentModeId(_ snapshot: RemoteJSONValue?) -> String? { + guard case .object(let object)? = snapshot else { return nil } + if let currentMode = object["currentModeId"] { + switch currentMode { + case .string(let value): return value + case .null: break + default: return nil + } + } + guard case .array(let options)? = object["configOptions"], + let option = options.first(where: { value in + guard case .object(let fields) = value, + case .string(let id)? = fields["id"] + else { return false } + return id == "mode" + }), + case .object(let fields) = option, + case .string(let value)? = fields["currentValue"] + else { return nil } + return value +} + +/// Planning is a presentation fact, never a canonical phase. Mirror desktop's +/// provider-specific `chatIsPlanning` checks. Permission posture alone is not +/// evidence that a provider accepted Plan mode. +func workSessionIsPlanning(summary: AgentChatSessionSummary?) -> Bool { + guard let summary else { return false } + switch summary.provider { + case "claude": + return summary.interactionMode == "plan" + case "codex": + return summary.codexEffectiveCollaborationMode == "plan" + case "cursor": + if summary.cursorModeIdWasCleared == true || summary.cursorModeId != nil { + return summary.cursorModeId == "plan" + } + return workCursorPlanningModeId(summary.cursorModeSnapshot) == "plan" + case "droid": + return summary.interactionMode == "plan" + case "opencode": + return summary.opencodePermissionMode == "plan" + case "qwen", "kimi": + guard summary.acpConfigSnapshotWasCleared != true else { return false } + return workAcpCurrentModeId(summary.acpConfigSnapshot) == "plan" + case "copilot": + guard summary.acpConfigSnapshotWasCleared != true else { return false } + return workAcpCurrentModeId(summary.acpConfigSnapshot) == workCopilotPlanModeId + default: + return false + } +} + +/// A current, agent-reported detail that refines a running Work row's one +/// status slot. Foreground chat reports must belong to the current turn; a +/// tracked CLI row has no chat turn marker, so its explicit report is the best +/// available signal. +func workSessionActivityDetailPresentation( + session: TerminalSessionSummary, + phase: CanonicalSessionPhase, + currentTurnStartedAt: String? +) -> WorkSessionStatusPresentation? { + guard phase == .running else { return nil } + + let hasLiveChatTurn = !isWorkChatToolType(session.toolType) + || currentTurnStartedAt.flatMap(workParsedDate) != nil + guard hasLiveChatTurn, + let activityStatus = session.activityStatus, + activityStatus.source == "agent", + ["planning", "implementing", "testing", "reviewing", "debugging", "monitoring"].contains(activityStatus.value), + let updatedAt = workParsedDate(activityStatus.updatedAt) + else { return nil } + + let isStaleForTurn = currentTurnStartedAt + .flatMap(workParsedDate) + .map { updatedAt < $0 } ?? false + guard !isStaleForTurn else { return nil } + + let isPlanning = activityStatus.value == "planning" + return WorkSessionStatusPresentation( + label: activityStatus.value.capitalized, + tone: isPlanning ? .violet : .blue, + glyph: ActivityGlyph(rawValue: activityStatus.value) ?? .working, + showsElapsed: true, + prominent: false, + kind: nil, + activityReportUpdatedAt: activityStatus.updatedAt + ) +} diff --git a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift index 571450e0a3..a241a2e62d 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift @@ -300,14 +300,6 @@ func workActivityPhase(for phase: CanonicalSessionPhase) -> AccountAttentionPhas } } -/// Planning is a PRESENTATION fact, never a canonical phase — the same split -/// desktop makes, where `chatActivityMode` is derived from the chat's -/// interaction mode and folded in at render time -/// (`chatSessionProjection.ts`: `interactionMode === "plan"`). -func workSessionIsPlanning(summary: AgentChatSessionSummary?) -> Bool { - summary?.interactionMode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "plan" -} - /// Which capsule identity a phase wears, once planning has already been folded /// in by the caller. `stopped`/`ended` have none: their story is the neutral dot /// and the timestamp, and a row must not shift layout to say "nothing is @@ -400,6 +392,7 @@ struct WorkSessionStatusPresentation: Equatable { let showsElapsed: Bool let prominent: Bool let kind: SessionBadgeKind? + var activityReportUpdatedAt: String? = nil } /// Everything one Work row renders about its state, derived ONCE. @@ -462,7 +455,8 @@ func workSessionRowPresentation( phase: phase, resolved: resolved, now: now, - usageLimitStatus: usageLimitStatus + usageLimitStatus: usageLimitStatus, + currentTurnStartedAt: summary?.currentTurnStartedAt ) // Whether the usage-limit overlay actually took the slot is the slot // function's own answer, not something to re-derive from the rendered glyph: @@ -502,9 +496,13 @@ private func workUsageLimitStatusMayOwnSlot(_ phase: CanonicalSessionPhase) -> B /// slot; neutral and not prominent, because a row you deferred is not /// asking for anything. /// 3. woke — it came back early, which is worth the eye: amber and prominent. -/// 4. planning — a presentation fact derived from the chat's interaction mode, -/// never a canonical phase. Already folded into `resolved` by the caller. -/// 5. the phase table. +/// 4. settled — no status slot; the collapsed section and timestamp carry the +/// resting row's context. +/// 5. usage limit — only for ready/idle/failed, where it can quiet an old +/// failure while a resume is scheduled. +/// 6. fresh agent activity detail — only for a running turn; native Planning +/// is a separate presentation fact folded into `resolved` by the caller. +/// 7. the phase table, including that pre-resolved Planning presentation. /// /// `ownedByUsageLimit` is true only when the usage-limit overlay below actually /// took the slot. The caller needs that fact to drop the badge and borrow the @@ -521,7 +519,8 @@ private func workSessionStatusSlot( phase: CanonicalSessionPhase, resolved: (kind: SessionBadgeKind?, presentation: ActivityPhasePresentation), now: Date, - usageLimitStatus: WorkUsageLimitRowStatus? + usageLimitStatus: WorkUsageLimitRowStatus?, + currentTurnStartedAt: String? ) -> (presentation: WorkSessionStatusPresentation?, ownedByUsageLimit: Bool) { // needsYou skips the overlay gate entirely and falls straight through to the // phase table below, which already says "Needs you" in amber. @@ -582,6 +581,14 @@ private func workSessionStatusSlot( ), true) } + if let activityPresentation = workSessionActivityDetailPresentation( + session: session, + phase: phase, + currentTurnStartedAt: currentTurnStartedAt + ) { + return (activityPresentation, false) + } + return (WorkSessionStatusPresentation( label: resolved.presentation.label, tone: resolved.presentation.tone, @@ -638,18 +645,15 @@ func workCanonicalSessionState( } /// The genuine last-activity timestamp used ONLY to drive the stale check. -/// Unlike `workSessionActivityTimestamp` (which feeds display/sort and falls -/// back to `session.startedAt`), this returns nil when no real activity signal -/// exists — the iOS `TerminalSessionSummary` carries no desktop-style -/// `lastActivityAt`, so a plain terminal has no output timestamp. Falling back -/// to `startedAt` would flag any terminal open >3h as Stale even with output -/// seconds ago; nil disables the check, mirroring the desktop caller which -/// passes the real `lastActivityAt` or null (never `startedAt`). +/// Unlike `workSessionActivityTimestamp` (which falls back to the session's +/// start time for display), this returns nil when no real activity signal +/// exists. Falling back to `startedAt` would flag a terminal open >3h as stale +/// even if it never produced output; nil disables the check, matching desktop. private func workSessionStaleActivityTimestamp( session: TerminalSessionSummary, summary: AgentChatSessionSummary? ) -> String? { - summary?.lastActivityAt ?? session.chatIdleSinceAt + summary?.lastActivityAt ?? session.lastActivityAt ?? session.chatIdleSinceAt } // MARK: - Snooze — a synced VISIBILITY OVERLAY, deliberately NOT a lifecycle state diff --git a/apps/ios/ADE/Views/Work/WorkSessionRowCard.swift b/apps/ios/ADE/Views/Work/WorkSessionRowCard.swift index 45283665bd..4e1071f3a4 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionRowCard.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionRowCard.swift @@ -172,6 +172,7 @@ private struct WorkSessionRowRenderSignature: Equatable { /// one `Text` instead of invalidating every visible row. let statusLabel: String? let statusGlyph: ActivityGlyph? + let statusElapsedSince: String? /// The slot's hue token, never a resolved `Color`: this signature is built on /// the detached presentation rebuild, and `ActivityTone` is a String-backed /// enum that crosses that boundary safely where a `Color` would not. @@ -268,6 +269,9 @@ private struct WorkSessionRowRenderSignature: Equatable { self.rowTone = row.tone self.statusLabel = row.status?.label self.statusGlyph = row.status?.glyph + self.statusElapsedSince = row.status?.showsElapsed == true + ? (chatSummary?.currentTurnStartedAt ?? row.status?.activityReportUpdatedAt ?? self.activityTimestamp) + : nil self.showsElapsed = row.status?.showsElapsed ?? false // Settled resolves to a nil presentation, so it is not prominent and recedes. // That is the intent, not an oversight: the timestamp owns its slot. @@ -841,9 +845,7 @@ struct WorkSessionRow: View, Equatable { tone: tone, glyph: renderSignature.statusGlyph, showsElapsed: renderSignature.showsElapsed, - elapsedSince: renderSignature.showsElapsed - ? workParsedDate(renderSignature.activityTimestamp) - : nil, + elapsedSince: workParsedDate(renderSignature.statusElapsedSince), wraps: wraps, // The slot pulses once when this flips true underneath a reader who is // already looking at the row. Passing the phase rather than a trigger @@ -956,10 +958,8 @@ struct WorkSessionRow: View, Equatable { /// fact that leaves the VISUAL row must not leave VoiceOver with it. Every /// clause below reads a model field, not a view. var accessibilityLabel: String { - var parts = [chatSummary?.title ?? session.title, session.laneName, sessionStatusLabel(for: status)] - if let statusLabel = renderSignature.statusLabel { - parts.append(statusLabel) - } + let effectiveStatus = renderSignature.statusLabel ?? sessionStatusLabel(for: status) + var parts = [chatSummary?.title ?? session.title, session.laneName, effectiveStatus] if renderSignature.steeringInput && renderSignature.statusGlyph == .working { parts.append("has a question") } @@ -1118,12 +1118,10 @@ struct WorkSessionRowStatusSlot: View { let tone: ActivityTone let glyph: ActivityGlyph? let showsElapsed: Bool - /// **Divergence, stated so nobody hunts for a bug.** iOS's - /// `TerminalSessionSummary` carries no `currentTurnStartedAt`, so the ticker - /// anchors on the row's activity timestamp: it measures time since last - /// activity, where desktop's `SessionStatusSlot` measures time since the turn - /// started. On a live turn the two agree closely; on a quiet one this reads - /// larger. + /// Elapsed time uses the chat summary's `currentTurnStartedAt` when present, + /// matching desktop. When that anchor is absent, iOS falls back to the + /// activity report's update time and then the row's activity timestamp; + /// desktop and CLI fall back directly to last activity. let elapsedSince: Date? /// Accessibility sizes let the slot wrap instead of holding one line. var wraps: Bool = false diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index f65745c55b..a2f17304d1 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -839,7 +839,7 @@ func normalizedWorkChatSessionStatus(session: TerminalSessionSummary?, summary: // moved in over 7 days is almost certainly never going to resume. Keep // explicit awaiting-input sessions visible until they are resolved or closed. if raw == "active" || raw == "idle" { - let lastActivityRaw = summary?.lastActivityAt ?? session?.chatIdleSinceAt ?? session?.startedAt + let lastActivityRaw = summary?.lastActivityAt ?? session?.lastActivityAt ?? session?.chatIdleSinceAt ?? session?.startedAt if let last = lastActivityRaw, let date = workChatLastActivityDate(last), Date().timeIntervalSince(date) > workChatStaleAfterSeconds { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index b3810e4bfa..4995b84110 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -11175,7 +11175,20 @@ final class ADETests: XCTestCase { session.attentionRequestedAt = "2026-03-17T00:13:00.000Z" session.attentionMessage = "Choose the release target" session.lastTurnFailedAt = "2026-03-17T00:14:00.000Z" - try database.replaceTerminalSessions([session]) + session.lastActivityAt = "2026-03-17T00:15:30.000Z" + session.activityStatus = SessionActivityReport( + value: "testing", + source: "agent", + updatedAt: "2026-03-17T00:15:00.000Z" + ) + session.activityStatusChangedAt = "2026-03-17T00:16:00.000Z" + var outputlessSession = session + outputlessSession.id = "session-no-output" + outputlessSession.startedAt = "2026-03-17T00:00:00.000Z" + outputlessSession.lastActivityAt = nil + outputlessSession.activityStatus = nil + outputlessSession.activityStatusChangedAt = nil + try database.replaceTerminalSessions([session, outputlessSession]) let stored = try XCTUnwrap(database.fetchSessions().first) XCTAssertEqual(stored.chatSessionId, "chat-abc") @@ -11184,6 +11197,11 @@ final class ADETests: XCTestCase { XCTAssertEqual(stored.attentionRequestedAt, "2026-03-17T00:13:00.000Z") XCTAssertEqual(stored.attentionMessage, "Choose the release target") XCTAssertEqual(stored.lastTurnFailedAt, "2026-03-17T00:14:00.000Z") + XCTAssertEqual(stored.lastActivityAt, "2026-03-17T00:15:30.000Z") + XCTAssertNil(database.fetchSession(id: "session-no-output")?.lastActivityAt) + XCTAssertEqual(stored.activityStatus?.value, "testing") + XCTAssertEqual(stored.activityStatus?.source, "agent") + XCTAssertEqual(stored.activityStatusChangedAt, "2026-03-17T00:16:00.000Z") // Round-trip via JSON to confirm the wire-format Codable layer preserves the field too. let encoded = try JSONEncoder().encode(stored) @@ -11194,6 +11212,9 @@ final class ADETests: XCTestCase { XCTAssertEqual(decoded.attentionRequestedAt, stored.attentionRequestedAt) XCTAssertEqual(decoded.attentionMessage, stored.attentionMessage) XCTAssertEqual(decoded.lastTurnFailedAt, stored.lastTurnFailedAt) + XCTAssertEqual(decoded.lastActivityAt, stored.lastActivityAt) + XCTAssertEqual(decoded.activityStatus, stored.activityStatus) + XCTAssertEqual(decoded.activityStatusChangedAt, stored.activityStatusChangedAt) // Decoding a payload that omits the new field (older desktop builds) still succeeds. let legacyJson = """ @@ -11217,6 +11238,9 @@ final class ADETests: XCTestCase { XCTAssertNil(legacy.attentionRequestedAt) XCTAssertNil(legacy.attentionMessage) XCTAssertNil(legacy.lastTurnFailedAt) + XCTAssertNil(legacy.lastActivityAt) + XCTAssertNil(legacy.activityStatus) + XCTAssertNil(legacy.activityStatusChangedAt) database.close() } @@ -18982,6 +19006,95 @@ final class ADETests: XCTestCase { XCTAssertEqual(summary.requestedCwd, "apps/ios/ADE") } + func testPlanningSummaryAndLiveModeUpdatesDecodeCodexAndACPSignals() throws { + func decodeSummary(_ provider: String, extra: [String: Any]) throws -> AgentChatSessionSummary { + let payload: [String: Any] = [ + "sessionId": "chat-planning", + "laneId": "lane-1", + "provider": provider, + "model": "test-model", + "status": "running", + "startedAt": "2026-03-25T00:00:00.000Z", + "lastActivityAt": "2026-03-25T00:00:01.000Z", + ].merging(extra) { _, new in new } + return try JSONDecoder().decode( + AgentChatSessionSummary.self, + from: JSONSerialization.data(withJSONObject: payload) + ) + } + + var codex = try decodeSummary("codex", extra: [ + "interactionMode": "plan", + "codexEffectiveCollaborationMode": "default", + ]) + XCTAssertFalse(workSessionIsPlanning(summary: codex), "requested Plan is not evidence of accepted Codex Plan") + + codex.codexEffectiveCollaborationMode = "plan" + XCTAssertTrue(workSessionIsPlanning(summary: codex)) + let acceptedDefault = try JSONDecoder().decode( + AgentChatSessionMetaModeUpdate.self, + from: JSONSerialization.data(withJSONObject: [ + "type": "session_meta_updated", + "codexEffectiveCollaborationMode": "default", + ]) + ) + XCTAssertTrue(acceptedDefault.hasAnyField) + codex.applyModeUpdate(acceptedDefault) + XCTAssertFalse(workSessionIsPlanning(summary: codex)) + XCTAssertEqual(codex.codexEffectiveCollaborationMode, "default") + + var liveCodex = try decodeSummary("codex", extra: ["codexEffectiveCollaborationMode": "plan"]) + var clearedCodex = liveCodex + let clearCodex = try JSONDecoder().decode( + AgentChatSessionMetaModeUpdate.self, + from: JSONSerialization.data(withJSONObject: [ + "type": "session_meta_updated", + "codexEffectiveCollaborationMode": NSNull(), + ]) + ) + XCTAssertTrue(clearCodex.codexEffectiveCollaborationModeWasCleared) + XCTAssertTrue(clearCodex.hasAnyField) + clearedCodex.applyModeUpdate(clearCodex) + liveCodex.mergeModeFields(from: clearedCodex) + XCTAssertNil(liveCodex.codexEffectiveCollaborationMode) + XCTAssertFalse(workSessionIsPlanning(summary: liveCodex)) + + var cachedPlan = try decodeSummary("codex", extra: ["codexEffectiveCollaborationMode": "plan"]) + let refreshedClear = try decodeSummary("codex", extra: [ + "codexEffectiveCollaborationModeWasCleared": true, + ]) + cachedPlan.mergeModeFields(from: refreshedClear) + XCTAssertNil(cachedPlan.codexEffectiveCollaborationMode) + XCTAssertFalse(workSessionIsPlanning(summary: cachedPlan)) + + var cachedPlanFromOlderHost = try decodeSummary( + "codex", + extra: ["codexEffectiveCollaborationMode": "plan"] + ) + let olderHostSummary = try decodeSummary("codex", extra: [:]) + cachedPlanFromOlderHost.mergeModeFields(from: olderHostSummary) + XCTAssertEqual(cachedPlanFromOlderHost.codexEffectiveCollaborationMode, "plan") + XCTAssertTrue(workSessionIsPlanning(summary: cachedPlanFromOlderHost)) + + var qwen = try decodeSummary("qwen", extra: [ + "acpConfigSnapshot": ["currentModeId": "plan"], + ]) + XCTAssertEqual(qwen.acpConfigSnapshot, .object(["currentModeId": .string("plan")])) + XCTAssertTrue(workSessionIsPlanning(summary: qwen)) + let clearACP = try JSONDecoder().decode( + AgentChatSessionMetaModeUpdate.self, + from: JSONSerialization.data(withJSONObject: [ + "type": "session_meta_updated", + "acpConfigSnapshot": NSNull(), + ]) + ) + XCTAssertTrue(clearACP.acpConfigSnapshotWasCleared) + XCTAssertTrue(clearACP.hasAnyField) + qwen.applyModeUpdate(clearACP) + XCTAssertNil(qwen.acpConfigSnapshot) + XCTAssertFalse(workSessionIsPlanning(summary: qwen)) + } + func testAgentChatSessionSummaryPreservesExplicitCursorModeClear() throws { let payload: [String: Any] = [ "sessionId": "chat-cleared", @@ -28255,6 +28368,7 @@ final class RosterDeltaTests: XCTestCase { } func testRosterLifecyclePayloadBuildsSettledAndAttentionCanonicalStates() throws { + let now = ISO8601DateFormatter().date(from: "2026-07-23T10:05:00Z")! let settledData = Data(""" { "id": "chat-settled", @@ -28271,14 +28385,15 @@ final class RosterDeltaTests: XCTestCase { XCTAssertEqual(settledSession.settledAt, "2026-07-23T10:00:00.000Z") XCTAssertEqual(settledSession.statusNote, "Shipped the lifecycle mirror") - XCTAssertEqual(workCanonicalSessionState(session: settledSession, summary: nil).phase, .settled) + XCTAssertEqual(workCanonicalSessionState(session: settledSession, summary: nil, now: now).phase, .settled) XCTAssertEqual( workSessionGroups( organization: .byStatus, sessions: [settledSession], chatSummaries: [:], archivedSessionIds: [], - orderedLanes: [] + orderedLanes: [], + now: now ).map(\.id), [workSettledSectionId] ) @@ -28286,9 +28401,9 @@ final class RosterDeltaTests: XCTestCase { var activeSettledSession = settledSession activeSettledSession.status = "running" activeSettledSession.runtimeState = "running" - XCTAssertEqual(workCanonicalSessionState(session: activeSettledSession, summary: nil).phase, .running) + XCTAssertEqual(workCanonicalSessionState(session: activeSettledSession, summary: nil, now: now).phase, .running) activeSettledSession.runtimeState = "idle" - XCTAssertEqual(workCanonicalSessionState(session: activeSettledSession, summary: nil).phase, .settled) + XCTAssertEqual(workCanonicalSessionState(session: activeSettledSession, summary: nil, now: now).phase, .settled) let attentionData = Data(""" { @@ -28308,7 +28423,7 @@ final class RosterDeltaTests: XCTestCase { XCTAssertEqual(attentionSession.attentionMessage, "Choose the release target") XCTAssertEqual(attentionSession.lastTurnFailedAt, "2026-07-23T09:30:00.000Z") - XCTAssertEqual(workCanonicalSessionState(session: attentionSession, summary: nil).phase, .needsYou) + XCTAssertEqual(workCanonicalSessionState(session: attentionSession, summary: nil, now: now).phase, .needsYou) } func testRosterCleanExitAndLegacyPayloadRemainCompatible() throws { diff --git a/apps/ios/ADETests/WorkLiveRosterHydrationTests.swift b/apps/ios/ADETests/WorkLiveRosterHydrationTests.swift index a81b66ab62..f0efaa814b 100644 --- a/apps/ios/ADETests/WorkLiveRosterHydrationTests.swift +++ b/apps/ios/ADETests/WorkLiveRosterHydrationTests.swift @@ -2,6 +2,113 @@ import XCTest @testable import ADE final class WorkLiveRosterHydrationTests: XCTestCase { + func testNewerLocalActivityDoesNotReplaceNewerRemoteAwaitingLifecycle() { + var remote = makeRosterChat(id: "chat-1", laneId: "lane-1") + remote.title = "Remote title" + remote.status = .awaiting + remote.awaitingInput = true + remote.lastActivityAt = "2026-07-22T12:04:00.000Z" + remote.lifecycleUpdatedAt = "2026-07-22T12:04:00.000Z" + remote.preview = "Remote preview" + remote.attentionRequestedAt = "2026-07-22T12:04:00.000Z" + + var local = makeRosterChat(id: "chat-1", laneId: "lane-1") + local.title = "Stale title" + local.status = .running + local.awaitingInput = false + local.lastActivityAt = "2026-07-22T12:05:00.000Z" + local.lifecycleUpdatedAt = "2026-07-22T12:00:00.000Z" + local.preview = "Stale preview" + local.activityStatus = SessionActivityReport( + value: "testing", + source: "agent", + updatedAt: "2026-07-22T12:05:00.000Z" + ) + + let merged = remote.merging(local: local) + + XCTAssertEqual(merged.status, .awaiting) + XCTAssertEqual(merged.awaitingInput, true) + XCTAssertEqual(merged.title, "Remote title") + XCTAssertEqual(merged.preview, "Remote preview") + XCTAssertEqual(merged.attentionRequestedAt, "2026-07-22T12:04:00.000Z") + XCTAssertEqual(merged.activityStatus?.value, "testing") + XCTAssertEqual(merged.lastActivityAt, "2026-07-22T12:05:00.000Z") + } + + func testExplicitLocalActivityClearBeatsStaleRemoteReport() { + var remote = makeRosterChat(id: "chat-1", laneId: "lane-1") + remote.title = "Remote title" + remote.status = .awaiting + remote.awaitingInput = true + remote.lastActivityAt = "2026-07-22T12:04:00.000Z" + remote.lifecycleUpdatedAt = "2026-07-22T12:04:00.000Z" + remote.activityStatus = SessionActivityReport( + value: "monitoring", + source: "agent", + updatedAt: "2026-07-22T12:02:00.000Z" + ) + remote.activityStatusChangedAt = "2026-07-22T12:02:00.000Z" + + var local = makeRosterChat(id: "chat-1", laneId: "lane-1") + local.title = "Older local title" + local.status = .running + local.awaitingInput = false + local.lastActivityAt = "2026-07-22T12:05:00.000Z" + local.lifecycleUpdatedAt = "2026-07-22T12:03:00.000Z" + local.activityStatus = nil + local.activityStatusChangedAt = "2026-07-22T12:05:00.000Z" + + let merged = remote.merging(local: local) + + XCTAssertEqual(merged.status, .awaiting) + XCTAssertEqual(merged.awaitingInput, true) + XCTAssertEqual(merged.title, "Remote title") + XCTAssertNil(merged.activityStatus) + XCTAssertEqual(merged.activityStatusChangedAt, "2026-07-22T12:05:00.000Z") + XCTAssertEqual(merged.lastActivityAt, "2026-07-22T12:05:00.000Z") + } + + func testActivityReportOnlyFreshnessDoesNotBecomeLifecycleWhenMaterialized() { + var chat = makeRosterChat(id: "chat-1", laneId: "lane-1") + chat.lastActivityAt = "2026-07-22T12:05:00.000Z" + chat.activityStatus = SessionActivityReport( + value: "testing", + source: "agent", + updatedAt: "2026-07-22T12:05:00.000Z" + ) + + let session = chat.asTerminalSessionSummary(laneName: "Lane") + + XCTAssertEqual(session.startedAt, "") + XCTAssertNil(session.endedAt) + XCTAssertNil(session.lastActivityAt) + XCTAssertEqual(session.activityStatus?.updatedAt, "2026-07-22T12:05:00.000Z") + } + + @MainActor + func testActiveProjectLocalRosterUsesLastActivityForLifecycleFreshness() throws { + let database = DatabaseService(baseURL: makeTemporaryDirectory()) + defer { database.close() } + try database.executeSqlForTesting(""" + insert into projects (id, root_path, display_name, default_base_ref, created_at, last_opened_at) + values ('project-1', '/tmp/project-1', 'Project', 'main', '2026-07-22T00:00:00.000Z', '2026-07-22T00:00:00.000Z'); + """) + + let service = SyncService(database: database) + service.setActiveProjectForTesting(projectId: "project-1", rootPath: "/tmp/project-1") + let lane = makeLane(id: "lane-1", name: "Feature") + try database.replaceLaneSnapshots([lane]) + + var session = makeSession(id: "chat-1", laneId: lane.id, laneName: lane.name) + session.lastActivityAt = "2026-07-22T12:05:00.000Z" + try database.replaceTerminalSessions([session]) + + let chat = try XCTUnwrap(service.buildActiveProjectLocalRoster()?.chats.first) + XCTAssertEqual(chat.lifecycleUpdatedAt, "2026-07-22T12:05:00.000Z") + XCTAssertEqual(chat.lastActivityAt, "2026-07-22T12:05:00.000Z") + } + func testAuthoritativeRosterChatLaneBeatsEarlierStaleLaneAndBranchHints() { let project = makeProject(id: "project-1", name: "ADE") let stale = makeRosterLane(id: "lane-stale", name: "Stale", branch: "feature/stale") diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 3d67bd859f..67e97a0a39 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -348,7 +348,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { Case( name: "planning chat", session: makeSession(status: "running", runtimeState: "running", toolType: "codex-chat", startedAt: iso(now)), - summary: makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan"), + summary: makeChatSummary(status: "active", awaitingInput: false, codexEffectiveCollaborationMode: "plan"), kind: .planning, label: "Planning", tone: .violet @@ -612,6 +612,95 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertFalse(ActivityPhaseVocabulary.presentation(for: workActivityPhase(for: .starting)).prominent) } + func testAgentActivityRefinesRunningStatusAndKeepsNeedsYouAhead() { + let values = ["planning", "implementing", "testing", "reviewing", "debugging", "monitoring"] + let glyphs: [String: ActivityGlyph] = [ + "planning": .planning, + "implementing": .implementing, + "testing": .testing, + "reviewing": .reviewing, + "debugging": .debugging, + "monitoring": .monitoring, + ] + XCTAssertEqual(Set(glyphs.values.map(\.systemImage)).count, values.count) + for value in values { + var session = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat") + session.activityStatus = SessionActivityReport( + value: value, + source: "agent", + updatedAt: iso(now.addingTimeInterval(-60)) + ) + var summary = makeChatSummary(status: "active", awaitingInput: false) + summary.currentTurnStartedAt = iso(now.addingTimeInterval(-120)) + let status = workSessionRowPresentation(session: session, summary: summary, now: now).status + XCTAssertEqual(status?.label, value.capitalized, value) + XCTAssertEqual(status?.tone, value == "planning" ? .violet : .blue, value) + XCTAssertEqual(status?.glyph, glyphs[value], value) + XCTAssertEqual(status?.showsElapsed, true, value) + } + + var blocked = makeSession( + status: "running", + runtimeState: "waiting-input", + toolType: "codex-chat", + pendingInputItemId: "ask-1" + ) + blocked.activityStatus = SessionActivityReport( + value: "testing", + source: "agent", + updatedAt: iso(now.addingTimeInterval(-60)) + ) + let status = workSessionRowPresentation( + session: blocked, + summary: makeChatSummary(status: "active", awaitingInput: false), + now: now + ).status + XCTAssertEqual(status?.label, "Needs you") + XCTAssertEqual(status?.kind, .needsYou) + } + + func testAgentActivityDoesNotOverrideSnoozeOrAReportFromAnEarlierTurn() { + var snoozed = snoozedSession(untilOffset: 1_800, atOffset: -60) + snoozed.activityStatus = SessionActivityReport( + value: "monitoring", + source: "agent", + updatedAt: iso(now.addingTimeInterval(-30)) + ) + XCTAssertEqual( + workSessionRowPresentation(session: snoozed, summary: nil, now: now).status?.label, + "wakes in 30m" + ) + + var running = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat") + running.activityStatus = SessionActivityReport( + value: "testing", + source: "agent", + updatedAt: iso(now.addingTimeInterval(-120)) + ) + var summary = makeChatSummary(status: "active", awaitingInput: false) + summary.currentTurnStartedAt = iso(now.addingTimeInterval(-60)) + XCTAssertEqual( + workSessionRowPresentation(session: running, summary: summary, now: now).status?.label, + "Working" + ) + + var backgroundSession = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat") + backgroundSession.activityStatus = SessionActivityReport( + value: "testing", + source: "agent", + updatedAt: iso(now.addingTimeInterval(-30)) + ) + var backgroundSummary = makeChatSummary(status: "active", awaitingInput: false) + backgroundSummary.activeBackgroundTaskCount = 1 + let backgroundStatus = workSessionRowPresentation( + session: backgroundSession, + summary: backgroundSummary, + now: now + ).status + XCTAssertEqual(backgroundStatus?.label, "Working") + XCTAssertEqual(backgroundStatus?.glyph, .working) + } + func testCodexSteeringInputStaysWorkingInsteadOfNeedsYou() { let session = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat") var summary = makeChatSummary(status: "active", awaitingInput: false) @@ -681,11 +770,11 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertNil(status?.kind) } - /// Planning is a presentation fact derived from the chat's interaction mode, - /// exactly as on desktop — it never becomes a canonical phase. + /// Planning is a presentation fact derived from the provider's current + /// structured mode, exactly as on desktop — it never becomes a canonical phase. func testPlanningNeverBecomesACanonicalPhase() { let session = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat", startedAt: iso(now)) - let summary = makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan") + let summary = makeChatSummary(status: "active", awaitingInput: false, codexEffectiveCollaborationMode: "plan") XCTAssertEqual(workCanonicalSessionState(session: session, summary: summary, now: now).phase, .running) XCTAssertEqual( @@ -703,7 +792,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { toolType: "codex-chat", pendingInputItemId: "approval-1" ) - let summary = makeChatSummary(status: "active", awaitingInput: false, interactionMode: "plan") + let summary = makeChatSummary(status: "active", awaitingInput: false, codexEffectiveCollaborationMode: "plan") XCTAssertEqual( workSessionRowPresentation(session: session, summary: summary, now: now).badge?.kind, @@ -711,6 +800,75 @@ final class WorkSessionCanonicalStateTests: XCTestCase { ) } + func testCodexPlanningRequiresTheModeAcceptedByTheActiveTurn() { + let session = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat", startedAt: iso(now)) + let requestedPlanAcceptedDefault = makeChatSummary( + status: "active", + awaitingInput: false, + interactionMode: "plan", + codexEffectiveCollaborationMode: "default" + ) + let requestedPlanBeforeAcceptance = makeChatSummary( + status: "active", + awaitingInput: false, + interactionMode: "plan" + ) + let acceptedPlan = makeChatSummary( + status: "active", + awaitingInput: false, + interactionMode: "default", + codexEffectiveCollaborationMode: "plan" + ) + + XCTAssertEqual(workSessionRowPresentation(session: session, summary: requestedPlanAcceptedDefault, now: now).status?.label, "Working") + XCTAssertEqual(workSessionRowPresentation(session: session, summary: requestedPlanBeforeAcceptance, now: now).status?.label, "Working") + XCTAssertEqual(workSessionRowPresentation(session: session, summary: acceptedPlan, now: now).status?.label, "Planning") + } + + func testPlanningUsesTheSameProviderSpecificSignalsAsDesktop() { + let acpModePlan = RemoteJSONValue.object(["currentModeId": .string("plan")]) + let acpConfigOptionPlan = RemoteJSONValue.object([ + "configOptions": .array([ + .object(["id": .string("mode"), "currentValue": .string("plan")]), + ]), + ]) + let copilotPlan = RemoteJSONValue.object([ + "currentModeId": .string("https://agentclientprotocol.com/protocol/session-modes#plan"), + ]) + let acpBooleanMode = RemoteJSONValue.object([ + "configOptions": .array([ + .object(["id": .string("mode"), "currentValue": .bool(true)]), + ]), + ]) + let cursorSnapshotPlan = RemoteJSONValue.object(["currentModeId": .string("plan")]) + let cursorSnapshotNonCanonical = RemoteJSONValue.object(["currentModeId": .string(" plan ")]) + let cases: [(String, AgentChatSessionSummary, Bool)] = [ + ("Claude interaction mode", makeChatSummary(status: "active", awaitingInput: false, provider: "claude", interactionMode: "plan"), true), + ("Claude permission posture", makeChatSummary(status: "active", awaitingInput: false, provider: "claude", interactionMode: "default", permissionMode: "plan"), false), + ("Codex accepted mode", makeChatSummary(status: "active", awaitingInput: false, provider: "codex", codexEffectiveCollaborationMode: "plan"), true), + ("Cursor current mode id", makeChatSummary(status: "active", awaitingInput: false, provider: "cursor", cursorModeId: "plan"), true), + ("Cursor explicit plan id wins over snapshot", makeChatSummary(status: "active", awaitingInput: false, provider: "cursor", cursorModeSnapshot: .object(["currentModeId": .string("agent")]), cursorModeId: "plan"), true), + ("Cursor current snapshot", makeChatSummary(status: "active", awaitingInput: false, provider: "cursor", cursorModeSnapshot: cursorSnapshotPlan), true), + ("Cursor snapshot mode id is exact", makeChatSummary(status: "active", awaitingInput: false, provider: "cursor", cursorModeSnapshot: cursorSnapshotNonCanonical), false), + ("Cursor explicit agent id suppresses stale plan snapshot", makeChatSummary(status: "active", awaitingInput: false, provider: "cursor", cursorModeSnapshot: cursorSnapshotPlan, cursorModeId: "agent"), false), + ("Cursor explicit clear suppresses stale plan snapshot", makeChatSummary(status: "active", awaitingInput: false, provider: "cursor", cursorModeSnapshot: cursorSnapshotPlan, cursorModeIdWasCleared: true), false), + ("Droid interaction mode", makeChatSummary(status: "active", awaitingInput: false, provider: "droid", interactionMode: "plan"), true), + ("Droid read-only permission", makeChatSummary(status: "active", awaitingInput: false, provider: "droid", droidPermissionMode: "read-only"), false), + ("OpenCode native permission mode", makeChatSummary(status: "active", awaitingInput: false, provider: "opencode", opencodePermissionMode: "plan"), true), + ("OpenCode legacy permission", makeChatSummary(status: "active", awaitingInput: false, provider: "opencode", interactionMode: "plan", permissionMode: "plan"), false), + ("Qwen native mode", makeChatSummary(status: "active", awaitingInput: false, provider: "qwen", acpConfigSnapshot: acpModePlan), true), + ("Kimi mode option", makeChatSummary(status: "active", awaitingInput: false, provider: "kimi", acpConfigSnapshot: acpConfigOptionPlan), true), + ("Copilot protocol mode", makeChatSummary(status: "active", awaitingInput: false, provider: "copilot", acpConfigSnapshot: copilotPlan), true), + ("ACP boolean option", makeChatSummary(status: "active", awaitingInput: false, provider: "qwen", acpConfigSnapshot: acpBooleanMode), false), + ("Grok has no trustworthy mode", makeChatSummary(status: "active", awaitingInput: false, provider: "grok", acpConfigSnapshot: acpModePlan), false), + ("Pi has no provider mode", makeChatSummary(status: "active", awaitingInput: false, provider: "pi", interactionMode: "plan"), false), + ] + + for (name, summary, expected) in cases { + XCTAssertEqual(workSessionIsPlanning(summary: summary), expected, name) + } + } + func testCanonicalPhasesMapOntoTheSharedVocabulary() { XCTAssertEqual(workActivityPhase(for: .running), .running) XCTAssertEqual(workActivityPhase(for: .starting), .starting) @@ -1125,12 +1283,22 @@ final class WorkSessionCanonicalStateTests: XCTestCase { status: String, awaitingInput: Bool?, pendingInputItemId: String? = nil, - interactionMode: String? = nil + provider: String = "codex", + interactionMode: String? = nil, + permissionMode: String? = nil, + codexEffectiveCollaborationMode: String? = nil, + opencodePermissionMode: String? = nil, + droidPermissionMode: String? = nil, + cursorModeSnapshot: RemoteJSONValue? = nil, + cursorModeId: String? = nil, + cursorModeIdWasCleared: Bool? = nil, + acpConfigSnapshot: RemoteJSONValue? = nil, + acpConfigSnapshotWasCleared: Bool? = nil ) -> AgentChatSessionSummary { AgentChatSessionSummary( sessionId: "chat-1", laneId: "lane-1", - provider: "codex", + provider: provider, model: "gpt-5.4", modelId: nil, sessionProfile: nil, @@ -1140,17 +1308,21 @@ final class WorkSessionCanonicalStateTests: XCTestCase { codexFastMode: nil, fastMode: nil, executionMode: nil, - permissionMode: nil, + permissionMode: permissionMode, interactionMode: interactionMode, claudePermissionMode: nil, codexApprovalPolicy: nil, codexSandbox: nil, codexConfigSource: nil, - opencodePermissionMode: nil, - droidPermissionMode: nil, - cursorModeSnapshot: nil, - cursorModeId: nil, + codexEffectiveCollaborationMode: codexEffectiveCollaborationMode, + opencodePermissionMode: opencodePermissionMode, + droidPermissionMode: droidPermissionMode, + cursorModeSnapshot: cursorModeSnapshot, + cursorModeId: cursorModeId, + cursorModeIdWasCleared: cursorModeIdWasCleared, cursorConfigValues: nil, + acpConfigSnapshot: acpConfigSnapshot, + acpConfigSnapshotWasCleared: acpConfigSnapshotWasCleared, identityKey: nil, surface: nil, automationId: nil, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36e7ac146c..e3ea576452 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -224,7 +224,7 @@ than a hot spin. **Required ADE account auth.** `ade login` preserves the local-browser loopback OAuth path, but selects the account-directory device authorization bridge for explicit `--headless`, SSH, display-less hosts, or a failed browser launch. The brain generates and retains the device redemption secret, polls the bridge, and persists the resulting refresh-capable session under `account.session.v1`. For a JWT access token, its decoded `exp` claim is authoritative over the OAuth `expires_in` bookkeeping: status reports that expiry, and `getAccessToken()` refreshes inside the two-minute skew even when an older stored session record claims a later expiry. Tokens without a usable JWT expiry retain the stored `expiresAt` fallback. The desktop, CLI, and ADE Code do not independently exchange the rotating refresh credential: they install a refresh broker that asks the brain for an access token, with a local exchange only as the explicit unavailable-brain fallback. The brain persists successful rotation before best-effort enrichment, and raw tokens are never logged. `ADE_ACCOUNT_TOKEN` takes precedence without starting a login flow: JWT access credentials are used through their declared expiry, while refresh credentials are exchanged and rotated only in memory. `ade account token create` wraps the current interactive refresh credential with its public issuer/client context in a versioned secret envelope, so a newly provisioned agent or CI host needs no local Clerk configuration. Legacy raw opaque refresh tokens retain local-config compatibility and return migration guidance when that config is absent. Distributed CLI/brain binaries and packaged Electron set `ADE_RUNTIME_PACKAGED=1` before account services start. In that mode, a Clerk issuer or JWKS URL under `*.clerk.accounts.dev`, plus the exact ADE development directory override, is rejected atomically in favor of the complete built-in production OAuth, attestation, and directory configuration; a non-development custom issuer remains valid, and source checkouts retain their existing override behavior. Persisted sessions pinned to a development issuer/client, sessions carrying a development `iss` access-token claim, and equivalent `ADE_ACCOUNT_TOKEN` credentials are rejected before token return, refresh, userinfo, or directory use. A rejected environment credential is treated as absent by status, access-token resolution, interactive login, device login, and durable-token provisioning, so it cannot block a new production sign-in. When the credential store supports atomic updates (`supportsAtomicCredentialUpdate`, asked before the write because a compare-and-swap degraded to check-then-set is a different write that can clobber a peer), a persisted development session is compare-and-deleted through the shared `updateCredentialKeySync` ladder, then persistence is re-read exactly once: a peer-written acceptable production replacement is returned in the same status call. Without compare-and-delete support, ADE leaves the stored value untouched to avoid erasing a peer write but continues to report that development session as signed out. The audited outcome (`erased` or `rejected_locally`) reports what the write actually did rather than what the store claimed to support: the mutator declines when a peer replaced the record, and a store write that throws is logged as `account.session_write_failed` and treated as a write that did not happen — the local rejection still stops this process serving the session. `ADE_ALLOW_DEVELOPMENT_CLERK=1` is the explicit packaged-build escape hatch for controlled development testing. The desktop Account page exposes one honest browser continuation because the bridge opens the generic hosted account flow rather than selecting a provider; the browser presents whichever methods are enabled. Native iOS uses ClerkKit's transferable OAuth result to distinguish new accounts from returning users. Its identifier-first email path starts sign-in, falls back to sign-up only for Clerk's precise account-not-found codes, sends the sign-up email verification code, and verifies against the matching sign-in or sign-up attempt. Account status exposes `loopback`, `device`, or `env-token`. ADE requires an account from the first run on every machine, headless included. A session lost afterwards never gates local projects, `ade code`, local pairing, or PIN workflows: the launch gate offers a pass-through, and a permanent, non-dismissable shell bar nags on every surface until the user signs in. When product analytics is enabled, a known signed-in account produces one quota-counted PostHog `$identify` using only a one-way account hash plus plan, platform, and app version; explicit sign-out rotates the analytics anonymous identity. -**Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial chat message, `ade chat send` / `message` / `steer` / `wait` for peer chat delivery and status polling, silent bounded `ade chat read ` / `--page --cursor ` reads for any project-backed chat registered with the machine brain, `ade chat note` / `ask` for the current Work row (settling is not agent-reachable — see below), `ade chat scheduled-work create --cron "" --prompt "" [--once]` for durable provider-neutral scheduling, `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment, and `--parent ` / `--no-parent` to control child-chat lineage — a chat or agent-provider CLI created inside a tracked agent shell defaults its parent to `$ADE_CHAT_SESSION_ID`; every parented launch must declare `--type subagent|peer`, where `subagent` is the coordinated/default choice for work the parent will join or review and `peer` is fire-and-forget; `--no-parent` deliberately creates an independent top-level session), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade apple` — see [features/apple-device/README.md](./features/apple-device/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), universal search (`ade search ""` over chats, terminals, PRs, commits, branches, lanes, files, and Linear — see [features/search/README.md](./features/search/README.md)), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (low-level normal-turn send), `chat.messageSession` (normalized peer delivery: auto, queue, wake, interrupt-replace), `chat.readTranscript`, `chat.readTranscriptPage`, `chat.createScheduledWork`, `chat.listScheduledWork`, `chat.getScheduledWorkState`, `chat.cancelScheduledWork`, `chat.setScheduledWorkPaused`, `chat.resumeUsageLimitNow` (send the usage-limit continue prompt now instead of waiting for the published reset; it refuses when there is no live limit or the row is already delivering, and throws — after restoring the armed state, its mirror and the durable row — when the dispatch itself fails), and model-catalog actions; `chat.getSessionSummary` answers with the summary plus the brain's IANA `timeZone`, so a CLI caller can render `usageLimitResume.fireAt` and `nextWakeAt` in the zone the brain schedules in; the session action surface includes caller-scoped `requestSessionAttention` and `setSessionStatusNote`, the agent-reachable snooze family (`snoozeSession`, `snoozeSessions`, `wakeSession`, `wakeSessions`, `clearWokeMarker`), and a CTO-only settle family (`settleSession`, `unsettleSession`, `settleSessions`, `unsettleSessions`, `setSettleOverride`). The caller-scoped `settleSelfSession` / `unsettleSelfSession` pair was removed in 2026-07: deciding that work is finished is a subjective judgment agents are unreliable at, so the only settle writers left are user surfaces (the desktop renderer's remote-runtime client and `ade code`, both of which authenticate at cto role) and the deterministic PR-merge policy, which calls `sessionService` directly. A bound agent may target only its own eligible session for still-scoped lifecycle and mutation actions, and an omitted lifecycle target is injected from that binding. `chat.messageSession` remains the reviewed primitive for deliberately messaging another ADE chat through routing semantics. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), named iOS Simulator actions for launch, live view, inspection, input, Preview Lab, device sessions, device tools, the event log, semantic element actions, and proof bundles, and `search` (`query`, `indexStatus`, and the CTO-only `rebuildIndex` against `searchService`; the machine router searches the active project normally and aggregates bounded chat hits from every registered project for session-bound and unbound callers alike). +**Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial chat message, `ade chat send` / `message` / `steer` / `wait` for peer chat delivery and status polling, silent bounded `ade chat read ` / `--page --cursor ` reads for any project-backed chat registered with the machine brain, `ade chat note` / `ask` / `activity` for the current Work row (settling is not agent-reachable — see below), `ade chat scheduled-work create --cron "" --prompt "" [--once]` for durable provider-neutral scheduling, `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment, and `--parent ` / `--no-parent` to control child-chat lineage — a chat or agent-provider CLI created inside a tracked agent shell defaults its parent to `$ADE_CHAT_SESSION_ID`; every parented launch must declare `--type subagent|peer`, where `subagent` is the coordinated/default choice for work the parent will join or review and `peer` is fire-and-forget; `--no-parent` deliberately creates an independent top-level session), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade apple` — see [features/apple-device/README.md](./features/apple-device/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), universal search (`ade search ""` over chats, terminals, PRs, commits, branches, lanes, files, and Linear — see [features/search/README.md](./features/search/README.md)), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (low-level normal-turn send), `chat.messageSession` (normalized peer delivery: auto, queue, wake, interrupt-replace), `chat.readTranscript`, `chat.readTranscriptPage`, `chat.createScheduledWork`, `chat.listScheduledWork`, `chat.getScheduledWorkState`, `chat.cancelScheduledWork`, `chat.setScheduledWorkPaused`, `chat.resumeUsageLimitNow` (send the usage-limit continue prompt now instead of waiting for the published reset; it refuses when there is no live limit or the row is already delivering, and throws — after restoring the armed state, its mirror and the durable row — when the dispatch itself fails), and model-catalog actions; `chat.getSessionSummary` answers with the summary plus the brain's IANA `timeZone`, so a CLI caller can render `usageLimitResume.fireAt` and `nextWakeAt` in the zone the brain schedules in; the session action surface includes caller-scoped `requestSessionAttention`, `setSessionStatusNote`, and `setSessionActivity`, the agent-reachable snooze family (`snoozeSession`, `snoozeSessions`, `wakeSession`, `wakeSessions`, `clearWokeMarker`), and a CTO-only settle family (`settleSession`, `unsettleSession`, `settleSessions`, `unsettleSessions`, `setSettleOverride`). The caller-scoped `settleSelfSession` / `unsettleSelfSession` pair was removed in 2026-07: deciding that work is finished is a subjective judgment agents are unreliable at, so the only settle writers left are user surfaces (the desktop renderer's remote-runtime client and `ade code`, both of which authenticate at cto role) and the deterministic PR-merge policy, which calls `sessionService` directly. A bound agent may target only its own eligible session for still-scoped lifecycle and mutation actions, and an omitted lifecycle target is injected from that binding. `chat.messageSession` remains the reviewed primitive for deliberately messaging another ADE chat through routing semantics. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), named iOS Simulator actions for launch, live view, inspection, input, Preview Lab, device sessions, device tools, the event log, semantic element actions, and proof bundles, and `search` (`query`, `indexStatus`, and the CTO-only `rebuildIndex` against `searchService`; the machine router searches the active project normally and aggregates bounded chat hits from every registered project for session-bound and unbound callers alike). Settings additionally use the brain action domains `account_settings` and `account_vault`. `account_settings` exposes non-secret per-key account @@ -568,7 +568,7 @@ Schema bootstrap in `kvDb.ts` creates ~104 tables. Anchor tables for agents read | `lanes` | Worktree-backed units of work. Types: `primary`, `worktree`, `attached`. Supports parent/child stacks, color/icon/tags. | | `lane_usage_tombstones` | One row per removed lane, written by `cleanupLaneDatabaseRows` before the delete cascade so lifetime activity stats are not survivor stats. Integer counters, the created/deleted calendar days, and a hex active-day bitmap only — no transcript text, path, branch, lane name, or per-day breakdown. `absorbed` duplicates are recorded without counting a second lane created; a lane that failed to finish being created is not tombstoned at all. Read back by `usageStatsStore`. | | `local_worktree_residual_cleanups` | Machine-local lane-delete cleanup debt for residual managed worktree directories. Stores absolute paths and is excluded from CRR replication because only the runtime on that machine can safely retry removal. | -| `terminal_sessions` | Tracked PTY sessions per lane with transcript path and head SHAs. The `chat_session_id` column (indexed) marks terminals owned by a chat (chat terminal drawer, App Control launch terminal); `ptyService` exposes them through the `ade.terminal.*` IPC and the `terminal` ADE action domain. The `owner_pid` column (indexed) identifies the ADE OS process that owns the live runtime for the row — cross-process reconcile/dispose paths check it before sweeping so concurrent surfaces don't mark each other's live sessions dead. See §3.5. Lifecycle lives in five nullable text columns: `settle_override` (tri-state `settled` / `active` / null, consulted before the derived exit-0 settle) and the snooze visibility overlay `snoozed_until` / `snoozed_at` with its `woke_at` / `woke_reason` marker. None of them carry a unique index — the table replicates to iOS through cr-sqlite, and `crsql_as_crr` rejects any non-primary-key unique index — and all five are mirrored in both iOS schema halves (`DatabaseBootstrap.sql` and `Database.swift`'s `ensureColumn` migrations). The settle columns (`settled_at`, `settle_override`, `settle_source`) are host-authoritative: only `sessionService` may decide them, so the sync host drops them from inbound phone changesets and iOS renders an in-flight settle through a local overlay rather than a replicating write. | +| `terminal_sessions` | Tracked PTY sessions per lane with transcript path and head SHAs. The `chat_session_id` column (indexed) marks terminals owned by a chat (chat terminal drawer, App Control launch terminal); `ptyService` exposes them through the `ade.terminal.*` IPC and the `terminal` ADE action domain. The `owner_pid` column (indexed) identifies the ADE OS process that owns the live runtime for the row — cross-process reconcile/dispose paths check it before sweeping so concurrent surfaces don't mark each other's live sessions dead. See §3.5. Lifecycle lives in five nullable text columns: `settle_override` (tri-state `settled` / `active` / null, consulted before the derived exit-0 settle) and the snooze visibility overlay `snoozed_until` / `snoozed_at` with its `woke_at` / `woke_reason` marker. None of them carry a unique index — the table replicates to iOS through cr-sqlite, and `crsql_as_crr` rejects any non-primary-key unique index — and all five lifecycle columns are mirrored in both iOS schema halves (`DatabaseBootstrap.sql` and `Database.swift`'s `ensureColumn` migrations). The separate `activity_status_json` column stores the fixed-value agent-reported card detail and is also present in both iOS schema halves. The settle columns (`settled_at`, `settle_override`, `settle_source`) are host-authoritative: only `sessionService` may decide them, so the sync host drops them from inbound phone changesets and iOS renders an in-flight settle through a local overlay rather than a replicating write. | | `runtime_processes` | Machine-local process-liveness registry. Every ADE process (desktop main, brain process, TUI runtime) inserts a row on boot keyed by the process incarnation (`pid`, `started_at`) and refreshes `last_seen` on a 5 s heartbeat. The table is excluded from CRR replication because PIDs are only meaningful on the current OS; reconcile / dispose paths cross-reference `terminal_sessions.owner_pid` and `owner_process_started_at` against locally known and live rows to tell "row whose local owner crashed" from "row a sibling process is actively managing" without detaching sessions owned by another synced machine. See §3.5. | | `session_deltas` | Post-session diff stats + touched files + failure lines. Input to pack generation. | | `operations` | Audit log of every significant mutation (git, pack updates). Pre/post HEAD SHAs enable undo. | diff --git a/docs/bug-ledger-web-client.md b/docs/bug-ledger-web-client.md index a0c9f1b12c..4577a702ce 100644 --- a/docs/bug-ledger-web-client.md +++ b/docs/bug-ledger-web-client.md @@ -157,12 +157,9 @@ The entire signed-out → machine → project funnel is a custom `WebWorkspaceHu already makes). Prior art: push publisher keeps a transition-gated `statusSinceAt` for Activity (`pushPublisherService.ts:700`, `:855`) with a comment explicitly avoiding this reset bug — Work list just can't see it. -- C7b `scoped` (product, future workstream line) — richer CLI states: today working/idle = OSC 133 prompt markers + - 12 s silence timer (`ptyService.ts:5026-5035`, `terminalSessionSignals.ts:493`); nothing parses TUI content, and - provider JSONL transcripts are read only for chat history. The UI vocabulary already exists: `planning` glyph in - `SessionStatusLabel.tsx:25-26` fed by `chatActivityMode` — detecting Claude Code's footer/plan banner from the PTY - stream (or tailing provider transcripts) and mapping onto `chatActivityMode` lights up planning/asking for CLI - sessions with zero new UI. +- C7b `superseded` — richer CLI detail now uses typed, agent-reported activity values for provider launch paths + that ADE has verified can call the session-scoped CLI. Raw TUI text and provider transcripts remain unparsed by + design; planning comes from structured provider modes, and Needs you comes from the explicit ADE attention path. ### C8 — Session preview corruption for full-screen TUI CLIs (spaces gone + escape residue) - C8a `diagnosed` — **Preview builder flattens PTY bytes with no cursor model; the spaces were never in the stream.** @@ -412,10 +409,10 @@ Headline: **`WEB_CLIENT_TAB_PATHS` is dead code** (nothing imports it); the real fallback with explicit silent-list — kills the new-table silent-staleness class; verified only 10 low-frequency tables hit the fallback). Classifier map itself CLEARED as blank-Work cause (programmatic 89-table diff: only intentional losses). -- C22d `pre-ship perf caution (WS-E follow-up)` — Today's preview cursor emulator + TUI marker scans run per PTY - chunk on the host main process with no chunk-size cap (ptyService.ts:5058-5065; char-loop + ~9 regexes over - ≤8.5KB). NOT the cause of tonight's web lag (owner's runtime runs the beta, not this branch) but needs - measurement/capping before this branch ships to the Mac — TUIs repaint multi-KB per keystroke. +- C22d `pre-ship perf caution (WS-E follow-up)` — The preview cursor emulator runs per PTY chunk on the host main + process with no chunk-size cap (ptyService.ts:5058-5065). NOT the cause of tonight's web lag (owner's runtime + runs the beta, not this branch) but needs measurement/capping before this branch ships to the Mac — TUIs repaint + multi-KB per keystroke. TUI status-marker scanning was removed; agent-reported activity uses the typed ADE CLI. ### C23 — Terminal mirror: wrong-width scrollback + mouse snapback (both FIXED client-side) - C23a `fixed` — Full-snapshot `replace` wrote bytes at xterm's constructor-default 80 cols before first fit (xterm @@ -575,15 +572,13 @@ Headline: **`WEB_CLIENT_TAB_PATHS` is dead code** (nothing imports it); the real - C31c `verified en route` — Normalization decline hypothesis measured DEAD (156 cols inferred at every tail size on the live, rolled-over transcript). Multi-instance probe artifact explained (first .xterm = healthy instance). -### C33 — /quality gate item, disposed by owner's standing merge instruction -- C33 `accepted-unfixed, designed follow-up required` — TUI-heuristic waiting-input emits - `attentionSource: "provider_structured"` (a lie: it's a regex read). The label is LOAD-BEARING: canonical - attention only grants needs_you + Settle through it (`sessionCanonicalState.ts:122`), and - `SessionStatusSlot.tsx:103` keys dismissibility on it — so a relabel without a cross-surface contract change - (new `tui_heuristic` member + tier decision + iOS decoder + dismiss-clause inversion, 5 surfaces) regresses - behavior. Owner's C7b decision wanted heuristic waiting feeding Attention, so BEHAVIOR matches intent; only - provenance is dishonest. Full analysis in `tuiRowOverlay()`'s docblock (ptyService.ts). Disposition: ship as-is - per owner's explicit merge instruction; schedule the tier design with the C7b follow-on. +### C33 — historical /quality item, superseded by the session-status reliability work +- C33 `superseded` — The original review documented PTY text heuristics stamping + `attentionSource: "provider_structured"` and feeding prompt-looking output into Needs you. That decision was + later superseded after the CLI capability audit: the PTY text scanner and `tuiRowOverlay()` are removed, so + terminal text no longer creates Planning or Needs you. Tracked CLI Needs you still comes from explicit ADE + attention such as `ade chat ask`; `provider_structured` remains for actual structured provider events. Current + behavior is described in `docs/features/terminals-and-sessions/README.md`. ### C32 — Polish backlog (from the final live round; queued for quality loop) - C32a — **"Orphaned sessions" flash on cross-machine project open**: connecting a second machine for the same @@ -742,10 +737,11 @@ one broken-both-ways invalidation path. - **WS-D "Web event/invalidation hygiene"** (C4a + C4b + C6) — neutral lifecycle type + toast guard, refresh-policy fix for the invalidation→includeStatus→write→invalidation cycle, and the one-line `#root` height fix. Likely absorbs future "phantom event/refresh/layout" reports as they arrive. -- **WS-E "CLI session telemetry fidelity"** (C7a + C8a + C8b; C7b as stretch/follow-on) — all three bugs live in the +- **WS-E "CLI session telemetry fidelity"** (C7a + C8a + C8b) — all three bugs live in the same `ptyService` telemetry pipeline (runtime state + preview builder) and ship to every surface through - `enrichSessions`/`last_output_preview`. Turn-anchor fix (3 edits), cursor-aware preview parser + split-CSI carry, - then optionally TUI-marker → `chatActivityMode` mapping for planning/asking states. Cross-cutting (desktop, web, + `enrichSessions`/`last_output_preview`. Turn-anchor fix (3 edits) and cursor-aware preview parser + split-CSI carry. + Planning comes from structured provider modes; CLI activity detail uses the typed ADE reporting command. + Cross-cutting (desktop, web, iOS all benefit); not web-only despite being reported from the web client. - **WS-F "Web adapter parity & input fidelity"** (C9 + C10 + C11 + C10-sys/C12-pattern sweep) — surfaces that exist on desktop/iOS but are silently dead or degraded on web: missing adapter passthroughs (usage panel), ignored diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index d14f7cd046..dda85c8938 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -53,10 +53,10 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/closedCliSessions.ts` | Converts live or ended tracked CLI terminal sessions into chat-like summaries, retains the persisted settled/status/attention/failure fields, filters ended rows out of open chat lists, derives resumability/provider metadata, projects the scheduler-backed pause/jobs/next-wake state fetched by the Ink root, and maps user-initiated closes (0/130/143) to the neutral idle glyph instead of a failure state. | | `apps/ade-cli/src/tuiClient/sessionLifecycle.ts` | ADE Code's half of the session-lifecycle surface: argument parsing for the `/session …` slash commands plus the text-only row markers the sessions pane and the right-pane chat list render. Everything semantic is imported, never re-derived — `isSessionSnoozed` / `isSessionFiledAsSnoozed` from `apps/desktop/src/shared/sessionCanonicalState.ts`, wake-label copy (`snoozeWakeLabel` → "wakes in 3h" / "wakes tomorrow" / "wakes when asked" / "wakes now") and woke-reason copy (`sessionWokeMarker` → "needs approval" / "errored" / "turn finished") from `apps/desktop/src/renderer/lib/sessionSnooze`, and duration grammar from `sessionSnoozeDuration.ts`. Snooze stays a visibility overlay: nothing in the module reads or writes a canonical phase. `resolveSnoozeChoices` / `resolveSnoozeChoice` / `resolveSnoozeFreeText` back the duration picker. `resolveSnoozeChoices(nowMs)` is a function rather than the module-level constant it replaced, and deliberately so: the shared `resolveSnoozePresets` suppresses "This evening" once 18:00 is within an hour or past, and a list derived once at import would freeze that decision at process start — a TUI launched at 2pm would still offer "This evening" at 11pm. | | `apps/ade-cli/src/sessionSnoozeDuration.ts` | Snooze duration parsing shared by the `ade session snooze` planner in `cli.ts` and the TUI's `/session snooze`. Extracted rather than duplicated so there is exactly one answer to "what does `1.5h` mean" and exactly one cap (`MAX_SNOOZE_MS`, 30 days — beyond that it is almost certainly a typo, and no scheduler exists that could walk the deadline back). Grammar: an integer or one-decimal amount plus a unit suffix (`30m`, `1h`, `1.5h`, `4h`, `1d`, `1w`); a bare number reads as minutes. It returns a result union (`{ ok: true, ms }` \| `{ ok: false, code: "invalid" \| "too-short" \| "too-long", message }`) instead of throwing, so each surface dresses the failure in its own voice: `cli.ts` re-throws a `CliUsageError` with the flag-worded `message`, while the TUI switches on `code` to write terminal copy that never mentions a flag the user did not type. | -| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over the runtime action domains used by the Ink root, including the session lifecycle calls `snoozeSession`, `wakeSession`, `setSessionSettleOverride`, and `clearSessionWokeMarker` (all mapping onto the `session` action domain) and the `TuiSessionLifecycleFields` type. `enrichChatSessionsWithLifecycle` / `enrichTerminalSessionsWithLifecycle` are the single merge funnel: besides `settleOverride`, `snoozedUntil`, `snoozedAt`, `wokeAt`, and `wokeReason` they carry the Work-row presentation columns (`runtimeState`, `toolType`, `attentionSource`, `exitCode`, `laneName`, `pinned`, `chatActivityMode`, `activeBackgroundTaskCount`, `backgroundWork`) plus the whole `session.list` row as `workSummary`. The row rides along rather than being flattened because `AgentChatSessionSummary` types `status`, `title`, and `lastActivityAt` differently from `TerminalSessionSummary`; `workRow.ts: toWorkSessionSummary` is the one place that projects the two back together. Chat hydration requests a 1,000-event / 256 KiB recent window and exposes the append-stable `getChatEventHistoryPage` byte-cursor wrapper for older pages. Both history calls use the runtime's canonical single object envelope instead of positional options that one-argument runtime wrappers would discard. `sendChatMessage` uses the same object form: the host re-derives trusted provenance for `chat.sendMessage` / `messageSession` / `steer` from the caller's bound identity and rejects the positional form so metadata cannot route around that check (the old trailing `{ awaitDispatch: true }` positional was never forwarded by the action wrapper anyway). `dispatchSteerMessage` returns a **`Partial`** result on purpose: `connection.action` ends in an unchecked cast, and a durably queued command answers with an ack envelope carrying no `dispatchedAt` key at all, so callers must handle the field's absence rather than read it as a delivery. | +| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over the runtime action domains used by the Ink root, including the session lifecycle calls `snoozeSession`, `wakeSession`, `setSessionSettleOverride`, and `clearSessionWokeMarker` (all mapping onto the `session` action domain) and the `TuiSessionLifecycleFields` type. `enrichChatSessionsWithLifecycle` / `enrichTerminalSessionsWithLifecycle` are the single merge funnel: besides `settleOverride`, `snoozedUntil`, `snoozedAt`, `wokeAt`, and `wokeReason` they carry the Work-row presentation columns (`runtimeState`, `toolType`, `attentionSource`, `activityStatus`, `exitCode`, `laneName`, `pinned`, `chatActivityMode`, `activeBackgroundTaskCount`, `backgroundWork`) plus the whole `session.list` row as `workSummary`. The row rides along rather than being flattened because `AgentChatSessionSummary` types `status`, `title`, and `lastActivityAt` differently from `TerminalSessionSummary`; `workRow.ts: toWorkSessionSummary` is the one place that projects the two back together. Chat hydration requests a 1,000-event / 256 KiB recent window and exposes the append-stable `getChatEventHistoryPage` byte-cursor wrapper for older pages. Both history calls use the runtime's canonical single object envelope instead of positional options that one-argument runtime wrappers would discard. `sendChatMessage` uses the same object form: the host re-derives trusted provenance for `chat.sendMessage` / `messageSession` / `steer` from the caller's bound identity and rejects the positional form so metadata cannot route around that check (the old trailing `{ awaitDispatch: true }` positional was never forwarded by the action wrapper anyway). `dispatchSteerMessage` returns a **`Partial`** result on purpose: `connection.action` ends in an unchecked cast, and a durably queued command answers with an ack envelope carrying no `dispatchedAt` key at all, so callers must handle the field's absence rather than read it as a delivery. | | `apps/ade-cli/src/tuiClient/olderHistory.ts` | Bounded transcript-window policy for ADE Code. It initially paints the newest 500 snapshot events, drains the contiguous local remainder before network paging, dedupes page seams, and keeps at most 60,000 resident events. At the cap, scrollback becomes a sliding window that retains the newly requested older side and marks the view detached; `End` rehydrates the authoritative recent tail and folds in buffered live events. The cursor stays retryable on `unavailable`, and the underfill/near-top policy triggers loading without requiring an extra scroll event. | | `apps/ade-cli/src/tuiClient/workListModel.ts` | The pure model behind the sessions pane: rows → lane groups → quiet shelves, plus `resolveWorkListSelection` / `stepWorkListSelection` / `workListSelectionCopyText` and the cross-machine projection `foreignRowsFromAttention`. Everything semantic is IMPORTED from the desktop tree rather than re-derived — `canonicalSessionState` / `canonicalStatusBucket` / `isSessionFiledAsSnoozed`, `sessionStatusPresentation` + `sessionElapsedLabel`, `sessionStatusDisplay` / `sessionFilingBucket`, `primarySessionLabel`, `snoozeWakeLabel` / `sessionWokeMarker`, `relativeTimeCompact`, `orderWorkLanes` / `workLaneTier`, `ACTIVITY_STATE_GLYPHS`, and `indexNestedSubagents` from `sessionSpawnNesting.ts` — all of which are React-free. Same-lane `spawnKind: "subagent"` chats (and tracked CLI `--type subagent`) nest as compact one-line rows under the parent; peers stay top-level; demote un-nests; cross-lane children stay top-level; quiet parents pull up not-done children; grandchildren flatten into that parent. `toWorkSessionSummary` must carry `usageLimitResume` through the projection: `sessionStatusPresentation` computes the usage-limit row label from that field alone, so dropping it makes a chat that is going to resume itself read **Failed**. The row's elapsed is not derived here: `sessionElapsedLabel` picks the anchor (turn start, `backgroundWorkSince`, or last activity) and formats it, so a TUI row and the desktop row cannot report different durations for the same session. | -| `apps/ade-cli/src/tuiClient/workRow.ts` | The two small pure helpers that were trapped inside desktop `.tsx` files, copied with attribution: `getPreviewLine` (SessionCard) and `partitionQuietSessions` (SessionListPane). Also owns `toWorkSessionSummary`, the one adapter that projects a TUI chat row onto the `TerminalSessionSummary` shape the shared modules speak. | +| `apps/ade-cli/src/tuiClient/workRow.ts` | The two small pure helpers that were trapped inside desktop `.tsx` files, copied with attribution: `getPreviewLine` (SessionCard) and `partitionQuietSessions` (SessionListPane). Also owns `toWorkSessionSummary`, the one adapter that projects a TUI chat row onto the `TerminalSessionSummary` shape the shared status modules speak, carrying the current-turn anchor and agent activity report through that projection. | | `apps/ade-cli/src/tuiClient/workListLayout.ts` | Single source of truth for sessions-pane row geometry: `workListRowHeight` (a full card is always 3 lines, matching the desktop SessionCard; a nested same-lane subagent is 1 line), `computeWorkListLayout` (scroll window that always contains the selection), `workListMouseHitForLayout`, and `workListHitRects`. Nested helpers take no blank line above them. Singleton cards split the first line as a `lane-identity` hit so a click on the lane name opens lane details; title and preview still open the chat. The renderer and the mouse handler both consume `layout.placements`, so a click and what is on screen cannot drift. | | `apps/ade-cli/src/tuiClient/newLaneForm.ts` | Pure model for the `/new lane` form: start-from modes (primary / child / import), Linear issue + setup-template fields, per-mode field lists, and `buildNewLaneSubmission` mapping form values onto `lane.create` / `lane.createChild` / `lane.importBranch` payloads. | | `apps/ade-cli/src/tuiClient/eventDedup.ts` | Reserves and syncs chat-event dedupe keys so replayed runtime events do not render twice. | @@ -76,7 +76,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/subagentPane.ts` | Pure builders for the Chat Info pane's subagent roster: `buildSubagentPaneRows`, `subagentIndexForPaneLine`, `selectedSubagentSnapshot`, and `subagentPaneContentFromRightPane` (extracts a `SubagentPaneContent` from the `chat-info` right-pane state). `subagentSnapshotsFromEvents` reconstructs snapshots from `subagent_*` and teammate envelopes with sibling-aware parent-placeholder resolution. | | `apps/ade-cli/src/tuiClient/workEventIds.ts` | Stable Work-tab identity helpers used by the TUI to thread `ade.work-*` event ids through the renderer without re-deriving them per frame. | | `apps/ade-cli/src/tuiClient/state.ts` | Persists terminal-client state under `~/.ade/ade-code-state.json`: the last selected chat per lane (`lastChatByLane`), the most recently active lane (`lastLaneId`), and the last explicitly chosen draft Interface (`draftKind`, `chat` or `cli`). Entries are project-scoped with legacy global fallback, matching desktop's project-scoped Work view memory. Writes are serialized through a short lock file so multiple TUI instances do not corrupt the JSON state. | -| `apps/ade-cli/src/tuiClient/theme.ts` | Shared Ink color and status tokens. Mirrors the Claude Design wireframe terminal palette 1:1: surfaces, text levels, brand violets, status (`running`/`attention`/`idle`/`failed`/`primary`), executor brand colors (Claude/Codex/Cursor/OpenCode/Droid + Shell + Copilot), plus helper exports `laneStatusColor`, `agentStatusColor`, `agentStatusGlyph`, and per-provider `glyph` + `wordmark`. | +| `apps/ade-cli/src/tuiClient/theme.ts` | Shared Ink color and status tokens. Mirrors the Claude Design wireframe terminal palette 1:1: surfaces, text levels, brand violets, status (`running`/`attention`/`idle`/`failed`/`primary`), executor brand colors (Claude/Codex/Cursor/OpenCode/Droid + Shell + Copilot), plus helper exports `laneStatusColor`, `agentStatusColor`, `agentStatusGlyph`, and per-provider `glyph` + `wordmark`. `sessionGlyphMark` maps shared activity glyph identities to readable terminal marks. | | `apps/ade-cli/src/tuiClient/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, `RightPaneContent` (`empty`, `help`, `status`, `details`, `diff`, `chat-info`, `new-chat-setup`, `model-setup`, `form`, `lane-details` with git stats + PR CI fields + the PR next step and agent reviewers + lane chat counts, …), `ChatInfoSnapshot`, `ChatInfoPlan`, `ChatInfoPlanStep`, `SubagentSnapshot`, `ChatScheduledWorkSnapshot`, plus navigation DTOs aligned with `apps/desktop/src/shared/types`. | | `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `WorkSessionsPane` (the session-first left pane: desktop-parity three-line session cards with a blank line between chats, singleton cards that carry the lane icon + name instead of the header, status cluster in desktop order (glyph then label then elapsed), a lane-icon + rule divider over grouped cards, and collapsed snoozed/settled shelves that keep the same card shape — fully-quiet lanes file into those shelves instead of leaving an empty name in the inbox. Same-lane `spawnKind: "subagent"` chats (and tracked CLI `--type subagent`) indent as one-line nested rows under the parent — title plus shout-only Needs you / Failed; a terminal cannot paint provider logos, so there is no collapsible **N subagents** drawer. Peers stay top-level; demote un-nests; cross-lane children stay top-level; quiet parents pull up not-done children; grandchildren flatten. New chats go through `/new chat`, not a per-lane button), `ChatView` (transcript renderer; exports `renderChatVisibleSelectionRows` / `renderChatSelectableRowTexts` / `selectedTextFromChatRows` for the ADE-owned mouse selection, plus `computeChatScrollMaxOffset` and `renderChatTranscriptPlainText`), `Header`, `RightPane` (`computeLaneChatCounts` with active / needs-you / settled / closed / failed rollups, `LANE_DETAIL_PR_ACTION_INDEX`, wireframe `lane-details` STATUS/SETUP/CHANGES/ACTIONS/PR/CHATS sections, Chat Info `chat-info` (title + lane caption, sections only when they exist, usage at the bottom), and `PrNextStepLines`, which prints the PR's next step (`▸ ` in the desktop Merge card's tone) and an `agents · CodeRabbit, Devin` line under the PR in both lane details and Chat Info. The values come from `PrLaneSummary.nextStep` / `agents`, which the host computes from the cached status with the shared `resolvePrNextStepFromStatus` (`apps/desktop/src/shared/prNextStep.ts`). A selected PR row in lane details shows its link instead of these lines, `model-setup`, masked `/secrets` list), `SlashPalette`, `MentionPalette`, `ApprovalPrompt` (all structured questions, explicit-vs-highlighted picks, impact/default context, honest option/freeform/default/decline hints, and payload-derived Send labels), `ModelStatus`, `FooterControls`, and `TerminalPane` (xterm-headless preview pane that consumes `ChatTerminalPreviewResult` from `ade.terminal.preview` plus live `ade.pty.data` chunks to render a real terminal grid inside Ink; running provider CLI terminals — Claude, Codex, Cursor, Droid, OpenCode — can be put into direct control mode from the TUI). | | `apps/desktop/src/shared/externalSessionAffordances.ts` | Cross-client capability-to-action policy shared by desktop and ADE Code, including cwd-locked original-folder continuation and cross-lane Copy eligibility. | @@ -192,7 +192,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt `apps/ade-cli/src/tuiClient/app.tsx` is the Ink root. Layout: - **Header** — project name, active lane, branch, the terminal client frame, and the shared machine account state. ADE Code reads account status once while the TUI surface is active; it does not add a poll loop. `ade login` remains the canonical sign-in command. -- **Sessions pane** (left, full-height; starts open; `Ctrl+O` / `^o work` hides it completely) — desktop Work-tab parity, replacing the old lanes/chats drawer. Lanes are group headers; every chat and tracked CLI session is a 2–3 line card: title + right-aligned status word, a preview line (ask → note → last output → summary → goal), then age + optional `✎` draft pencil + text lifecycle marker (`z` snoozed / `*` woke / `done` settled) — a terminal cannot paint provider logos, so the card never fakes one. Same-lane `spawnKind: "subagent"` helpers indent as one-line nested rows under the parent (shout-only Needs you / Failed); peers stay top-level. Quiet parents pull up not-done children; done children stay nested. Grandchildren flatten. Cross-lane children stay top-level. Quiet rows collapse into snoozed and settled shelves at the bottom. Cross-machine rows come from the same attention snapshot `/activity` already fetches, joined by project canonical id; they sit under a local lane when the lane **name** matches, otherwise under a dim `⧉ machine` group with last-seen when offline. Status colour comes only from `theme.sessionToneColor` (the ANSI twin of the desktop tone table); every state also carries a word and a text marker so the pane still reads with colour off. `↑`/`↓` walk one flat row list from `workListModel.ts`; highlighting a local chat previews it in the centre pane via `resolveTuiChatRefreshTarget` (`drawerBrowsingChatId`); `↵` opens it, a lane header opens lane details, `+ new chat` starts a draft with the right pane closed, and a shelf expands/collapses. Foreign rows hop this TUI onto that machine over the paired runtime (`connectionPool.ts`) and open the chat; offline rows say so instead of connecting. `ade code remote` remains a CLI launcher (including Advanced SSH); in-session hops are paired-only. Ended tracked CLI sessions remain resumable from their card through the same continuation path as desktop (stored model, reasoning, Fast Mode, permission mode, Codex approval/sandbox). Row geometry and mouse hit-testing share `workListLayout.ts` (`computeWorkListLayout` / `workListMouseHitForLayout`) so a click cannot drift from what is painted. Hover highlighting uses SGR 1003 motion tracking (off with `ADE_CODE_HOVER=0`). `Esc` returns focus to the composer. Lane ops that used to live in the drawer (`/lane …`, `/new lane`, lane-details pane) still do. +- **Sessions pane** (left, full-height; starts open; `Ctrl+O` / `^o work` hides it completely) — desktop Work-tab parity, replacing the old lanes/chats drawer. Lanes are group headers; every chat and tracked CLI session is a 2–3 line card: title + right-aligned status word, a preview line (ask → note → last output → summary → goal), then age + optional `✎` draft pencil + text lifecycle marker (`z` snoozed / `*` woke / `done` settled) — a terminal cannot paint provider logos, so the card never fakes one. The status slot shows one effective label: a current agent activity detail can refine a live Working row, while Needs you keeps priority; the detail does not move the row to another board phase. Same-lane `spawnKind: "subagent"` helpers indent as one-line nested rows under the parent (shout-only Needs you / Failed); peers stay top-level. Quiet parents pull up not-done children; done children stay nested. Grandchildren flatten. Cross-lane children stay top-level. Quiet rows collapse into snoozed and settled shelves at the bottom. Cross-machine rows come from the same attention snapshot `/activity` already fetches, joined by project canonical id; they sit under a local lane when the lane **name** matches, otherwise under a dim `⧉ machine` group with last-seen when offline. Status colour comes only from `theme.sessionToneColor` (the ANSI twin of the desktop tone table); every state also carries a word and a text marker so the pane still reads with colour off. `↑`/`↓` walk one flat row list from `workListModel.ts`; highlighting a local chat previews it in the centre pane via `resolveTuiChatRefreshTarget` (`drawerBrowsingChatId`); `↵` opens it, a lane header opens lane details, `+ new chat` starts a draft with the right pane closed, and a shelf expands/collapses. Foreign rows hop this TUI onto that machine over the paired runtime (`connectionPool.ts`) and open the chat; offline rows say so instead of connecting. `ade code remote` remains a CLI launcher (including Advanced SSH); in-session hops are paired-only. Ended tracked CLI sessions remain resumable from their card through the same continuation path as desktop (stored model, reasoning, Fast Mode, permission mode, Codex approval/sandbox). Row geometry and mouse hit-testing share `workListLayout.ts` (`computeWorkListLayout` / `workListMouseHitForLayout`) so a click cannot drift from what is painted. Hover highlighting uses SGR 1003 motion tracking (off with `ADE_CODE_HOVER=0`). `Esc` returns focus to the composer. Lane ops that used to live in the drawer (`/lane …`, `/new lane`, lane-details pane) still do. - **ChatView** — the main transcript. Renders user, assistant, file-change, and system events from `chat/event` notifications while normalized tool telemetry stays behind the active activity/status row or the completed turn's `Ran for` row. Codex and most providers label the live row `model working`; Claude keeps its existing provider-specific live presentation and adds only a compact actions disclosure when tools are available. Expanding either status reveals one line per tool (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`); MCP events prefer the app/plugin/server name plus action instead of a generic `mcp` label, and expanded `web_search` actions include the first provider action query/title/URL when available plus up to three Codex structured `title — domain` previews with a `+N more` tail. Generated/viewed-image lifecycle updates still collapse to one concise notice per item, and provider-specific narration, reasoning, subagent/activity cards, and notices remain in their existing positions. File-change groups remain chronological, collapse to one summary row, and expand to typed file rows whose `diff` action opens the turn diff in the right pane. Every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A run of adjacent `spawn_completed` notices for the same `childSessionId` collapses to one counted line (`Chat "" finished its turn ×3`), matching desktop's chip and the iOS fold: a parent that spawned a peer gets one byte-identical notice per sibling turn, and the shared notice dedupe would otherwise drop every repeat with no trace, so these bypass it. The count renders on the newest body, so a child renamed mid-run reads the same on all three surfaces; a completion whose `spawnCompletion` detail is missing folds into nothing and keeps its own line rather than absorbing a different child's. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. Near the top, both scroll and underfilled viewports silently request more history; the stable first row reads `↑ older messages`, changes in place to `↑ loading earlier…`, and exposes `Ctrl+R` only after all automatic retries fail. Paging continues beyond the 60,000-event resident ceiling by sliding the window toward the transcript head; live events are buffered while detached, and `End` restores the latest bounded tail. - **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Both triggers are detected cursor-relatively through the shared `apps/desktop/src/shared/composerTriggers.ts` module (`detectComposerTrigger`), so a `/command` or `@file` token is recognized anywhere in the draft — not just at position 0 (`fix @src/foo.ts then run /test`). Both palettes stay visible with a no-match row while the user is actively typing. Selecting a suggestion splices exactly the trigger span (`replaceComposerTriggerSpan`) rather than replacing the whole prompt; a lone leading `/command` keeps the legacy fill-the-prompt behavior. `Tab` completes the highlighted slash command, and for a **mid-sentence** slash trigger `Enter` completes into the draft (instead of submitting/running), mirroring the desktop command menu — a leading-only command still runs on `Enter`. Confirmed tokens render as colored chips in the prompt rows via `findConfirmedComposerTokens` + `segmentPromptLineText`: inserted `@file` mentions and `/command` names matching the built-in or runtime catalog paint cyan (files) or violet (commands) and bold, while unmatched `@`/`/` text stays plain. URLs detected by shared `smartLinks.ts` also paint violet and add a compact `links [provider label]` row above the raw prompt; GitHub, Linear, ADE, and generic web labels are deterministic and do not require metadata fetching in the terminal. Character Backspace/Delete removes the whole intersected URL, while the canonical URL remains the submitted prompt text. Mention completion publishes local lane/chat hits immediately, then debounces remote file/git/PR RPCs; file results are cached per lane+query and git/PR results are cached per lane for the open TUI session. The `@` menu mirrors the desktop composer and asks for **folders** too (`file.quickOpen` with `includeDirectories: true`), rendering them with a trailing slash and the label `Adds a folder reference` — a folder is a pointer, not an attachment, so `attachableFileMentions` filters it out of the upload list on **both** the submit and background-launch paths. Those two paths are otherwise identical and had already drifted: the folder guard reached only one of them. Pending tool approvals surface as `ApprovalPrompt`. AskUserQuestion-style requests render every question inline with its options, decision impact, visible default assumption, and an `N of M answered` header. The seeded recommendation is only a cursor, never a preselected answer. While the composer is empty, `↑`/`↓` move the cursor, `←`/`→` switch questions, and `1`-`9` mark an option without submitting; clicking does the same. `Enter` banks the active answer and advances or sends. Typed text accumulates after marked selections instead of replacing them. A question that forbids freeform never advertises a note; without options it either offers Enter for its visible default assumption or directs the user to decline. If printable text follows a provisional digit pick, that digit becomes freeform text and the earlier selection is restored. The deny chip declines the whole request. Selection lives in `pendingInput.ts`'s `PendingQuestionSelectionState`, while payload and label semantics come from `apps/desktop/src/shared/pendingInputAnswers.ts`. - **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. `/secrets` opens a masked project-secret list and copies the selected secret value to the local system clipboard with `Enter` or `c`; it never reveals values inline and only uses the read actions behind the existing project-secret RPC path. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): pane title `CHAT INFO` (no provider/family), chat title, lane glyph + name as a caption, then sections only when they have content — plan (hidden when empty), Codex `/goal`, subagent/teammate/background roster (including completed and threadless agents from `chat.listSubagents`), **TASKS**, **SCHEDULE** (wakeups/cron/`/loop` plus `⏰ next wake <duration>`), **BACKGROUND**, and **PR** (`state` + `/pr for details`). Context % and the token summary sit at the bottom. There is no new-chat row, idle/live chip, or ghost `main` roster row. The next-wake line is omitted when the timestamp is missing, invalid, paused, or already past. PR rows refresh from runtime PR update notifications when available and still keep the 30s poll as a fallback. Codex goal state comes from the shared chat event stream and is normalized so provider token budgets do not show as ADE-side limits. Selecting a subagent row with `↵` first probes for a usable subagent transcript; if one is available the centre transcript swaps to it, otherwise the local reconstruction stays visible with a notice. `Esc` returns to the main chat. For an active lane with no chat focus, the default switches to the wireframe **`lane-details`** view: **STATUS** (clean/dirty, ahead/behind), optional **SETUP** (lane setup progress or retryable failure; press `r` on a failed setup to retry), **CHANGES** (file list + staged/unstaged counts from `diff.listLaneDiffStats`), **ACTIONS** (lane shortcuts — `new chat`, `open / create PR`, `stage all`, `move unstaged to new lane`, `commit`, `push`, `diff`, `reparent`, `delete lane`; each row carries a semantic glyph color so additive actions are green, navigational actions are violet, the rescue-unstaged action is amber, and `delete lane` is red), optional **PR #N** (state chip, CI activity via `checksPending` / `checksFailed`, plus the host's canonical `checksStatus` — `checksPassed === checksTotal` is not proof of a pass, and a `not_run` rollup reads "CI: not run" rather than a green count; `↵` opens the PR URL when the PR row is selected), and **CHATS** (active / needs you / settled / closed / failed counts from `computeLaneChatCounts`). A `worktreeAvailable` guard surfaces a recoverable warning when the lane worktree path is missing from disk. `/model` opens a transient **`model-wizard`** pane (provider → family → model → settings) that closes on commit; it does not stay open as a setup panel. diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index e3c1dce8b0..8c380c9d2c 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -16,7 +16,7 @@ The former worker/hiring agents were removed. There is one persistent identity | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools on PATH. | | `apps/desktop/src/main/services/ai/piInstallation.ts` | Resolves the user's Pi installation — CLI path, SDK package root/entry, agent dir, `auth.json` / models / settings paths, provider inventory, and a `blocker` when the SDK path is unusable. `sdkAvailable` and `cliAvailable` are independent signals. Provider rows are the shared `AiPiProviderStatus` shape; a provider whose `baseUrl` is a loopback host is classified `local` and carries that endpoint through, so a model server the user runs is never mistaken for an API provider on the strength of a placeholder key. | | `apps/desktop/src/main/services/ai/piAuthService.ts` | In-app Pi sign-in: enumerates signable providers, drives Pi's own `ModelRuntime.login` on a dedicated inventory-only worker, and relays Pi's prompts and notices to whatever surface is listening. Relays credentials, never stores or logs them. | -| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. `ade new chat --mode chat|cli ... --type <subagent|peer>` mirrors the desktop New Chat toggle; parented agent sessions inherit `ADE_CHAT_SESSION_ID` and must choose a type, while `--no-parent` creates an independent top-level session. `ade chat read <session> --limit <n> --max-chars <n>` silently reads a bounded project-backed transcript window across registered projects, and `--page --cursor <offset>` walks older content. Personal chats remain on `ade chat ... --personal`. The file also owns typed Work status, scheduled work, Linear attachment, secrets, iOS Simulator, App Control, and browser command families. | +| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. `ade new chat --mode chat|cli ... --type <subagent|peer>` mirrors the desktop New Chat toggle; parented agent sessions inherit `ADE_CHAT_SESSION_ID` and must choose a type, while `--no-parent` creates an independent top-level session. `ade chat read <session> --limit <n> --max-chars <n>` silently reads a bounded project-backed transcript window across registered projects, and `--page --cursor <offset>` walks older content. Personal chats remain on `ade chat ... --personal`. `ade chat activity` sets or clears a fixed session-card activity detail; the file also owns typed Work status, scheduled work, Linear attachment, secrets, iOS Simulator, App Control, and browser command families. | | `apps/ade-cli/src/services/account/accountAuthService.ts` | Required ADE account auth for humans, remote agents, and CI: loopback OAuth, account-directory device authorization, shared `account.session.v1` refresh storage, JWT-`exp`-authoritative access-token refresh, one cross-process refresh-rotation recovery attempt after `invalid_grant`, and ephemeral `ADE_ACCOUNT_TOKEN` credentials. Desktop, CLI, and ADE Code ask the brain-owned refresh broker for access tokens. | | `apps/desktop/src/main/services/ai/apiKeyStore.ts`, `apps/desktop/src/main/services/cto/linearCredentialService.ts` | Encrypted provider/Linear credential storage with account/device provenance. Account-origin values hydrate from the brain-backed vault and are purged at sign-out or account switch; device-only values remain local. | | `apps/ade-cli/src/adeRpcServer.ts`, `apps/ade-cli/src/multiProjectRpcServer.ts`, `apps/ade-cli/src/runtimeRoles.ts` | Private ADE action RPC, caller-role boundary, and multi-project routing. `start_cli_session` requires `subagent` or `peer` whenever it records parent lineage. The RPC edge derives trusted parent→child turn provenance for `chat.messageSession`, strips spoofed provenance, keeps writes/history/lifecycle scoped, and permits bounded transcript reads from project-backed chats. The machine router locates the owning registered project for a chat id and aggregates foreign-project chat search while excluding personal chats. | @@ -28,7 +28,7 @@ The former worker/hiring agents were removed. There is one persistent identity | `apps/desktop/src/main/services/ai/tools/systemPrompt.ts` | Provider-runtime prompt assembly, including one shared timezone-safe scheduled-work contract for Claude, Codex, Cursor, Droid, OpenCode, and Pi, plus runtime-specific native-subagent versus ADE-child routing guidance. | | `apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md` | Agent-facing schema for Mosaic v1 interactive cards: an agent emits a fenced ` ```mosaic ` JSON block to ask the user for structured input (select / multiselect / number / input / approval / table) and the submitted answers return as the next user message. Parsing/rendering live in `apps/desktop/src/shared/chatMosaic.ts` (see [chat composer-and-ui.md](../chat/composer-and-ui.md)). | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side install / status / uninstall surface for the `ade` launcher. | -| `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, using socket-backed live surfaces, registering proof, and cleaning up processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, the CTO, and mobile-started runtime work. | +| `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, using socket-backed live surfaces, reporting session activity when a provider has trusted session-scoped CLI access, registering proof, and cleaning up processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, the CTO, and mobile-started runtime work. | | `apps/desktop/src/shared/agentSkillRoots.ts` | Resolves and formats Agent Skill roots injected into prompts and CLI environments. | | `apps/desktop/src/shared/types/cto.ts` | CTO identity, capability mode, onboarding, memory, and prompt-preview types. | @@ -77,6 +77,11 @@ into the Work list: - `ade chat note "testing desktop auth fallback"` updates the row's quiet status line, trimmed to at most 72 characters (agents aim for 6 words or fewer); an empty note clears it. +- `ade chat activity planning|implementing|testing|reviewing|debugging|monitoring` + sets one fixed activity detail on the session card; `clear` removes it. It + never changes the parent board phase, **Needs you** takes priority, and a new + user turn clears the old report. Provider guidance exposes this only when ADE + can make a session-scoped CLI call reliably. - `ade chat ask "Which account should I use?"` creates a loud, persisted `Needs you` state, clears settle, and sends a time-sensitive push. The next user turn clears the ask. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 602ea9441f..7f8ec3440a 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -395,23 +395,28 @@ Replication is not only a question of which *tables* cross the boundary. A few columns on tables that do replicate are decisions only the host can make, and the host refuses to let a controller author them. -The current set is `terminal_sessions.settled_at`, `settle_override`, and -`settle_source` (`HOST_AUTHORITATIVE_COLUMNS_BY_TABLE` in `syncHostService.ts`). -A settle is decided by `sessionService`, the only place that can weigh it -against live work. Because the table replicates and cr-sqlite merges +The current set is `terminal_sessions.settled_at`, `settle_override`, +`settle_source`, `activity_status_json`, and `activity_status_changed_at` +(`HOST_AUTHORITATIVE_COLUMNS_BY_TABLE` in `syncHostService.ts`). `sessionService` +decides settlement against live work and writes agent activity reports with a +host timestamp. Because the table replicates and cr-sqlite merges last-writer-wins per column, a controller that writes its own optimistic `settled_at` sends a value carrying no host lifecycle revision — and it merges in regardless of what the host decided, so a host that *rejected* the settle still ends up with a settled row. That is a guard defeated by a merge rather -than by a caller, which no amount of host-side checking closes. +than by a caller, which no amount of host-side checking closes. The activity +report fields have the same boundary: a phone-authored value could replace the +host report through CRR even though the session action validates and timestamps +reports on the host. Host-authored reports still replicate to phones and paired +desktops. The filter drops those columns from inbound changesets **from phone peers only**. Two properties make it different from the table-level `SYNC_HOST_AUTHORITATIVE_TABLES` rule: - **It is peer-scoped, and deliberately so.** A paired desktop runs the same - `sessionService` chokepoint, so its settle writes are host-decided too and - must keep replicating; broadening the filter would silently stop settle + `sessionService` chokepoint, so its settle and activity writes are + host-decided too and must keep replicating; broadening the filter would stop propagating between two of one user's machines. Those writes are not applied blind, though: `applyChanges` reports settle-tuple columns whose value actually moved, and the receiving host re-asserts them through its own @@ -1810,7 +1815,8 @@ Canonical files (`apps/ade-cli/src/services/sync/`): winning `crsql_changes` row that would flip `brain_device_id`; brain handover stays on the explicit host-transfer RPC), the host-authoritative *column* filter (`HOST_AUTHORITATIVE_COLUMNS_BY_TABLE`: - `terminal_sessions.settled_at` / `settle_override` / `settle_source`, + `terminal_sessions.settled_at` / `settle_override` / `settle_source` / + `activity_status_json` / `activity_status_changed_at`, dropped from inbound changesets **from phone peers only** — see [Host-authoritative columns](#host-authoritative-columns-are-peer-scoped)), the inbound @@ -1882,8 +1888,11 @@ Canonical files (`apps/ade-cli/src/services/sync/`): the top forever, since mobile has no way to clear it. `runningCount` counts chats whose status is `running` and that are **not snoozed**; a snoozed running chat is idle on Activity, so including it would disagree with the - Hub tree and the island. Each chat carries optional `snoozedUntil` / - `snoozedAt` so older hosts omit them and older phones ignore them. Previews + Hub tree and the island. Each roster session carries optional `snoozedUntil` / + `snoozedAt`, plus normalized `activityStatus` and its separate + host-authored `activityStatusChangedAt`; older hosts omit these fields and + older phones ignore them. Activity-report time contributes to row freshness + without replacing lifecycle freshness. Previews are hard-truncated (~120 chars). Also exports `createForeignChatTranscriptResolver({ projectRegistry })` — the resolver behind cross-project chat quick-look and its security boundary: it maps a @@ -3707,7 +3716,7 @@ payload. | File access | On-demand project/worktree file reads, listings, writes | iOS Files, desktop remote viewing | | Terminal stream/control | Subscribe to a logical-offset transcript snapshot plus live PTY output. The host installs a snapshot barrier before capture, queues concurrent data/exit events (256 events / 2 MB), trims overlap at UTF-8 boundaries, and recaptures up to four times when the snapshot did not reach the queued watermark; it closes instead of flushing a gap or unreconstructable overflow. Web/iOS clients drop duplicate ranges, trim overlap, and issue one guarded `sinceOffset` recovery subscribe when a live chunk starts beyond their watermark. A delta appends only the missing suffix; a full snapshot is authoritative replacement even when its end equals the current watermark. ACK-capable input uses stable `inputId`s and a bounded host dedupe ledger so reconnect/timeout retry cannot type twice; legacy hosts receive one-shot input with no ambiguous retry. Viewport resize remains subscription-scoped and the last desktop size is restored after the last mobile viewer detaches | iOS Work tab, hosted web Work terminal | | Chat stream | Agent chat transcript events plus subscribed byte-cursor scrollback. Each `chat_event` carries a host-assigned per-session monotonic `seq` backed by a capped replay buffer (500 events / 2 MB per session). The host carries sequence high-water marks through shared-listener rehydration and seeds a recreated buffer from the agent event sequence persisted in session metadata/transcript state, so it never reuses a `(sessionId, seq)` pair. The field remains optional and old clients keep working unchanged. `chat_subscribe` accepts `sinceSeq`: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to an authoritative snapshot. Optional live sends are marked delivered only after the WebSocket accepts the frame; a backpressured peer keeps its transcript offset in place and the pump stops at the first failed event so later chunks cannot overtake the missing one. A per-session hydration barrier blocks both the live broadcaster and transcript pump while a snapshot is captured. The pump resumes after the ack from the logical byte offset recorded before capture, so appends racing a slow snapshot arrive after the ack without a gap; snapshot overlap is removed by the normal delivery-key dedupe. The snapshot is a byte-capped tail: `chat_subscribe` also carries the client's `maxBytes`, and the host clamps the snapshot's `getChatEventHistory` budget to `min(host cap, maxBytes)` — for a mobile-sized budget even the newest oversize event is dropped rather than force-included, so a phone never receives a snapshot larger than it asked for. Modern acks also return `cursorKind: "byte"`, `tailStartOffset`, and authoritative `hasOlderHistory`. A host advertising `chatHistoryPaging` accepts `chat_history` only for an already-subscribed session and matching project/personal/foreign scope; it reads the same authorized transcript path without switching projects or booting a runtime. Transient failures return `unavailable: true` and preserve the requested cursor. Snapshot and older-page transcript reads use asynchronous filesystem/zlib work; same-session tail reads coalesce, while archived gzip inflations are globally admitted with only the active inflate and newest queued destination retained. Small archives use a bounded memory cache; a larger archive is inflated at most once into an unlinked, process-private temporary file under a 256 MiB logical-size/LRU budget and a temporary-volume free-space guard, after which pages are random-access disk reads. Request cancellation propagates through queued work, file reads, and inflates, so disconnected clients cannot leave expensive transcript jobs running. Both event-history paging and the legacy `chat.getTranscript` route use append-stable logical byte cursors; the latter advertises `cursorKind: "byte"` so clients do not treat an offset as a dense entry index. Hosted-web and iOS older pages are capped at 256 KiB and a failed read preserves its byte cursor for retry. Snapshot events are marked as already-sent to that peer, so the follow-on live pump does not re-deliver the overlap. The ack also carries `turnActive` from the live agent chat service — because the snapshot is a byte-capped tail, a long turn's `status: started` event can fall outside the window and the flag is what lets a mid-turn subscriber render streaming/stop affordances without waiting on the changeset pump (a full ack without the flag tells the client to drop any latched hint). The additive foreign-scope protocol remains available to controller reads, but iOS Hub taps activate the owning project before opening the chat. A `session_meta_updated` `chat_event` carrying a client's permission/interaction/mode change also rides this stream, so a mode switch made on one client (desktop ↔ iOS) patches every subscribed client's cached summary and composer controls live without a refetch | iOS Work tab, iOS Hub, controller chat | -| Chat roster | Machine-wide all-projects projection of every project's lanes + work sessions grouped by lane — agent chats, their attached shell rows, and standalone CLI (tracked terminal) sessions, live **and** ended — so the mobile Hub renders every project's sessions at once **without activating each project**. Identity-bound chats (including each project's CTO) and all attached descendants are excluded from this ordinary roster; the optional `identityKey` marker lets clients reject stale or legacy leaked rows. `roster_subscribe` (handshake mirrors `chat_subscribe`, with an optional `sinceSeq`) → `roster_snapshot` then incremental `roster_delta` (`changed` upserts whole project entries, `removed` lists dropped `projectId`s). Un-booted projects are read cheaply from disk — each project's `<root>/.ade/ade.db` (read-only, no cr-sqlite / no runtime boot) plus `.ade/cache/chat-sessions/*.json` — so their session status is limited to the last-persisted `idle`/`ended`/`awaiting`; live `running`/`awaiting` fidelity is overlaid only for scopes currently booted on the runtime (booted scopes also overlay PTY liveness so a live standalone CLI session reads `running`). `attentionCount` counts awaiting/failed **chat** rows and their attached shells only — standalone CLI failures never count, so a long-dead CLI exit can't pin a project to the top of the hub. Rows carry `toolType` so the phone routes chat rows to the chat surface and CLI rows to the terminal path. Transcripts are excluded from the roster and load on demand after a row tap activates the owning project; the Hub cover exposes switching/hydration progress and an error with Retry instead of silently ignoring an unhydrated project. Oversized snapshots ride the generic `envelope_chunk` path. A host without a roster provider (single-project desktop) simply never answers `roster_subscribe`, so the phone falls back to the active project only | iOS Hub | +| Chat roster | Machine-wide all-projects projection of every project's lanes + work sessions grouped by lane — agent chats, their attached shell rows, and standalone CLI (tracked terminal) sessions, live **and** ended — so the mobile Hub renders every project's sessions at once **without activating each project**. Identity-bound chats (including each project's CTO) and all attached descendants are excluded from this ordinary roster; the optional `identityKey` marker lets clients reject stale or legacy leaked rows. `roster_subscribe` (handshake mirrors `chat_subscribe`, with an optional `sinceSeq`) → `roster_snapshot` then incremental `roster_delta` (`changed` upserts whole project entries, `removed` lists dropped `projectId`s). Un-booted projects are read cheaply from disk — each project's `<root>/.ade/ade.db` (read-only, no cr-sqlite / no runtime boot) plus `.ade/cache/chat-sessions/*.json` — so their session status is limited to the last-persisted `idle`/`ended`/`awaiting`; live `running`/`awaiting` fidelity is overlaid only for scopes currently booted on the runtime (booted scopes also overlay PTY liveness so a live standalone CLI session reads `running`). `attentionCount` counts awaiting/failed **chat** rows and their attached shells only — standalone CLI failures never count, so a long-dead CLI exit can't pin a project to the top of the hub. Rows carry `toolType` so the phone routes chat rows to the chat surface and CLI rows to the terminal path. Roster session rows may also carry the normalized fixed-value `activityStatus` report and `activityStatusChangedAt`; this refines the Work card status slot without changing the roster phase or Activity group. Transcripts are excluded from the roster and load on demand after a row tap activates the owning project; the Hub cover exposes switching/hydration progress and an error with Retry instead of silently ignoring an unhydrated project. Oversized snapshots ride the generic `envelope_chunk` path. A host without a roster provider (single-project desktop) simply never answers `roster_subscribe`, so the phone falls back to the active project only | iOS Hub | | Command routing | Send named actions (`chat.send`, `lanes.create`, `git.push`, `prs.getMobileSnapshot`, `work.listExternalSessions`, `work.importExternalSession`, etc.) | Controller devices | | Project switching | `project_catalog` + `project_switch_request/result` for multi-project runtimes | iOS project hub | | Project actions | Runtime-scoped project browser plus open/create/clone/list-GitHub-repos/default-parent-dir/forget envelopes. Available from the active project host or the machine-wide fallback handler before a project is selected | iOS project hub | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 6aca79e997..21ff5cbd0e 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -2799,14 +2799,21 @@ The iOS pieces: - `apps/ios/ADE/Resources/DatabaseBootstrap.sql` declares the five new nullable `terminal_sessions` columns (`settle_override`, `snoozed_until`, - `snoozed_at`, `woke_at`, `woke_reason`) for fresh installs. + `snoozed_at`, `woke_at`, `woke_reason`) for fresh installs. The separate + `activity_status_json` and `activity_status_changed_at` columns carry the + host-authored Work-card activity detail. - `apps/ios/ADE/Services/Database.swift` carries the matching `ensureColumn` migrations for existing installs, plus the columns threaded through the - session upsert bind indices, the row struct, and the session read queries. + session upsert bind indices, the row struct, and the session read queries, + including the two activity-report columns. - `apps/ios/ADE/Models/RemoteModels.swift` and `RemoteRosterModels.swift` decode `settleOverride`, `snoozedUntil`, `snoozedAt`, `wokeAt`, and `wokeReason` as optional `String` fields through `decodeIfPresent`, and include them in - equality so a lifecycle-only change still redraws the row. Roster chats expose + equality so a lifecycle-only change still redraws the row. Session summaries + also decode `activityStatus` and `activityStatusChangedAt`; chat summaries + carry `currentTurnStartedAt` for the Work-row timer. Roster chats merge + activity reports by their own timestamp, separately from lifecycle freshness. + Roster chats expose `countsTowardRunning` (live running, not snoozed) and `applyLocalSnoozeOverlay`, because snooze does not bump `lastActivityAt` and a fresher remote row would otherwise wipe the overlay the phone just wrote. @@ -2815,8 +2822,10 @@ The iOS pieces: is `idle`, and both `idle` and `ended` strings land on `idle` rather than `done` so week-old roster history does not paint emerald. - `apps/ios/ADE/Services/SyncService.swift` holds the `session.*` remote-command - callers. The phone never decides a lifecycle value, so these commands are the - mechanism, and the connect-time descriptor list gates the affordances. + callers. The phone does not author activity-report columns; the host filters + phone-authored copies and sends normalized reports through CRR. Session + commands remain the lifecycle mutation path, and the connect-time descriptor + list gates the affordances. - **The settle columns are host-authoritative and the phone never writes them.** `settled_at`, `settle_override`, and `settle_source` are decided by the host's `sessionService`, which is the only place that can weigh a settle against live @@ -2838,6 +2847,9 @@ The iOS pieces: from inbound phone changesets (`syncHostService`), and such a phone self-heals on the next `refreshWorkSessions`. See [settle-teardown design §3c-i](../terminals-and-sessions/settle-teardown-design.md). + The same phone-only inbound changeset filter protects `activity_status_json` + and `activity_status_changed_at`: these reports are written and timestamped + by the host, then replicated to the phone for display. - **Attention clears get the same treatment, on a shorter fuse.** `PendingAttentionClearStates.swift` is the second local, non-persisted overlay, covering the three host-authoritative columns the needs-you tier reads @@ -2972,6 +2984,16 @@ supposed to mean *your move*, so the "Needs you" badge stopped registering. 2. Title plus the lane's PR badge (`WorkLanePrIndicator` / `LanePrTag`). 3. An italic preview line plus the provider mark. +The status slot shows one effective label. A structured provider mode may show +**Planning** for the live turn; a current agent activity report can refine a +running row to **Planning**, **Implementing**, **Testing**, **Reviewing**, +**Debugging**, or **Monitoring**. Pending input keeps **Needs you** in the slot +ahead of an activity detail. The report refines the card presentation only: it +does not change the session phase, Work-board column, or grouped Activity count. +The phone does not generate these reports; it displays the host-authoritative +value. Provider-specific planning signals and activity-report eligibility are +listed in [the session provider signal boundaries](../terminals-and-sessions/README.md#provider-signal-boundaries). + A nested same-lane subagent is a compact one-line card: identicon, title, then the provider mark on the trailing edge (same seat as a full card), and shout-only status words (Needs you / Failed). The **N subagents** drawer @@ -3038,11 +3060,13 @@ Known limits, all deliberate: `spawnKind`, so the by-lane Work list nests same-lane subagent chats under the parent the way desktop does (`WorkSpawnNesting.swift`). Nested compact rows put the identicon and title on the leading edge and the provider mark - on the trailing edge. It still omits `branchRef`, - `currentTurnStartedAt`, `nextWakeAt`, and `lastActivityAt`, so desktop's - branch chip, machine tower glyph, grid indicator, and `nextWakeAt`-driven - "Waiting" status have no iOS equivalent, and the elapsed ticker anchors on - activity time rather than turn start. `parentIdentityKey` is present. + on the trailing edge. The terminal summary still omits `branchRef` and + `nextWakeAt`, so desktop's branch chip, machine tower glyph, grid indicator, + and `nextWakeAt`-driven **Waiting** status have no iOS equivalent. It includes + `lastActivityAt`, which drives row freshness and sorting. Chat summaries carry + `currentTurnStartedAt` for foreground chat timers; tracked CLI rows have no + such turn anchor and fall back to an eligible activity report's timestamp, + then the row's activity timestamp. `parentIdentityKey` is also present. - Against a host that predates `dismissPendingInput` on the bulk action, the flag is ignored: the settle reports success and the row stays "Needs you". diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 72b0d02bf8..a1bdc99100 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -87,41 +87,23 @@ and in tests. `canAcceptScheduledTurn(sessionId)` is the scheduler's non-mutating delivery boundary: ended tracked CLIs are resumable, while live CLIs require a provider-specific visible composer marker plus the short quiet window before - a durable prompt may be submitted. It also owns the richer CLI state - projection: each `PtyEntry` carries an optional `TuiMarkerState` (created at - spawn only for tracked agent CLI tool types, so shells and unknown tools - allocate nothing) that is folded on the same chunk the OSC 133 scan already - reads, plus a `previewCursor` threaded through `derivePreviewFromChunk`. The - runtime-state entry gains `runningSince`, stamped only on a - non-running → running *transition* and cleared on every non-running state — + a durable prompt may be submitted. CLI card status comes from host lifecycle + and explicit ADE attention requests: PTY output and OSC 133 can establish + liveness, and output silence can establish idle, but painted TUI text is not + parsed into Planning or Needs you. An agent's `ade chat ask` still marks a + tracked CLI as waiting for the user through ADE's explicit attention path. + The service also owns a `previewCursor` threaded through + `derivePreviewFromChunk`. The runtime-state entry gains `runningSince`, which + is stamped only on a non-running → running *transition* and cleared on every + non-running state — not `lastActivityAt`, which is re-stamped on every output tick and would render as "time since last write". `anchorTurnStart` / `isTurnSubmitWrite` re-anchor that turn when a user write ends in a newline (a literal Enter; - bracketed-paste payloads contain newlines but never end in one), and the same - write path calls `clearTuiWaitingInput`. The shared session-row projection — + bracketed-paste payloads contain newlines but never end in one). The shared + session-row projection — the one chokepoint desktop, lane snapshots, web, and iOS all read — then - emits `currentTurnStartedAt` for non-chat rows, the `tuiRowOverlay` spread, - and `runtimeState: "waiting-input"` when a marker latch is live. - ~4,450 lines. -- `apps/desktop/src/main/utils/terminalTuiMarkers.ts` — the TUI marker packs - that give PTY-backed CLIs the same vocabulary chat sessions already have - (planning / waiting-on-you), mapped onto the existing `chatActivityMode` and - `runtimeState` fields so no surface needs new rendering. `PACKS` is keyed by - `TerminalResumeProvider` and covers claude, codex, cursor, opencode, and - droid; anything without a pack resolves to null and does zero scanning. - `scanTuiMarkers` does one bounded pass per chunk (`MAX_CHUNK_SCAN_CHARS = - 8_000` head plus the same tail, joined, with a `CARRY_CHARS = 512` carry - across chunk boundaries) and `tuiActivityFromState` resolves it. The two - marker shapes are deliberately asymmetric: **planning is a footer state**, so - it is sticky with a `PLANNING_TTL_MS = 60_000` decay and needs no - "left plan mode" event (none is reliably printed); **waiting-input is an - event**, so it is an edge-triggered latch armed only when currently null and - cleared only by evidence — a `working` marker painting after the prompt, or - `clearTuiWaitingInput` when the user types. `WAITING_TTL_MS` (30 minutes) - exists to bound a false positive, not to time out a human. Where a window - contains both a prompt and the spinner that replaced it, the later match - wins, and waiting-input outranks planning because it is the actionable one. - Failure needs no markers: a nonzero exit already lands as `status: "failed"`. + emits `currentTurnStartedAt` for non-chat rows. `runtimeState: "waiting-input"` + comes from explicit ADE attention requests; it is not inferred from TUI text. - `apps/desktop/src/main/utils/terminalPreview.ts` — the one-line session preview builder. It is a real column-cursor model (`PreviewCursorState`, `createPreviewCursorState`, `derivePreviewFromChunk`) over a mutable cell @@ -138,8 +120,6 @@ and in tests. often enough that per-chunk stripping leaked `[53;37H` into previews as literal text — and on overflow the carried tail is re-anchored on its last `ESC` so a blind slice cannot write escape garbage into the preview. -- `apps/desktop/src/main/utils/terminalTuiMarkers.test.ts` — pack matching, - latch arming/clearing, and ordering coverage. - `apps/desktop/src/main/services/pty/supervisedPtyHost.ts` and `ptyHostWorker.ts` — isolated node-pty worker host. Local runtimes fork the worker from the built desktop files; remote runtimes can receive @@ -172,7 +152,9 @@ and in tests. - `apps/desktop/src/main/services/sessions/sessionService.ts` — persistence layer for `terminal_sessions` rows. CRUD, continuation metadata normalization, `reattach`, `reconcileStaleRunningSessions`, and the durable - settled/status-note/attention/last-turn-failure mutations. Normalized + settled/status-note/attention/last-turn-failure mutations. It also stores + fixed-value, host-timestamped agent activity reports without changing the + parent lifecycle phase, and clears them when a new turn is accepted. Normalized `TerminalResumeMetadata` retains optional `orchestrationParentSessionId` / `spawnKind`; tracked agent CLI rows project those fields onto `TerminalSessionSummary`, and resume-command backfill merges the existing @@ -373,6 +355,50 @@ Shared types and IPC: and it carries no command lines or environments. `ade session show` is its consumer; it exists so "this chat is holding a warm agent process open" is answerable without dropping to `ps`. + + #### Provider signal boundaries + + Parent phases use the same host lifecycle rules for every provider. Provider + adapters contribute **Needs you** only from structured input/permission + requests; tracked PTY CLIs also get explicit `ade chat ask`. PTY text is never + parsed into a status. Agent-reported activity requires both the + runtime-resolved ADE CLI executable and this runtime's RPC socket, and is + disabled for embedded runtimes. Each provider path is advertised only when + its command/tool and permission route is verified. Native Plan and + agent-reported activity have narrower capability gates: + + | Provider path | Structured Plan signal | Agent-reported activity detail | + | --- | --- | --- | + | Claude SDK | Current `interactionMode` | Available outside Plan mode when the session runtime resolves the ADE CLI path | + | Codex app-server | The accepted `turn/start` collaboration mode | Available in effective default mode, except external `config.toml` sessions | + | Cursor SDK | Local `currentMode` | Available in local Agent mode; omitted in Cursor Cloud and other modes | + | Droid SDK | Explicit `interactionMode` | Available only with explicit write-capable, non-AGI, non-Spec permission | + | OpenCode SDK | Current permission mode | Available outside Plan and external `config-toml` modes | + | Pi SDK | No current-mode signal | Available in non-personal POSIX sessions outside Plan when Bash is allowlisted and the ADE CLI resolves | + | ACP Qwen | Structured ACP configuration | Not currently offered: ADE injects its CLI path but does not wire verified activity guidance or a command tool | + | ACP Kimi / Grok / Copilot | Structured configuration varies by provider | Not currently offered: ADE does not wire activity guidance or a session-scoped command tool; these providers share pooled processes | + | Tracked PTY CLI | No Plan inference from terminal text | Activity guidance for Codex and OpenCode outside Plan / external `config-toml`, write-capable non-AGI Droid, Pi full-auto, and Cursor launches with an initial prompt. Windows guidance includes PowerShell and cmd forms plus a PowerShell bridge for Git Bash, and tells the agent to use only the form matching its command shell; if none applies, it leaves activity unchanged. Claude is omitted because its shell fallback drops the activity instruction, and blank Cursor launches remain omitted. `ADE_ACTIVITY_SESSION_ID` scopes reports to the terminal row while `ADE_CHAT_SESSION_ID` remains the owning chat. | + + ACP omission is an ADE wiring gap, not a protocol impossibility. ADE passes + the resolved CLI path into ACP environments but currently does not provide a + verified command tool or send session-specific activity instructions; each + dialect disables ADE's terminal capability. Qwen has a private process, but + Kimi, Grok, and Copilot share pooled processes, so their activity reports need + an explicit protocol-session target rather than a process environment id. + + Claude, Cursor, Droid, and OpenCode expose pending requests through their + provider events; Codex uses app-server input/permission events; Pi uses its + approval and AskUser events; ACP providers use `session/request_permission`. + Cursor and Droid retain their normalized pending request while waiting, so the + card can reconstruct **Needs you** after the renderer reloads. Host task + lifecycle events own automatic **Monitoring** detection; an agent's + `monitoring` report remains a separate signal. + + Codex Planning uses the collaboration mode in the active `turn/start` request + only after the app-server accepts it. Approval and sandbox settings do not + imply Plan; when native Plan is unavailable and ADE falls back to `default`, + the card stays Working (or shows a valid agent-reported activity detail). + The **settle override** (`terminal_sessions.settle_override`, `null | "settled" | "active"`) is consulted at the declared-settle tier, i.e. `"settled"` behaves like a declared settle, and `"active"` is an explicit @@ -423,6 +449,11 @@ Shared types and IPC: map its dependency-free glyph ids to platform symbols. `sessionStatusShoutsLabel` is the nested-compact filter: the status word is painted only for Needs you or a red Failed tone. +- `apps/desktop/src/shared/types/sessions.ts` — the fixed six-value activity + vocabulary. `apps/desktop/src/shared/sessionActivity.ts` imports it and + normalizes one host-timestamped agent report at the boundary. + The report refines a card's single status slot without moving its parent phase; + `sessionActivity.test.ts` pins normalization and malformed-input handling. - `apps/desktop/src/shared/sessionSpawnNesting.ts` — the one by-lane filing rule desktop, ADE Code, and the iOS Swift mirror consult. Same-lane `spawnKind: "subagent"` chats (and tracked CLI `--type subagent` sessions) @@ -2033,6 +2064,12 @@ hand translation at the drop handler. | **Waiting** | snoozed rows, plus running rows whose lane PR is mid-CI or has a review requested | **no** | | **Done** | resting rows (`ready`/`idle`), then ended rows, then settled rows | yes | +Each card shows at most one status label and icon. In Kanban, the column names +the parent state, so the card does not repeat it. A current activity detail can +occupy the card's one status slot; **Needs you** takes that slot whenever input +is pending. The list view uses the same single effective status, so an activity +detail replaces the generic **Working** label instead of appearing beside it. + The first column takes the `needs_you` phase, **not** the list's whole `awaiting-input` partition. That partition is a container holding three phases — `needs_you`, `ready` and `idle` — which is why the list names it "Your move" and @@ -2619,17 +2656,12 @@ degrades to "no ADE prompt" rather than a failed launch. that is not running it. Similarly, only `markLastTurnFailed` applies the strictly-newer-than-`snoozed_at` comparison — drop it and the error the user snoozed on top of instantly re-wakes the row, making snooze a no-op. -- **TUI-marker needs-you rides on `attentionSource: "provider_structured"`, and - that label is a known mislabel.** The value is supposed to mean the provider - told us it is blocked; for a marker latch the evidence is regex heuristics - over painted output. It is load-bearing anyway, because - `canonicalSessionState` derives `needs_you` from `pendingInputItemId`, - `attentionRequestedAt`, or `provider_structured` and ignores - `runtimeState: "waiting-input"` entirely — dropping the label would remove - the badge *and* leave the row unsettleable. `SessionStatusSlot` therefore - always allows dismissing a `provider_structured` needs-you. The real fix is a - heuristic-waiting tier in the canonical layer, which is a shared-contract - change across all five surfaces. +- **PTY text does not create semantic card states.** Painted prompt text is not + sufficient evidence that a CLI is blocked on the user, and plan-looking text + is not a reliable mode event, so the PTY service does not regex-scan it. + Tracked CLI `Needs you` comes from an explicit ADE request such as + `ade chat ask`; liveness and idle come from the PTY host. Structured provider + input remains a separate adapter event with its own provenance. - **Never stamp `provider_structured` without an item id.** Because `canonicalSessionState` treats that source as a needs-you trigger in its own right, independent of the item id, stamping it alongside a null diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md index 35892c7673..2d82570424 100644 --- a/docs/features/terminals-and-sessions/pty-and-sessions.md +++ b/docs/features/terminals-and-sessions/pty-and-sessions.md @@ -375,8 +375,31 @@ runtime state changes, when the preview changes more than 1.2 s after the previous signal, or as a 10 s heartbeat. Runtime states: `running`, `waiting-input`, `idle`, `exited`, `killed`. `idle` is inferred from output silence. OSC 133 `B`/`C` markers may confirm running, but -prompt markers never infer `waiting-input`; only explicit or -provider-structured lifecycle requests raise attention. +prompt markers and other painted TUI text never infer `waiting-input` or +Planning. For tracked CLIs, an explicit ADE request such as `ade chat ask` +sets waiting-input; structured provider input is handled by the provider +adapter, with its own provenance. + +Agent activity detail is a separate typed report (`planning`, `implementing`, +`testing`, `reviewing`, `debugging`, or `monitoring`), set through +`ade chat activity` only when session guidance confirms that provider can +invoke the runtime-resolved ADE CLI against this runtime's exact RPC socket; an +executable path alone is not enough, and embedded runtimes omit the guidance. +It refines a running card's single status label, never changes the parent +lifecycle phase, and clears when a new user turn is accepted. +Tracked CLI guidance is enabled for Codex and OpenCode outside Plan +and external `config-toml`, write-capable non-AGI Droid, Pi full-auto, and +Cursor launches with an initial prompt. It calls the host-resolved +`ADE_CLI_PATH` and scopes activity through `ADE_ACTIVITY_SESSION_ID`, the PTY +row id. `ADE_CHAT_SESSION_ID` continues to identify the owning chat for other +commands. Windows guidance provides PowerShell and cmd command forms plus a +PowerShell bridge for Git Bash, and tells the agent to use only the form +matching its command shell; when none matches, it leaves activity unchanged. Claude tracked CLI guidance stays +omitted because its shell fallback drops the activity instruction; blank Cursor +launches remain omitted. Other Pi +modes and Qwen, Kimi, Grok, and Copilot tracked CLIs remain omitted until their +command and prompt paths are verified together. PTY output is not scanned for +these labels. ### Process tree termination diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 2427cb53c5..95e533b64f 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -347,11 +347,17 @@ identicon, title, then the provider glyph on the right (same seat as every other session card), plus shout-only status words (Needs you / Failed). Nested rows omit the Subagent/Peer lineage pill — they already sit under the parent. -`SessionStatusSlot` is the card's only permanent status vocabulary. It resolves +`SessionStatusSlot` displays one effective status label at a time. It resolves words, glyphs, tone, prominence, and elapsed-time behavior through -`shared/sessionStatusPresentation.ts`. An active ADE chat in its authoritative -plan interaction mode reads **Planning** in violet; other active turns retain -**Working**. Once the foreground turn is idle, provider-reported background +`shared/sessionStatusPresentation.ts`. **Needs you** has priority over every +other label; an active snooze or unacknowledged **Woke** marker also takes the +slot before activity details. An active ADE chat in its authoritative +plan interaction mode reads **Planning** in violet; Codex uses the collaboration +mode accepted by the active `turn/start`. An eligible, current activity report +replaces the generic **Working** label for a live turn with one fixed short +label and its matching activity glyph on desktop, ADE Code, and iOS; without +one, the card shows **Planning** or **Working** from the provider mode. Once the +foreground turn is idle, provider-reported background tasks read blue **Background work** (**Background work ×N** when several are live), while an armed `nextWakeAt` reads neutral **Waiting** with a compact countdown. Naming that state rather than reusing @@ -359,10 +365,12 @@ live), while an armed `nextWakeAt` reads neutral "Working" on a finished turn is indistinguishable from one that has hung. These contextual labels do not change the canonical lifecycle, filing bucket, filters, or attention count, and CLI output -is never scraped to infer plan mode. Working/Planning elapsed time ticks from -the active chat's immutable `currentTurnStartedAt`, so streamed activity cannot -reset it; legacy chat rows without that anchor, plus CLI and Stale durations, -use last activity. Background work counts from `backgroundWorkSince` — when the +is never scraped to infer plan mode. Working, Planning, and activity elapsed +time use the active chat's immutable `currentTurnStartedAt` when available, so +streamed activity cannot reset the timer. Desktop and CLI fall back to last +activity when that anchor is absent. iOS uses an eligible activity report's +`updatedAt` first, then the row's activity timestamp. Background work counts +from `backgroundWorkSince` — when the session's live background set last went from empty to non-empty — which the runtime reports on the session summary. Anchoring it to last activity instead made it meaningless: every provider frame refreshes that column, so a job that diff --git a/packages/chat-ui/package-lock.json b/packages/chat-ui/package-lock.json index 0ba4b72e2a..e15ad76ebd 100644 --- a/packages/chat-ui/package-lock.json +++ b/packages/chat-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ade-dev/chat-ui", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ade-dev/chat-ui", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "devDependencies": { "@ade-dev/sdk": "file:../sdk", @@ -37,7 +37,7 @@ }, "../sdk": { "name": "@ade-dev/sdk", - "version": "0.2.2", + "version": "0.2.3", "dev": true, "license": "MIT", "devDependencies": { diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index 195b24b335..2810ab99a9 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ade-dev/chat-ui", - "version": "0.2.2", + "version": "0.2.3", "description": "Embeddable React chat components for the ADE SDK.", "license": "MIT", "type": "module", diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index 42a15ddbdb..875f5ef3aa 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ade-dev/sdk", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ade-dev/sdk", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "devDependencies": { "@types/node": "^20.11.30", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1db4d34fed..9cbe76a9dc 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@ade-dev/sdk", - "version": "0.2.2", + "version": "0.2.3", "description": "Typed Node/Electron-main client that owns a slim ADE runtime and exposes chat as durable named threads.", "license": "MIT", "type": "module", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 935b215418..f16100d2df 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -97,6 +97,7 @@ export { export type { AdeProvider, + AgentChatCodexCollaborationMode, AgentChatEvent, AgentChatEventEnvelope, AgentChatFileRef, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 75b1ea00c1..0733c79f96 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -33,6 +33,9 @@ export type AdeProvider = export type AgentChatSessionStatus = "active" | "idle" | "ended"; +/** Collaboration mode accepted by the Codex app-server for an active turn/start. */ +export type AgentChatCodexCollaborationMode = "default" | "plan"; + export type AgentChatPermissionMode = | "default" | "auto" @@ -398,6 +401,10 @@ export type AgentChatSessionSummary = { title?: string | null; reasoningEffort?: string | null; permissionMode?: AgentChatPermissionMode; + /** Accepted Codex app-server collaboration mode, when a live runtime reports one. */ + codexEffectiveCollaborationMode?: AgentChatCodexCollaborationMode; + /** True when a live Codex runtime confirms there is no accepted turn mode. */ + codexEffectiveCollaborationModeWasCleared?: boolean; status: AgentChatSessionStatus; startedAt: string; endedAt: string | null; diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts index 9108a9543e..e2aef5fc04 100644 --- a/packages/sdk/src/version.ts +++ b/packages/sdk/src/version.ts @@ -16,4 +16,4 @@ declare const __ADE_SDK_VERSION__: string | undefined; export const SDK_VERSION: string = - typeof __ADE_SDK_VERSION__ === "string" ? __ADE_SDK_VERSION__ : "0.2.2"; + typeof __ADE_SDK_VERSION__ === "string" ? __ADE_SDK_VERSION__ : "0.2.3";