From ae6802ee3b2b1452484e587ecb7a96f1a189bbd3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:02:25 -0400 Subject: [PATCH 1/2] feat(cto): make the operator tools real, keep mutations off primary, reach iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups to #986, all rooted in the same fact: the CTO session is pinned to the project's PRIMARY lane. It does not have a lane of its own. Operator tools now execute. createCtoOperatorTools was only ever reached by previewSessionToolNames and the prompt manifest, so the CTO was told it had a tool surface it could not call. createCtoRuntimeToolMap now feeds all five provider transports: an `ade-cto` SDK MCP server for Claude (deliberately without the orchestration lead's allowManagedMcpServersOnly lockdown, which would strip the user's own MCP servers from a daily-driver chat), the `ade_cto` namespace inside Codex's single dynamic-tool refresher, and a second HTTP MCP lease for Cursor/Droid/OpenCode. buildCtoOperatorToolDeps is shared by the preview and runtime paths so the advertised and callable surfaces cannot drift. Mutating git tools no longer default their lane. resolveLaneId defaulted to the CTO's session lane, so an omitted laneId on gitCommit/gitPush/gitPull — or on gitUndo/gitRedo, which run `git reset --hard` — meant writing to the primary worktree, the exact thing lanes exist to prevent. Reads keep the default; mutations must name a lane. Enforced in code rather than trusting the model to have read the manifest rule. Attention reaches iOS. The dot was desktop-only because the phone talks to the CTO over sync commands, a third transport. cto.getAttention delegates to the same canonical getCtoAttention() and is registered as an OPTIONAL mobile capability — requiring it would flip every shipped brain to limited mode. Co-Authored-By: Claude Opus 5 --- .../src/services/sync/syncHostService.test.ts | 1 + .../services/sync/syncRemoteCommandService.ts | 7 + .../ai/tools/ctoOperatorTools.test.ts | 64 +++++ .../services/ai/tools/ctoOperatorTools.ts | 83 +++--- .../services/chat/agentChatService.test.ts | 66 ++++- .../main/services/chat/agentChatService.ts | 242 ++++++++++++++---- .../sync/syncRemoteCommandService.test.ts | 20 ++ .../src/shared/syncMobileCompatibility.ts | 5 + apps/desktop/src/shared/types/sync.ts | 1 + apps/ios/ADE/App/ContentView.swift | 8 + apps/ios/ADE/Models/RemoteModels.swift | 13 + apps/ios/ADE/Services/SyncService.swift | 54 ++++ docs/ARCHITECTURE.md | 10 +- docs/features/agents/tool-registration.md | 11 + docs/features/chat/agent-routing.md | 11 +- docs/features/chat/tool-system.md | 44 +++- docs/features/cto/README.md | 66 ++++- docs/features/sync-and-multi-device/README.md | 7 +- .../sync-and-multi-device/ios-companion.md | 19 +- .../sync-and-multi-device/remote-commands.md | 16 ++ 20 files changed, 641 insertions(+), 107 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 62aab9411..7ada10cc8 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -4814,6 +4814,7 @@ describe("CTO-gated Linear sync commands", () => { "cto.completeLinearMobileOAuth", "cto.setLinearToken", "cto.clearLinearToken", + "cto.getAttention", "session.settleSessions", "session.unsettleSessions", "session.setSettleOverride", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index d083b97e6..37a3653cf 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -4779,6 +4779,13 @@ function registerCtoRemoteCommands({ args, register }: RemoteCommandRegistration const recentLimit = asOptionalNumber(payload.recentLimit); return ctoStateService.getSnapshot(recentLimit ?? 20); }); + register("cto.getAttention", { viewerAllowed: true }, async () => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + // Strictly read-only: `getCtoAttention` never calls ensureIdentitySession, + // so a phone drawing a badge cannot materialize a primary lane and a CTO + // chat session as a side effect. Returns CtoAttentionState verbatim. + return agentChatService.getCtoAttention(); + }); register("cto.getMemory", { viewerAllowed: true }, async () => { const ctoMemoryService = requireService(args.ctoMemoryService, "CTO memory service not available."); // Returns the exact CtoMemorySnapshot shape the iOS client decodes: diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index 2c2204e07..785bd84f6 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -182,6 +182,70 @@ describe("createCtoOperatorTools", () => { expect(gitService.stashPop).not.toHaveBeenCalled(); }); + // The CTO session is pinned to the project's primary lane, so `defaultLaneId` + // IS primary. A mutating git call with the lane omitted used to mean "write to + // the primary worktree" — the thing lanes exist to prevent. + describe("mutating git tools refuse to default the lane", () => { + const MUTATIONS: Array<[string, Record]> = [ + ["gitCommit", { message: "wip" }], + ["gitPush", {}], + ["gitPull", {}], + ["gitUndoLastHeadChange", {}], + ["gitRedoLastHeadChange", {}], + ["gitStashPush", {}], + ["gitStashPop", {}], + ["gitCheckoutBranch", { branch: "feature" }], + ["gitRebaseContinue", {}], + ["gitRebaseAbort", {}], + ["gitMergeAbort", {}], + ]; + + it.each(MUTATIONS)("%s errors instead of falling back to the CTO's lane", async (toolName, args) => { + const gitService = { + commit: vi.fn(), push: vi.fn(), pull: vi.fn(), + undoLastHeadChange: vi.fn(), redoLastHeadChange: vi.fn(), + stashPush: vi.fn(), stashPop: vi.fn(), listStashes: vi.fn().mockResolvedValue([]), + checkoutBranch: vi.fn(), rebaseContinue: vi.fn(), rebaseAbort: vi.fn(), mergeAbort: vi.fn(), + }; + const deps = buildDeps({ gitService: gitService as any }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools[toolName] as any).execute(args); + + expect(result.success).toBe(false); + expect(result.error).toContain("needs an explicit laneId"); + // Nothing may have touched git — especially not on the default lane. + for (const [name, call] of Object.entries(gitService)) { + if (name === "listStashes") continue; // read-only lookup inside gitStashPop + expect(call, `${name} must not run without an explicit lane`).not.toHaveBeenCalled(); + } + }); + + it("still runs the mutation when the lane is named", async () => { + const gitService = { commit: vi.fn().mockResolvedValue({ operationId: "commit-1" }) }; + const deps = buildDeps({ gitService: gitService as any }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.gitCommit as any).execute({ laneId: "lane-9", message: "real work" }); + + expect(gitService.commit).toHaveBeenCalledWith( + expect.objectContaining({ laneId: "lane-9", message: "real work" }), + ); + expect(result).toMatchObject({ success: true }); + }); + + it("keeps defaulting the lane for read-only git inspection", async () => { + const gitService = { getSyncStatus: vi.fn().mockResolvedValue({ branch: "main" }) }; + const deps = buildDeps({ gitService: gitService as any }); + const tools = createCtoOperatorTools(deps); + + await (tools.gitStatus as any).execute({}); + + // Reading the primary lane is normal supervision, so this default stays. + expect(gitService.getSyncStatus).toHaveBeenCalledWith({ laneId: "lane-1" }); + }); + }); + // ── Chat tools ────────────────────────────────────────────────── describe("chat tools", () => { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index b532d6c3f..158981164 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -1380,7 +1380,32 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record laneId?.trim() || deps.defaultLaneId; + /** + * Lane for a *read*. Defaulting to the CTO's own session lane is fine here: + * inspecting the primary lane is normal supervision. + */ + const resolveReadLaneId = (laneId?: string): string => laneId?.trim() || deps.defaultLaneId; + + /** + * Lane for a *mutation*. Deliberately has no default. + * + * `deps.defaultLaneId` is the CTO session's lane, and the CTO session is + * pinned to the project's primary lane — so defaulting here would turn an + * omitted `laneId` into "commit/push/reset the primary worktree", which is + * exactly what lanes exist to prevent. The capability manifest tells the CTO + * to name a lane for real work; this enforces it in code instead of trusting + * the model to have read the prompt. `gitGuard`/`conflictGuard` turn the + * throw into a `{ success: false, error }` the CTO can recover from by + * retrying with an explicit lane. + */ + const requireMutationLaneId = (laneId: string | undefined, operation: string): string => { + const trimmed = laneId?.trim(); + if (trimmed) return trimmed; + throw new Error( + `${operation} needs an explicit laneId. It is a mutating git operation and there is no safe default — ` + + "the CTO's own lane is the project's primary lane. Call listLanes and pass the lane you mean.", + ); + }; const gitGuard = async (fn: () => Promise): Promise<{ success: true } & T | { success: false; error: string }> => { if (!deps.gitService) return { success: false, error: "Git service is not available." }; @@ -1394,53 +1419,53 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.getSyncStatus({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.getSyncStatus({ laneId: resolveReadLaneId(laneId) })), }); tools.gitCommit = tool({ - description: "Create a git commit in a lane. By default stages all changes (stageAll: true). Use gitStatus first to see what will be committed.", - inputSchema: z.object({ laneId: z.string().optional(), message: z.string().min(1).describe("Commit message."), stageAll: z.boolean().optional().default(true).describe("Stage all changes before committing.") }), - execute: ({ laneId, message, stageAll }) => gitGuard(() => deps.gitService!.commit({ laneId: resolveLaneId(laneId), message, stageAll })), + description: "Create a git commit in a named lane. By default stages all changes (stageAll: true). Use gitStatus first to see what will be committed. Never commits to the CTO's own lane by default — laneId is required.", + inputSchema: z.object({ laneId: z.string().min(1).describe("Lane to commit in. Required — there is no default."), message: z.string().min(1).describe("Commit message."), stageAll: z.boolean().optional().default(true).describe("Stage all changes before committing.") }), + execute: ({ laneId, message, stageAll }) => gitGuard(() => deps.gitService!.commit({ laneId: requireMutationLaneId(laneId, "gitCommit"), message, stageAll })), }); tools.gitPush = tool({ - description: "Push commits to the remote for a lane.", - inputSchema: z.object({ laneId: z.string().optional(), force: z.boolean().optional().default(false) }), - execute: ({ laneId, force }) => gitGuard(() => deps.gitService!.push({ laneId: resolveLaneId(laneId), force })), + description: "Push commits to the remote for a named lane. laneId is required — there is no default.", + inputSchema: z.object({ laneId: z.string().min(1).describe("Lane to push. Required — there is no default."), force: z.boolean().optional().default(false) }), + execute: ({ laneId, force }) => gitGuard(() => deps.gitService!.push({ laneId: requireMutationLaneId(laneId, "gitPush"), force })), }); tools.gitPull = tool({ description: "Pull from the remote for a lane. Defaults to fast-forward only; use rebase or merge when that is the intended history shape.", inputSchema: z.object({ - laneId: z.string().optional(), + laneId: z.string().min(1).describe("Lane to pull into. Required — there is no default."), mode: z.enum(["ff-only", "rebase", "merge"]).optional().default("ff-only"), }), - execute: ({ laneId, mode }) => gitGuard(() => deps.gitService!.pull({ laneId: resolveLaneId(laneId), mode })), + execute: ({ laneId, mode }) => gitGuard(() => deps.gitService!.pull({ laneId: requireMutationLaneId(laneId, "gitPull"), mode })), }); tools.gitUndoLastHeadChange = tool({ - description: "Undo the latest successful head-changing git operation recorded by ADE for a lane. This resets the lane with git reset --hard.", + description: "Undo the latest successful head-changing git operation recorded by ADE for a named lane. This resets the lane with git reset --hard, so laneId is required — there is no default.", inputSchema: z.object({ laneId: z.string().optional() }), - execute: ({ laneId }) => gitGuard(() => deps.gitService!.undoLastHeadChange({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.undoLastHeadChange({ laneId: requireMutationLaneId(laneId, "gitUndo") })), }); tools.gitRedoLastHeadChange = tool({ - description: "Redo the latest successful ADE git undo for a lane. This resets the lane with git reset --hard.", + description: "Redo the latest successful ADE git undo for a named lane. This resets the lane with git reset --hard, so laneId is required — there is no default.", inputSchema: z.object({ laneId: z.string().optional() }), - execute: ({ laneId }) => gitGuard(() => deps.gitService!.redoLastHeadChange({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.redoLastHeadChange({ laneId: requireMutationLaneId(laneId, "gitRedo") })), }); tools.gitFetch = tool({ description: "Fetch remote refs for a lane.", inputSchema: z.object({ laneId: z.string().optional() }), - execute: ({ laneId }) => gitGuard(() => deps.gitService!.fetch({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.fetch({ laneId: resolveReadLaneId(laneId) })), }); tools.gitListRecentCommits = tool({ description: "List recent commits in a lane.", inputSchema: z.object({ laneId: z.string().optional(), limit: z.number().int().positive().max(100).optional().default(20) }), execute: ({ laneId, limit }) => gitGuard(async () => { - const commits = await deps.gitService!.listRecentCommits({ laneId: resolveLaneId(laneId), limit }); + const commits = await deps.gitService!.listRecentCommits({ laneId: resolveReadLaneId(laneId), limit }); return { count: commits.length, commits }; }), }); @@ -1449,7 +1474,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(async () => { - const branches = await deps.gitService!.listBranches({ laneId: resolveLaneId(laneId) }); + const branches = await deps.gitService!.listBranches({ laneId: resolveReadLaneId(laneId) }); return { count: branches.length, branches }; }), }); @@ -1457,7 +1482,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.checkoutBranch({ - laneId: resolveLaneId(laneId), + laneId: requireMutationLaneId(laneId, "gitCheckoutBranch"), branchName: branch, mode: create ? "create" : "existing", startPoint, @@ -1476,15 +1501,15 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.stashPush({ laneId: resolveLaneId(laneId), ...(message?.trim() ? { message: message.trim() } : {}) })), + inputSchema: z.object({ laneId: z.string().min(1).describe("Lane to stash in. Required — there is no default."), message: z.string().optional() }), + execute: ({ laneId, message }) => gitGuard(() => deps.gitService!.stashPush({ laneId: requireMutationLaneId(laneId, "gitStashPush"), ...(message?.trim() ? { message: message.trim() } : {}) })), }); tools.gitStashPop = tool({ description: "Pop a stash saved for a lane branch. Defaults to the latest branch-matching stash; call gitStashList to inspect refs.", - inputSchema: z.object({ laneId: z.string().optional(), stashRef: z.string().optional() }), + inputSchema: z.object({ laneId: z.string().min(1).describe("Lane to pop the stash in. Required — there is no default."), stashRef: z.string().optional() }), execute: ({ laneId, stashRef }) => gitGuard(async () => { - const resolvedLaneId = resolveLaneId(laneId); + const resolvedLaneId = requireMutationLaneId(laneId, "gitStashPop"); const trimmedRef = stashRef?.trim(); const stashes = await deps.gitService!.listStashes({ laneId: resolvedLaneId }); const selectedStash = trimmedRef @@ -1507,7 +1532,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(async () => { - const stashes = await deps.gitService!.listStashes({ laneId: resolveLaneId(laneId) }); + const stashes = await deps.gitService!.listStashes({ laneId: resolveReadLaneId(laneId) }); return { count: stashes.length, stashes }; }), }); @@ -1515,25 +1540,25 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.getConflictState({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.getConflictState({ laneId: resolveReadLaneId(laneId) })), }); tools.gitRebaseContinue = tool({ description: "Continue a rebase after resolving conflicts.", inputSchema: z.object({ laneId: z.string().optional() }), - execute: ({ laneId }) => gitGuard(() => deps.gitService!.rebaseContinue({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.rebaseContinue({ laneId: requireMutationLaneId(laneId, "gitRebaseContinue") })), }); tools.gitRebaseAbort = tool({ description: "Abort an in-progress rebase.", inputSchema: z.object({ laneId: z.string().optional() }), - execute: ({ laneId }) => gitGuard(() => deps.gitService!.rebaseAbort({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.rebaseAbort({ laneId: requireMutationLaneId(laneId, "gitRebaseAbort") })), }); tools.gitMergeAbort = tool({ description: "Abort an in-progress merge.", inputSchema: z.object({ laneId: z.string().optional() }), - execute: ({ laneId }) => gitGuard(() => deps.gitService!.mergeAbort({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.mergeAbort({ laneId: requireMutationLaneId(laneId, "gitMergeAbort") })), }); // --------------------------------------------------------------------------- @@ -1552,7 +1577,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record conflictGuard(() => deps.conflictService!.getLaneStatus({ laneId: resolveLaneId(laneId) })), + execute: ({ laneId }) => conflictGuard(() => deps.conflictService!.getLaneStatus({ laneId: resolveReadLaneId(laneId) })), }); tools.getConflictRiskMatrix = tool({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2d07e5758..75a1d6fa4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -574,9 +574,20 @@ vi.mock("../ai/tools/linearTools", () => ({ createLinearTools: vi.fn(() => []), })); -vi.mock("../ai/tools/ctoOperatorTools", () => ({ - createCtoOperatorTools: vi.fn(() => []), -})); +vi.mock("../ai/tools/ctoOperatorTools", async () => { + const { z } = await import("zod"); + // Returns one real ExecutableTool so tests can assert the CTO tool surface is + // actually registered on a live session, not just enumerated for the prompt. + return { + createCtoOperatorTools: vi.fn(() => ({ + spawnChat: { + description: "Create a native ADE work chat session.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: async () => ({ success: true }), + }, + })), + }; +}); vi.mock("../ai/tools/systemPrompt", () => ({ buildCodingAgentSystemPrompt: vi.fn(() => "system prompt"), @@ -3608,6 +3619,55 @@ describe("createAgentChatService", () => { expect(opts?.strictMcpConfig).toBeUndefined(); }); + // The CTO's operator tools used to exist only in the prompt manifest and the + // tool-name preview — the bodies were never registered on a live session, so + // the CTO was told it had tools it could not call. + it("registers the CTO operator tools on a live Claude CTO session", async () => { + const { service } = createService(); + const session = await service.ensureIdentitySession({ identityKey: "cto", laneId: "lane-1" }); + await service.updateSession({ sessionId: session.id, modelId: "anthropic/claude-sonnet-5" }); + await service.sendMessage({ sessionId: session.id, text: "status?" }); + + await vi.waitFor(() => { + expect(claudeSdkCreateSessionCompat).toHaveBeenCalled(); + }); + + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls.at(-1)?.[0] as any; + const server = opts?.mcpServers?.["ade-cto"]; + expect(server?.type).toBe("sdk"); + const toolNames = Object.keys(server?.instance?._registeredTools ?? {}); + expect(toolNames).toContain("spawnChat"); + + // Built from the LIVE session, not the `preview:` pseudo-session the + // prompt manifest uses — that distinction is the whole bug this fixes. + const { createCtoOperatorTools } = await import("../ai/tools/ctoOperatorTools"); + expect(vi.mocked(createCtoOperatorTools)).toHaveBeenCalledWith( + expect.objectContaining({ currentSessionId: session.id, defaultLaneId: "lane-1" }), + ); + + // The CTO is a daily-driver chat: it must NOT get the orchestration lead's + // managed-only MCP lockdown, which would strip the user's own servers. + expect(opts?.managedSettings?.allowManagedMcpServersOnly).toBeUndefined(); + }); + + it("does not register CTO tools on an ordinary chat", async () => { + const { service } = createService(); + const created = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "", + modelId: "anthropic/claude-sonnet-5", + }); + await service.sendMessage({ sessionId: created.id, text: "hello" }); + + await vi.waitFor(() => { + expect(claudeSdkCreateSessionCompat).toHaveBeenCalled(); + }); + + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls.at(-1)?.[0] as any; + expect(opts?.mcpServers?.["ade-cto"]).toBeUndefined(); + }); + it("keeps lightweight sessions lean by ignoring on-disk MCP config (strictMcpConfig)", async () => { fs.writeFileSync(path.join(tmpRoot, ".mcp.json"), JSON.stringify({ mcpServers: { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 456794982..37214799a 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2574,6 +2574,14 @@ type ManagedChatSession = { config: { type?: string; name?: string; url?: string; headers?: unknown }; close: () => Promise; } | null; + /** + * Separate lease from the orchestration one: each HTTP MCP server carries + * exactly one tool set, and a session can in principle want both. + */ + ctoHttpMcpServer: { + config: { type?: string; name?: string; url?: string; headers?: unknown }; + close: () => Promise; + } | null; activeBashControllers: Set; eventSequence: number; lastActivityTimestamp: number; @@ -6019,6 +6027,8 @@ function collectOrchestrationFields( const ORCHESTRATION_CLAUDE_SERVER_NAME = "ade-orchestration"; const ORCHESTRATION_CODEX_TOOL_NAMESPACE = "ade_orchestration"; +const CTO_MCP_SERVER_NAME = "ade-cto"; +const CTO_CODEX_TOOL_NAMESPACE = "ade_cto"; function stripJsonSchemaMeta(schema: unknown): unknown { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; @@ -8271,11 +8281,38 @@ export function createAgentChatService(args: { } if (identityKey === "cto") { - const ctoTools = createCtoOperatorTools({ + const ctoTools = createCtoOperatorTools(buildCtoOperatorToolDeps({ + sessionId, + laneId, + modelId: null, + reasoningEffort: null, + })); + for (const toolName of Object.keys(ctoTools)) { + toolNames.add(toolName); + } + } + + return Array.from(toolNames).sort((a, b) => a.localeCompare(b)); + }; + + /** + * The CTO operator tool dependency set. Shared by `previewSessionToolNames` + * (which only reads the keys, to generate the prompt manifest) and by the + * runtime tool map below (which actually executes them) so the advertised + * surface and the callable surface cannot drift apart. + */ + const buildCtoOperatorToolDeps = (args: { + sessionId: string; + laneId: string; + modelId: string | null; + reasoningEffort: string | null; + }): Parameters[0] => { + const { sessionId, laneId, modelId, reasoningEffort } = args; + return { currentSessionId: sessionId, defaultLaneId: laneId, - defaultModelId: null, - defaultReasoningEffort: null, + defaultModelId: modelId, + defaultReasoningEffort: reasoningEffort, resolveExecutionLane: resolveCtoExecutionLane, laneService, prService: prService ?? null, @@ -8314,15 +8351,29 @@ export function createAgentChatService(args: { permissionMode: "full-auto", }), previewSessionToolNames, - } as Parameters[0] & { - previewSessionToolNames: typeof previewSessionToolNames; - }); - for (const toolName of Object.keys(ctoTools)) { - toolNames.add(toolName); - } - } + } as Parameters[0] & { + previewSessionToolNames: typeof previewSessionToolNames; + }; + }; - return Array.from(toolNames).sort((a, b) => a.localeCompare(b)); + /** + * The CTO's operator tools for a LIVE session. + * + * Until this existed, `createCtoOperatorTools` was only ever reached by the + * prompt manifest and the tool-name preview — the bodies never executed, so + * the CTO was told it had tools it could not actually call. Returns null for + * every non-CTO session. + */ + const createCtoRuntimeToolMap = ( + managed: ManagedChatSession, + ): OrchestrationToolMap | null => { + if (managed.session.identityKey !== "cto") return null; + return createCtoOperatorTools(buildCtoOperatorToolDeps({ + sessionId: managed.session.id, + laneId: managed.session.laneId, + modelId: managed.session.modelId ?? null, + reasoningEffort: managed.session.reasoningEffort ?? null, + })); }; const deriveSessionCapabilities = (managed: ManagedChatSession | null): AgentChatSessionCapabilities => { @@ -10783,14 +10834,18 @@ export function createAgentChatService(args: { } } const orchestrationMcp = await ensureOrchestrationHttpMcpServer(managed); - const opencodeOrchestrationMcp = orchestrationMcp?.config.url + const ctoMcp = await ensureCtoHttpMcpServer(managed); + const opencodeRemoteMcpEntry = (name: string, url: string) => ({ + [name]: { type: "remote" as const, url, enabled: true, timeout: 10_000 }, + }); + const opencodeOrchestrationMcp = orchestrationMcp?.config.url || ctoMcp?.config.url ? { - [ORCHESTRATION_CLAUDE_SERVER_NAME]: { - type: "remote" as const, - url: orchestrationMcp.config.url, - enabled: true, - timeout: 10_000, - }, + ...(orchestrationMcp?.config.url + ? opencodeRemoteMcpEntry(ORCHESTRATION_CLAUDE_SERVER_NAME, orchestrationMcp.config.url) + : {}), + ...(ctoMcp?.config.url + ? opencodeRemoteMcpEntry(CTO_MCP_SERVER_NAME, ctoMcp.config.url) + : {}), } : undefined; let handle: OpenCodeSessionHandle; @@ -15415,14 +15470,47 @@ export function createAgentChatService(args: { return shape && typeof shape === "object" && !Array.isArray(shape) ? shape : {}; }; - const ensureOrchestrationHttpMcpServer = async ( + /** + * One HTTP MCP lease per tool set. Orchestration and CTO tools are different + * surfaces on potentially the same session, and each lease carries exactly one + * tool set, so they get separate server names and separate cache fields. + */ + type HttpMcpToolSet = "orchestration" | "cto"; + + const httpMcpServerName = (toolSet: HttpMcpToolSet): string => + toolSet === "cto" ? CTO_MCP_SERVER_NAME : ORCHESTRATION_CLAUDE_SERVER_NAME; + + const httpMcpToolMap = ( + managed: ManagedChatSession, + toolSet: HttpMcpToolSet, + ): OrchestrationToolMap | null => + toolSet === "cto" ? createCtoRuntimeToolMap(managed) : createOrchestrationRuntimeToolMap(managed); + + const readHttpMcpLease = ( + managed: ManagedChatSession, + toolSet: HttpMcpToolSet, + ): ManagedChatSession["orchestrationHttpMcpServer"] => + toolSet === "cto" ? managed.ctoHttpMcpServer : managed.orchestrationHttpMcpServer; + + const writeHttpMcpLease = ( + managed: ManagedChatSession, + toolSet: HttpMcpToolSet, + lease: ManagedChatSession["orchestrationHttpMcpServer"], + ): void => { + if (toolSet === "cto") managed.ctoHttpMcpServer = lease; + else managed.orchestrationHttpMcpServer = lease; + }; + + const ensureHttpMcpServer = async ( managed: ManagedChatSession, + toolSet: HttpMcpToolSet, ): Promise => { - if (managed.orchestrationHttpMcpServer) return managed.orchestrationHttpMcpServer; - const tools = createOrchestrationRuntimeToolMap(managed); + const existing = readHttpMcpLease(managed, toolSet); + if (existing) return existing; + const tools = httpMcpToolMap(managed, toolSet); if (!tools) return null; const server = createDroidSdkMcpServer({ - name: ORCHESTRATION_CLAUDE_SERVER_NAME, + name: httpMcpServerName(toolSet), version: appVersion, tools: Object.entries(tools).map(([name, toolDefinition]) => createDroidSdkTool( @@ -15442,58 +15530,89 @@ export function createAgentChatService(args: { ), }); const config = await server.start(); - managed.orchestrationHttpMcpServer = { - config, - close: () => server.close(), - }; - return managed.orchestrationHttpMcpServer; + const lease = { config, close: () => server.close() }; + writeHttpMcpLease(managed, toolSet, lease); + return lease; }; - const closeOrchestrationHttpMcpServer = (managed: ManagedChatSession): void => { - const lease = managed.orchestrationHttpMcpServer; - managed.orchestrationHttpMcpServer = null; + const ensureOrchestrationHttpMcpServer = ( + managed: ManagedChatSession, + ): Promise => + ensureHttpMcpServer(managed, "orchestration"); + + const ensureCtoHttpMcpServer = ( + managed: ManagedChatSession, + ): Promise => + ensureHttpMcpServer(managed, "cto"); + + const closeHttpMcpServer = (managed: ManagedChatSession, toolSet: HttpMcpToolSet): void => { + const lease = readHttpMcpLease(managed, toolSet); + writeHttpMcpLease(managed, toolSet, null); if (!lease) return; lease.close().catch((error) => { logger.warn("agent_chat.orchestration_mcp_close_failed", { sessionId: managed.session.id, + toolSet, error: error instanceof Error ? error.message : String(error), }); }); }; + /** Closes both leases. Every teardown path must drop the CTO one too. */ + const closeOrchestrationHttpMcpServer = (managed: ManagedChatSession): void => { + closeHttpMcpServer(managed, "orchestration"); + closeHttpMcpServer(managed, "cto"); + }; + const codexDynamicToolKey = (namespace: string | null | undefined, name: string): string => `${namespace ?? ""}\u0000${name}`; const buildCodexDynamicToolSpecs = ( tools: OrchestrationToolMap, + namespace: string, ): CodexDynamicToolSpec[] => Object.entries(tools).map(([name, toolDefinition]) => ({ - namespace: ORCHESTRATION_CODEX_TOOL_NAMESPACE, + namespace, name, description: toolDefinition.description, inputSchema: jsonSchemaForExecutableTool(toolDefinition), deferLoading: false, })); + /** + * Rebuilds the whole dynamic-tool map for a Codex runtime. + * + * Both tool sets must be registered here rather than in separate refreshers: + * this clears the map first, so a second refresher would clobber the first. + * They live under distinct namespaces so their names cannot collide. + */ const refreshCodexDynamicTools = ( managed: ManagedChatSession, runtime: CodexRuntime, ): CodexDynamicToolSpec[] => { runtime.dynamicTools.clear(); runtime.dynamicToolSpecs = []; - const tools = createOrchestrationRuntimeToolMap(managed); - if (!tools) return []; - for (const [name, toolDefinition] of Object.entries(tools)) { - runtime.dynamicTools.set(codexDynamicToolKey(ORCHESTRATION_CODEX_TOOL_NAMESPACE, name), toolDefinition); + const specs: CodexDynamicToolSpec[] = []; + const toolSets: Array<[string, OrchestrationToolMap | null]> = [ + [ORCHESTRATION_CODEX_TOOL_NAMESPACE, createOrchestrationRuntimeToolMap(managed)], + [CTO_CODEX_TOOL_NAMESPACE, createCtoRuntimeToolMap(managed)], + ]; + for (const [namespace, tools] of toolSets) { + if (!tools) continue; + for (const [name, toolDefinition] of Object.entries(tools)) { + runtime.dynamicTools.set(codexDynamicToolKey(namespace, name), toolDefinition); + } + specs.push(...buildCodexDynamicToolSpecs(tools, namespace)); } - runtime.dynamicToolSpecs = buildCodexDynamicToolSpecs(tools); + runtime.dynamicToolSpecs = specs; return runtime.dynamicToolSpecs; }; - const buildClaudeOrchestrationMcpServer = ( + const buildClaudeSdkMcpServer = ( managed: ManagedChatSession, + toolSet: HttpMcpToolSet, ): ReturnType | null => { - const tools = createOrchestrationRuntimeToolMap(managed); + const tools = httpMcpToolMap(managed, toolSet); if (!tools) return null; const sdkTools = Object.entries(tools).map(([name, toolDefinition]) => createClaudeSdkTool( @@ -15530,13 +15649,21 @@ export function createAgentChatService(args: { ), ); return createSdkMcpServer({ - name: ORCHESTRATION_CLAUDE_SERVER_NAME, + name: httpMcpServerName(toolSet), version: appVersion, tools: sdkTools, alwaysLoad: true, }); }; + const buildClaudeOrchestrationMcpServer = ( + managed: ManagedChatSession, + ): ReturnType | null => buildClaudeSdkMcpServer(managed, "orchestration"); + + const buildClaudeCtoMcpServer = ( + managed: ManagedChatSession, + ): ReturnType | null => buildClaudeSdkMcpServer(managed, "cto"); + const setOpenCodeRuntimeBusy = (runtime: OpenCodeRuntime, busy: boolean): void => { runtime.busy = busy; runtime.handle.setBusy(busy); @@ -16104,6 +16231,7 @@ export function createAgentChatService(args: { })) ?? [], localPendingInputs: new Map(), orchestrationHttpMcpServer: null, + ctoHttpMcpServer: null, activeBashControllers: new Set(), eventSequence: 0, lastActivityTimestamp: Date.now(), @@ -22022,9 +22150,12 @@ export function createAgentChatService(args: { if (runtime.dynamicTools.size === 0) { refreshCodexDynamicTools(managed, runtime); } + // Namespace-less or mis-namespaced calls fall back by name across both + // registered tool sets rather than failing the turn. const toolDefinition = runtime.dynamicTools.get(codexDynamicToolKey(namespace, toolName)) - ?? runtime.dynamicTools.get(codexDynamicToolKey(ORCHESTRATION_CODEX_TOOL_NAMESPACE, toolName)); + ?? runtime.dynamicTools.get(codexDynamicToolKey(ORCHESTRATION_CODEX_TOOL_NAMESPACE, toolName)) + ?? runtime.dynamicTools.get(codexDynamicToolKey(CTO_CODEX_TOOL_NAMESPACE, toolName)); if (!toolDefinition) { runtime.sendResponse(id, { success: false, @@ -26941,6 +27072,13 @@ export function createAgentChatService(args: { ...ORCHESTRATION_LEAD_DENIED_CLAUDE_TOOLS, ])); } + // The CTO tool server is injected without the `allowManagedMcpServersOnly` + // isolation the orchestration lead uses — the CTO is a daily driver chat and + // must keep the user's own MCP servers. + const ctoMcpServer = buildClaudeCtoMcpServer(managed); + if (ctoMcpServer) { + opts.mcpServers = { ...(opts.mcpServers ?? {}), [CTO_MCP_SERVER_NAME]: ctoMcpServer }; + } const orchestrationMcpServer = buildClaudeOrchestrationMcpServer(managed); if (orchestrationMcpServer) { opts.mcpServers = { @@ -28208,6 +28346,7 @@ export function createAgentChatService(args: { recentConversationEntries: [], localPendingInputs: new Map(), orchestrationHttpMcpServer: null, + ctoHttpMcpServer: null, activeBashControllers: new Set(), eventSequence: 0, lastActivityTimestamp: Date.now(), @@ -28878,6 +29017,7 @@ export function createAgentChatService(args: { recentConversationEntries: [], localPendingInputs: new Map(), orchestrationHttpMcpServer: null, + ctoHttpMcpServer: null, activeBashControllers: new Set(), eventSequence: 0, lastActivityTimestamp: Date.now(), @@ -32867,12 +33007,15 @@ export function createAgentChatService(args: { } const orchestrationMcp = await ensureOrchestrationHttpMcpServer(managed); - const cursorOrchestrationMcpServers = orchestrationMcp?.config.url + const cursorCtoMcp = await ensureCtoHttpMcpServer(managed); + const cursorOrchestrationMcpServers = orchestrationMcp?.config.url || cursorCtoMcp?.config.url ? { - [ORCHESTRATION_CLAUDE_SERVER_NAME]: { - type: "http" as const, - url: orchestrationMcp.config.url, - }, + ...(orchestrationMcp?.config.url + ? { [ORCHESTRATION_CLAUDE_SERVER_NAME]: { type: "http" as const, url: orchestrationMcp.config.url } } + : {}), + ...(cursorCtoMcp?.config.url + ? { [CTO_MCP_SERVER_NAME]: { type: "http" as const, url: cursorCtoMcp.config.url } } + : {}), } : undefined; @@ -34135,9 +34278,12 @@ export function createAgentChatService(args: { const auth = await detectAuth(); throwIfDroidSetupInterrupted(); const orchestrationMcp = await ensureOrchestrationHttpMcpServer(managed); - const droidOrchestrationMcpServers = orchestrationMcp?.config.url - ? [orchestrationMcp.config] - : undefined; + const droidCtoMcp = await ensureCtoHttpMcpServer(managed); + const droidMcpConfigs = [ + ...(orchestrationMcp?.config.url ? [orchestrationMcp.config] : []), + ...(droidCtoMcp?.config.url ? [droidCtoMcp.config] : []), + ]; + const droidOrchestrationMcpServers = droidMcpConfigs.length ? droidMcpConfigs : undefined; const persisted = readPersistedState(managed.session.id); const acquired = await acquireDroidSdkConnection({ poolKey, diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 8e8e7d669..3ed583f5b 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -207,6 +207,7 @@ function createMockAgentChatService() { summary: null, }), getChatTranscript: vi.fn().mockResolvedValue([]), + getCtoAttention: vi.fn().mockResolvedValue({ awaitingInput: true, since: "2026-01-01T00:00:00.000Z" }), createSession: vi.fn().mockResolvedValue({ id: "chat-1", laneId: "lane-1", @@ -2523,6 +2524,25 @@ describe("createSyncRemoteCommandService", () => { expect(service.getSupportedActions()).toContain("cto.getMemory"); }); + it("cto.getAttention relays the CTO's waiting state to the phone", async () => { + const result = await service.execute(makePayload("cto.getAttention", {})); + + // The phone cannot derive this from its chat roster — the CTO chat is + // excluded from every session list — so this is its only source. + expect(result).toEqual({ awaitingInput: true, since: "2026-01-01T00:00:00.000Z" }); + expect(service.getSupportedActions()).toContain("cto.getAttention"); + }); + + it("cto.getAttention never creates a CTO session or a primary lane", async () => { + agentChatService.ensureIdentitySession.mockClear(); + + await service.execute(makePayload("cto.getAttention", {})); + + // Drawing a phone badge must not materialize a lane and a chat session. + expect(agentChatService.ensureIdentitySession).not.toHaveBeenCalled(); + expect(agentChatService.createSession).not.toHaveBeenCalled(); + }); + it("cto exposes Linear quick view, issue picker, search, and comments through mobile sync", async () => { const quickView = await service.execute(makePayload("cto.getLinearQuickView", {})); expect(linearIssueTracker.getQuickView).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/desktop/src/shared/syncMobileCompatibility.ts b/apps/desktop/src/shared/syncMobileCompatibility.ts index 6e66277a0..825fefd4a 100644 --- a/apps/desktop/src/shared/syncMobileCompatibility.ts +++ b/apps/desktop/src/shared/syncMobileCompatibility.ts @@ -10,6 +10,11 @@ export const MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS = [ "cto.completeLinearMobileOAuth", "cto.setLinearToken", "cto.clearLinearToken", + // Whether the CTO thread is blocked on the user. The CTO chat is hidden from + // every session roster, so the phone cannot derive this from its chat list — + // it must ask. Optional so a phone on a newer build simply never lights the + // dot against an older brain. + "cto.getAttention", // Session lifecycle. The phone gates its settle/snooze affordances on these // appearing in hello_ok.features.commandRouting.actions, so they must be // advertised — but they stay OPTIONAL: shipped builds predating the feature diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index acccd755a..a2bef0aca 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1640,6 +1640,7 @@ export type SyncRemoteCommandAction = | "cto.ensureSession" | "cto.getState" | "cto.getMemory" + | "cto.getAttention" | "cto.getLinearConnectionStatus" | "cto.startLinearMobileOAuth" | "cto.completeLinearMobileOAuth" diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 1a266c358..bc9c12985 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -281,6 +281,14 @@ struct ContentView: View { .tabItem { Label("CTO", systemImage: "brain") } + // The CTO chat is excluded from every session roster, so it cannot borrow + // the Work badge above — a question from the CTO would otherwise surface + // nowhere on the phone. A string badge renders as a dot-sized marker and + // hides itself when nil. + .badge(syncService.ctoAttention.awaitingInput ? "!" : nil) + .accessibilityLabel( + syncService.ctoAttention.awaitingInput ? "CTO, waiting on you" : "CTO" + ) } } diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 7ee56e935..ff0da7e9d 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1203,6 +1203,19 @@ struct CtoSnapshot: Codable, Hashable { var recentSessions: [CtoRecentSession]? } +/// Returned by the `cto.getAttention` sync command: whether the CTO thread is +/// blocked on the user. +/// +/// The phone cannot derive this from its chat roster — the CTO chat is +/// deliberately excluded from every session list — so it has to ask. `since` is +/// optional because the host sends JSON `null` when nothing is waiting. +struct CtoAttention: Codable, Hashable { + var awaitingInput: Bool + var since: String? + + static let idle = CtoAttention(awaitingInput: false, since: nil) +} + /// Returned by the `cto.getMemory` sync command: the durable facts the CTO /// keeps (`MEMORY.md`), the rolling `thread-state.md`, and today's daily log. /// Every field is tolerant of a missing/null value so a partial host response diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 780251ad0..9d3d3d8f0 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3719,6 +3719,17 @@ final class SyncService: ObservableObject { /// for context but never listed. @Published private(set) var idleSessionsCount: Int = 0 + /// Whether the CTO thread is blocked on the user. + /// + /// Deliberately NOT folded into `awaitingInputSessionsCount`: the CTO chat is + /// excluded from every session roster, so the roster-derived counters above + /// can never see it. Without this the phone shows nothing at all when the CTO + /// asks a question. + @Published private(set) var ctoAttention: CtoAttention = .idle + + private var ctoAttentionTask: Task? + private var lastCtoAttentionFetchAt: Date? + /// 2s debounce task shared by all writers of the App Group workspace /// snapshot. Coalesces bursty state changes into a single widget reload. private var snapshotDebouncerTask: Task? @@ -8080,6 +8091,16 @@ final class SyncService: ObservableObject { return try await sendDecodableCommand(action: "cto.getState", args: args, as: CtoSnapshot.self) } + /// Whether the CTO thread is waiting on the user. + /// + /// Optional-gated: this action is in + /// `MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS`, so a phone on a newer build + /// talking to an older brain simply never lights the dot instead of erroring. + /// Callers should check `supportsRemoteAction("cto.getAttention")` first. + func fetchCtoAttention() async throws -> CtoAttention { + try await sendDecodableCommand(action: "cto.getAttention", as: CtoAttention.self) + } + /// Fetches the CTO's durable memory (`MEMORY.md`), rolling thread state, and /// today's daily log. The host command is version-gated: older hosts respond /// with a command error, which callers surface as a quiet "not available" @@ -18872,9 +18893,42 @@ extension SyncService { runningChatSessionCount = runningChatCount idleSessionsCount = idleCount + // The CTO is not in `allAgents` by design, so its dot has to be asked for + // separately off the same change pulse. + refreshCtoAttentionIfNeeded() + scheduleWorkspaceSnapshotWrite() } + /// Refreshes the CTO attention dot. Best-effort and debounced: it rides the + /// same "something changed" pulse as the roster rebuild, but the CTO is not in + /// that roster so it needs its own read. + /// + /// Failure keeps the last known value rather than clearing — falsely dropping + /// a pending question is worse than a slightly stale dot. + func refreshCtoAttentionIfNeeded(force: Bool = false) { + guard supportsRemoteAction("cto.getAttention") else { + // Older brain: never light the dot, and clear a value left over from a + // newer host we were previously paired with. + if ctoAttention.awaitingInput { ctoAttention = .idle } + return + } + guard canSendLiveRequests() else { return } + guard ctoAttentionTask == nil else { return } + if !force, let last = lastCtoAttentionFetchAt, Date().timeIntervalSince(last) < 5 { return } + lastCtoAttentionFetchAt = Date() + ctoAttentionTask = Task { [weak self] in + guard let self else { return } + defer { self.ctoAttentionTask = nil } + do { + let next = try await self.fetchCtoAttention() + self.ctoAttention = next + } catch { + // Keep the last known state. + } + } + } + private func activeSessionsSignature( agents: [AgentSnapshot], awaitingInputCount: Int, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2cd1ee840..f27815e0c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -563,7 +563,7 @@ Agent tools are split by domain: |------|--------| | `ai/tools/universalTools.ts` | Mutating tools (`bash`, `writeFile`, `editFile`), read/search tools, web tools, todos, and ask-user. | | `ai/tools/workflowTools.ts` | Workflow interaction tools. | -| `ai/tools/ctoOperatorTools.ts` | CTO-only operator tools. | +| `ai/tools/ctoOperatorTools.ts` | CTO-only operator tools. Registered on the live session via `createCtoRuntimeToolMap` through the per-provider transports (`ade-cto` SDK MCP server for Claude, the `ade_cto` dynamic-tool namespace for Codex, a dedicated HTTP MCP lease for Cursor/Droid/OpenCode). Git mutations require an explicit `laneId` because the CTO session is pinned to the primary lane. | | `ai/tools/linearTools.ts` | Linear integration tool surface. | | `ai/tools/webFetch.ts` / `webSearch.ts` | Outbound web access. | | `ai/tools/readFileRange.ts` / `globSearch.ts` / `grepSearch.ts` | Read-only file tools shared across all roles. | @@ -1038,9 +1038,11 @@ whole picture": it is filtered out of every roster, so it never appears in those rows. `useCtoAttention` reads it separately through the read-only `window.ade.cto.getAttention()` probe into `appStore.ctoAttention`, `TabNav` draws the dot on `/cto`, and `useAppWideSessionAttention` folds that one flag -into its badge count so it remains the single writer of `setDockBadgeCount`. The -probe must stay side-effect-free — creating the CTO session to draw a badge would -materialize a primary lane. See +into its badge count so it remains the single writer of `setDockBadgeCount`. iOS +reaches the same `agentChatService.getCtoAttention()` implementation through the +optional `cto.getAttention` sync command and badges its CTO tab. The probe must +stay side-effect-free on every transport — creating the CTO session to draw a +badge would materialize a primary lane. See [features/cto/README.md](./features/cto/README.md#hidden-from-rosters-but-never-silent). ### 8.3 ADE CLI auth + API-key storage diff --git a/docs/features/agents/tool-registration.md b/docs/features/agents/tool-registration.md index ed5ac2f47..ff2df064f 100644 --- a/docs/features/agents/tool-registration.md +++ b/docs/features/agents/tool-registration.md @@ -43,6 +43,17 @@ then hands them to the provider adapter: and ADE supplies a permission/hook bridge through `cursorSdkPool.ts` and `cursorSdkPolicy.ts`. ADE workflow actions are available through the `ade` CLI. +- **CTO sessions:** `createCtoRuntimeToolMap` (gated on + `identityKey === "cto"`) registers `ctoOperatorTools.ts` on the live + session through the same per-provider transports the orchestration set + uses: an `ade-cto` SDK MCP server for Claude (injected without + `allowManagedMcpServersOnly`, so the user's own MCP servers survive), the + `ade_cto` dynamic-tool namespace inside `refreshCodexDynamicTools` for + Codex, and a dedicated HTTP MCP lease (`ctoHttpMcpServer`) for Cursor, + Droid, and OpenCode. `buildCtoOperatorToolDeps` is shared with + `previewSessionToolNames`, so the prompt manifest and the callable tools + come from one definition. See + [chat/tool-system.md](../chat/tool-system.md#cto-operator-tools). - **Orchestration sessions:** `interactionMode` selects `orchestrator-lead`, `orchestrator-worker`, or `orchestrator-validator`. The lead receives a read-mostly base plus diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 8f2f55a78..aab84f5aa 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -474,16 +474,21 @@ CTO sessions (`identityKey: "cto"`) are routed differently: (`CtoPersonalityPreset`). See `ctoStateService.ts`. 4. Extra tooling: CTO sessions receive `ctoOperatorTools` (including the `saveMemory` / `searchMemory` / `readMemory` memory tools) and Linear - tools when connected. + tools when connected. `createCtoRuntimeToolMap` registers them on the + live session through each provider's tool transport — an `ade-cto` SDK + MCP server (Claude), the `ade_cto` dynamic-tool namespace (Codex), or a + dedicated HTTP MCP lease (Cursor / Droid / OpenCode). See + [tool-system](tool-system.md#registration-on-a-live-session). 5. Guarded permission defaults: Claude defaults to `"default"` (ask before dangerous ops); OpenCode defaults to `"edit"`. `full-auto` is only applied when explicitly requested. -6. Work the CTO launches never lands on the CTO's own lane. +6. Work the CTO launches never lands on the primary lane. `resolveCtoExecutionLane` honors an explicit `laneId` and otherwise creates a dedicated lane; it has no fallback to the CTO session's lane, because that lane is the project's primary lane. The capability manifest carries the matching rule so the model asks for the right - thing in the first place. See + thing in the first place. Mutating git tools follow the same rule + through `requireMutationLaneId`, which refuses to default at all. See [CTO](../cto/README.md#where-cto-launched-work-runs). 7. Creating the CTO thread seeds one real, visible opening user turn (`seedCtoIntroTurn`) so a first-run thread is not blank. It fires only diff --git a/docs/features/chat/tool-system.md b/docs/features/chat/tool-system.md index ee56a612a..b92bb8c85 100644 --- a/docs/features/chat/tool-system.md +++ b/docs/features/chat/tool-system.md @@ -12,7 +12,7 @@ worker to respawn. | `apps/desktop/src/main/services/ai/tools/executableTool.ts` | Thin wrapper around Zod + a handler function. Produces the common tool interface the Claude/Codex/OpenCode adapters consume. | | `apps/desktop/src/main/services/ai/tools/universalTools.ts` | Read, write, bash, todo, web fetch/search, ask-user. Available to every agent. | | `apps/desktop/src/main/services/ai/tools/workflowTools.ts` | `createLane`, `createPrFromLane`, `captureScreenshot`, `reportCompletion`, and the four PR issue-resolution tools. | -| `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO-only: `spawnChat`, lanes/PRs/git/tests, Linear reads and lightweight updates, and the `saveMemory` / `searchMemory` / `readMemory` memory tools. | +| `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO-only: `spawnChat`, lanes/PRs/git/tests, Linear reads and lightweight updates, and the `saveMemory` / `searchMemory` / `readMemory` memory tools. Git reads default their lane (`resolveReadLaneId`); git mutations require an explicit one (`requireMutationLaneId`). | | `apps/desktop/src/main/services/ai/tools/linearTools.ts` | Linear-only tools for CTO when Linear is connected. | | `apps/desktop/src/main/services/ai/tools/systemPrompt.ts` | `buildCodingAgentSystemPrompt` -- renders the top-of-context system prompt; adapts wording based on which tool names are present. | | `apps/desktop/src/main/services/ai/toolExposurePolicy.ts` | Filters tools by context (e.g., frontend-repo discovery tools). | @@ -123,7 +123,7 @@ uses to act on ADE itself: | Tool family | Purpose | |---|---| -| `spawnChat` | Spawn a new chat session with an explicit model, reasoning effort, and initial prompt. Lane resolution goes through `resolveExecutionLane`: an explicit `laneId` wins, and omitting it creates a **fresh** lane (`freshLaneName` / `freshLaneDescription`) rather than reusing the caller's. For the CTO that is load-bearing — its own lane is the project's primary lane, so a fallback would run spawned agents against the primary worktree. | +| `spawnChat` | Spawn a new chat session with an explicit model, reasoning effort, and initial prompt. Lane resolution goes through `resolveExecutionLane`: an explicit `laneId` wins, and omitting it creates a **fresh** lane (`freshLaneName` / `freshLaneDescription`) rather than reusing the caller's. For the CTO that is load-bearing — its session is pinned to the project's primary lane, so a fallback would run spawned agents against the primary worktree. | | `interruptChat`, `handoffChat` | Mid-session control over other chat sessions. | | `createTerminal`, `runCommand` | Create untracked shells or run fire-and-forget commands. | | `listLanes`, `createLane`, `renameLane`, `archiveLane`, `inspectLane` | Lane management. | @@ -131,11 +131,51 @@ uses to act on ADE itself: | Linear tools (when connected) | Read and lightly update issues: list/inspect, comment, state, assignee, label. | | `listLinearIssues`, `getLinearIssue` | Issue reads. | | `listTestSuites`, `runTestSuite`, `stopTestSuite`, `listTestRuns` | Test orchestration. | +| `gitStatus`, `gitFetch`, `gitListRecentCommits`, `gitListBranches`, `gitStashList`, `gitGetConflictState` | Git reads. `laneId` is optional and defaults to the CTO session's lane. | +| `gitCommit`, `gitPush`, `gitPull`, `gitUndoLastHeadChange`, `gitRedoLastHeadChange`, `gitCheckoutBranch`, `gitStashPush`, `gitStashPop`, `gitRebaseContinue`, `gitRebaseAbort`, `gitMergeAbort` | Git mutations. `laneId` is **required** — see [Lane defaulting is read-only](#lane-defaulting-is-read-only). | The system prompt's capability manifest is driven by which tool names are actually present; `systemPrompt.ts` inspects `toolNames` and renders only the sections the agent can act on. +### Registration on a live session + +The CTO tool set is a second consumer of the same per-provider tool +transports the orchestration tool set uses, gated on `identityKey === "cto"` +via `createCtoRuntimeToolMap(managed)` in `agentChatService.ts`: + +- **Claude** — `buildClaudeCtoMcpServer` produces an SDK MCP server named + `ade-cto` merged into `opts.mcpServers`. It is deliberately injected + *without* the orchestration lead's `allowManagedMcpServersOnly` lockdown, + because the CTO is a daily-driver chat that must keep the user's own MCP + servers. +- **Codex** — `refreshCodexDynamicTools` registers them as dynamic tools + under the `ade_cto` namespace next to orchestration's `ade_orchestration`. + Both sets must register in that one function: it clears the runtime's + dynamic-tool map before rebuilding, so a second refresher would clobber the + first. Dispatch falls back by bare name across both namespaces when a call + arrives un-namespaced. +- **Cursor, Droid, OpenCode** — a second HTTP MCP lease + (`ensureCtoHttpMcpServer`, cached on `managed.ctoHttpMcpServer`) advertised + under the `ade-cto` server name. It is a separate lease from + `orchestrationHttpMcpServer` because each lease carries exactly one tool + set; `closeOrchestrationHttpMcpServer` closes both. + +`buildCtoOperatorToolDeps` builds the dependency set for both the runtime map +and `previewSessionToolNames` (which enumerates the same names for the prompt), +so the advertised surface and the callable surface cannot drift. + +### Lane defaulting is read-only + +The CTO session is pinned to the project's **primary lane**, so `defaultLaneId` +means "the primary worktree". `ctoOperatorTools.ts` therefore splits lane +resolution in two: `resolveReadLaneId` keeps the default (inspecting primary is +normal supervision), while `requireMutationLaneId` has no default and throws +when `laneId` is missing. The mutating tools' zod schemas mark `laneId` required +so the model sees the requirement up front, and `gitGuard` / `conflictGuard` +convert the throw into a recoverable `{ success: false, error }` that names +`listLanes` rather than failing the turn. + ## Standalone-chat restrictions Chat sessions connected to the ADE CLI with a `chatSessionId` but diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index dcb34ff4c..c7d0516cd 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -10,7 +10,7 @@ The whole surface is built around one contract: the CTO is a daily chat you can - `ctoStateService.ts` — identity (name, personality, work style, model preferences), session logs, onboarding state, and the system-prompt preview. Owns the immutable doctrine, personality overlays, continuity model, memory-system guidance, environment knowledge, and capability manifest constants. `buildReconstructionContext()` assembles the memory-enriched context injected on session start, compaction, and model switch; `previewSystemPrompt()` returns the same layered prompt the settings UI renders verbatim. - `ctoMemoryService.ts` — the smart-memory file store under `.ade/cto/`. Reads/writes `MEMORY.md` and `thread-state.md` (atomic writes), appends per-turn lines to `daily/.md`, exposes `searchMemory(query)` (bounded, file-based, most-recent-first), `getSnapshot()`, and `buildMemoryContextSections()` (the capped copies used for injection). No new database or vector dependency. -- `ctoPromptContent.ts` — `buildCtoCapabilityManifest()`, the operator-tool manifest injected into the prompt. It is generated directly from `createCtoOperatorTools()` so registered tools and prompt documentation stay aligned; its operating rules are what keep CTO-launched work off the CTO's own lane. Also owns `CTO_INTRO_PROMPT` and `CTO_INTRO_ONBOARDING_STEP` — the opening turn and the once-only marker described in [The opening turn](#the-opening-turn). +- `ctoPromptContent.ts` — `buildCtoCapabilityManifest()`, the operator-tool manifest injected into the prompt. It is generated directly from `createCtoOperatorTools()` so registered tools and prompt documentation stay aligned; its operating rules are what keep CTO-launched work off the primary lane. Also owns `CTO_INTRO_PROMPT` and `CTO_INTRO_ONBOARDING_STEP` — the opening turn and the once-only marker described in [The opening turn](#the-opening-turn). - `linearClient.ts` — Linear GraphQL client (shared by desktop and the headless ADE CLI). Reads: `fetchIssueById`, `listProjects`, `searchIssues`, `getQuickView`, `fetchIssueComments`, `listLabels`, `listUsers`. Writes: `updateIssueState`, `updateIssueAssignee`, `createComment`, `addIssueLabel` / `removeIssueLabel`. - `linearIssueTracker.ts` / `issueTracker.ts` — issue cache, change detection, and the `getQuickView` / `searchIssues` / `fetchIssueComments` read shims plus the `updateIssueState` / `updateIssueAssignee` / `createComment` / `addLabel` write surface renderer surfaces call through. - `linearGraphQLInput.ts` — GraphQL input builders shared by the client and tracker. @@ -36,9 +36,9 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. - `apps/desktop/src/shared/ctoPersonalityPresets.ts` — `CTO_PERSONALITY_PRESETS` (`strategic`, `professional`, `hands_on`, `casual`, `minimal`, `custom`) with label, description, and `systemOverlay`. - `apps/desktop/src/shared/types/chat.ts` — `AgentChatIdentityKey`, now just the literal `"cto"`. The old `agent:` worker identity keys are gone. -- `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` — the operator tool surface registered for the CTO session, including the memory tools `saveMemory`, `searchMemory`, and `readMemory` and the session-lifecycle tools described in [Session lifecycle tools](#session-lifecycle-tools). -- `apps/desktop/src/main/services/chat/agentChatService.ts` — owns the CTO session lifecycle: single-session reuse/rebind (`listIdentitySessions` / `ensureIdentitySession`), the memory flush hooks, the reconstruction-context injection, `seedCtoIntroTurn` (the opening turn), `resolveCtoExecutionLane` (where CTO-launched work runs), and the canonical `getCtoAttention` probe (all detailed below). -- `apps/desktop/src/shared/types/cto.ts` — `CtoAttentionState` (`{ awaitingInput, since }`), the shape both attention transports return. +- `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` — the operator tool surface. `createCtoOperatorTools()` is the single factory behind both the prompt manifest and the tools a running CTO session can actually call (see [Operator tools on a live session](#operator-tools-on-a-live-session)). It includes the memory tools `saveMemory`, `searchMemory`, and `readMemory`, the session-lifecycle tools described in [Session lifecycle tools](#session-lifecycle-tools), and the git tools whose mutating half refuses to default a lane (`resolveReadLaneId` vs `requireMutationLaneId`). +- `apps/desktop/src/main/services/chat/agentChatService.ts` — owns the CTO session lifecycle: single-session reuse/rebind (`listIdentitySessions` / `ensureIdentitySession`), the memory flush hooks, the reconstruction-context injection, `seedCtoIntroTurn` (the opening turn), `resolveCtoExecutionLane` (where CTO-launched work runs), `buildCtoOperatorToolDeps` / `createCtoRuntimeToolMap` plus the per-provider transports that register them, and the canonical `getCtoAttention` probe (all detailed below). +- `apps/desktop/src/shared/types/cto.ts` — `CtoAttentionState` (`{ awaitingInput, since }`), the shape every attention transport returns. ### Attention surfaces (renderer) @@ -54,8 +54,10 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. - `CtoSetup.swift` — the first-run card (name, personality preset, work-style rows) shown when onboarding is incomplete. - `CtoSettingsScreen.swift` — sections: Identity (including personality/work style via `CtoIdentityEditor`), Model (live model/reasoning/Fast selection), Integrations (read-only Linear connection status), Memory (durable facts + thread summary via `cto.getMemory`), and Advanced (re-run setup). - `CtoIdentityEditor.swift` / `CtoReloadHelpers.swift` — the identity edit sheet and reload plumbing. +- `apps/ios/ADE/Models/RemoteModels.swift` — `CtoAttention` (`awaitingInput` + optional `since`, plus an `idle` constant), the Codable mirror of `CtoAttentionState`. +- `apps/ios/ADE/Services/SyncService.swift` — `fetchCtoAttention()` (the `cto.getAttention` call), the `@Published ctoAttention`, and `refreshCtoAttentionIfNeeded()`. -The CTO tab icon is the SF Symbol `brain` (`apps/ios/ADE/App/ContentView.swift`), matching the desktop Phosphor Brain glyph. +The CTO tab icon is the SF Symbol `brain` (`apps/ios/ADE/App/ContentView.swift`), matching the desktop Phosphor Brain glyph; the same tab carries the attention badge described in [Hidden from rosters, but never silent](#hidden-from-rosters-but-never-silent). ## Domain model @@ -117,10 +119,11 @@ The CTO thread is pinned to the project's **primary lane** (it needs a lane for Hiding the row removes it from `terminalAttention`, which is what the Work dot and the dock badge summarize. A hidden thread that asks a question would otherwise surface nowhere, so attention gets its own path: -- `agentChatService.getCtoAttention()` is the single implementation. Both transports — `IPC.ctoGetAttention` (plain IPC) and the `cto_state.getAttention` action (daemon-routed) — delegate to it, so a remote runtime and a local one cannot derive "needs you" differently. It returns `CtoAttentionState`, just `{ awaitingInput, since }`; `since` is the tooltip timestamp and is `null` while idle. +- `agentChatService.getCtoAttention()` is the single implementation. All three transports — `IPC.ctoGetAttention` (plain IPC), the `cto_state.getAttention` action (daemon-routed), and the `cto.getAttention` sync command (mobile) — delegate to it, so a remote runtime, a local one, and a phone cannot derive "needs you" differently. It returns `CtoAttentionState`, just `{ awaitingInput, since }`; `since` is the tooltip timestamp and is `null` while idle. - It is **read-only**. It resolves the thread through the same `listIdentitySessions` helper `ensureIdentitySession` uses, but never calls `ensureIdentitySession` itself: rendering a badge must not materialize a primary lane and a chat session as a side effect. The predicate is `awaitingInput || pendingInputItemId || attentionRequestedAt` (the last being an explicit `ade chat ask` hand-raise) rather than `canonicalStatusBucket`, whose awaiting-input bucket folds in `idle` and `ready` and would light the dot whenever the CTO is merely sitting there. A probe failure logs and returns idle. - `useCtoAttention` (mounted once in `AppShell`) keeps `appStore.ctoAttention` fresh from chat events, focus, and a 15 s visible-tab interval; `TabNav` renders the dot on `/cto`. It filters chat events through `shouldRefreshSessionListForChatEvent` so a streaming turn does not re-run a full identity scan per delta, debounces to 1.5 s (0 on focus), and clears to idle on project switch so the previous project's state cannot linger. - `useAppWideSessionAttention` adds the CTO to the dock badge count so a question reaches a minimized window. It stays the single writer of `setDockBadgeCount`. +- **iOS** takes the same path. `SyncService.fetchCtoAttention()` calls `cto.getAttention` and publishes `ctoAttention`; `ContentView` badges the CTO tab (a string badge, so it renders as a dot-sized marker and disappears when idle) with a matching accessibility label. The refresh rides the same "something changed" pulse that rebuilds the session roster — the CTO is not *in* that roster, so it needs its own read — and is debounced to 5 s with `force` for an explicit refresh. It is gated on `supportsRemoteAction("cto.getAttention")`: an older brain never lights the dot, and a value left over from a newer host is cleared. A failed probe keeps the last known value rather than clearing, because falsely dropping a pending question is worse than a slightly stale dot. ### The opening turn @@ -128,12 +131,54 @@ A brand-new CTO thread used to open on a blank screen. `ensureIdentitySession` n It is deliberately not a hidden or canned message: ADE has no hidden-turn mechanism, and a fabricated assistant message would feed back into the model's context on every later turn. The `intro` marker lives in `onboardingState.completedSteps` — not a user-facing setup step, but kept in that list so it is persisted and so `ctoResetOnboarding` clears it with the rest — so it survives restarts and fires once; it is written only *after* the send succeeds, so an unauthenticated first run retries instead of burning the one shot. The send is fire-and-forget with `awaitDispatch` so a dispatch failure is logged rather than escaping as an unhandled rejection. +### Operator tools on a live session + +The CTO's operator tools are registered on the running session, not merely +advertised in its prompt. `createCtoRuntimeToolMap(managed)` in +`agentChatService.ts` builds the executable map and returns `null` for anything +whose `identityKey` is not `"cto"`, so no other chat can reach these tools. + +`buildCtoOperatorToolDeps` builds the dependency set for both the runtime map +and `previewSessionToolNames`, which enumerates the same tool names for the +prompt. Sharing the deps is the point: the surface the CTO is told it has and +the surface it can actually call come from one definition and cannot drift +apart. (`buildCtoCapabilityManifest` renders its inventory from the same +`createCtoOperatorTools()` factory, with stub deps, for the same reason.) + +Registration then goes through whichever transport the session's provider +speaks: + +| Provider | Transport | +| --- | --- | +| Claude | `buildClaudeCtoMcpServer` returns an SDK MCP server named `ade-cto`, merged into `opts.mcpServers`. Unlike the orchestration lead's server it is injected **without** `allowManagedMcpServersOnly` — the CTO is a daily-driver chat and must keep the user's own MCP servers. | +| Codex | `refreshCodexDynamicTools` registers them as dynamic tools under the `ade_cto` namespace, alongside the orchestration set under `ade_orchestration`. Dispatch falls back by bare name across both namespaces when a call arrives un-namespaced. | +| Cursor / Droid / OpenCode | An HTTP MCP lease cached on `managed.ctoHttpMcpServer`, provisioned by `ensureCtoHttpMcpServer` and advertised to the runtime under the `ade-cto` server name. | + +Two invariants keep this from breaking quietly: + +- **One refresher per Codex runtime.** `refreshCodexDynamicTools` clears the + dynamic-tool map before rebuilding it, so both tool sets must register inside + that one function. A second refresher would clobber the first. +- **One tool set per HTTP MCP lease.** That is why `ctoHttpMcpServer` is a + separate field from `orchestrationHttpMcpServer` rather than a shared server + carrying both. `closeOrchestrationHttpMcpServer` closes both leases, so every + teardown path drops the CTO one too. + ### Where CTO-launched work runs -The CTO must not launch implementation work on its own lane — that lane is the primary lane, so agents would write straight to the primary worktree. Two things enforce this: +The CTO session is pinned to the project's **primary lane**, so a tool that +silently defaults its lane would act on the primary worktree. Nothing the CTO +does may land there by omission. + +For spawned work: + +- **The prompt.** `buildCtoCapabilityManifest`'s operating rules tell the CTO to leave `laneId` off for new work and reserve the lane its session is pinned to for read-only inspection. `ctoState.test.ts` pins the wording. +- **The code.** `resolveCtoExecutionLane` creates a dedicated lane when no `laneId` is requested, honoring the `freshLaneName` / `freshLaneDescription` contract that `CtoOperatorToolDeps` always declared. It never falls back to the CTO session's lane; if lane creation fails the error surfaces (`spawnChat` reports it) rather than quietly re-targeting primary. + +For git tools the rule is split by whether the call mutates: -- **The prompt.** `buildCtoCapabilityManifest`'s operating rules tell the CTO to leave `laneId` off for new work and reserve its own lane for read-only inspection. This is the live lever — the operator tool bodies are not registered on a running session, so the prompt is what actually steers where work lands — and `ctoState.test.ts` pins it. -- **The code.** `resolveCtoExecutionLane` creates a dedicated lane when no `laneId` is requested, honoring the `freshLaneName` / `freshLaneDescription` contract that `CtoOperatorToolDeps` always declared. It never falls back to the CTO's lane; if lane creation fails the error surfaces (`spawnChat` reports it) rather than quietly re-targeting primary. +- **Reads default.** `resolveReadLaneId` falls back to `deps.defaultLaneId` — inspecting the primary lane is normal supervision. `gitStatus`, `gitFetch`, `gitListRecentCommits`, `gitListBranches`, `gitStashList`, `gitGetConflictState`, and `getConflictStatus` take this path. +- **Mutations require an explicit lane.** `requireMutationLaneId` has no default and throws when `laneId` is missing; the zod schemas mark it required (`z.string().min(1)`) so the model sees the requirement before it calls. It covers `gitCommit`, `gitPush`, `gitPull`, `gitUndoLastHeadChange`, `gitRedoLastHeadChange`, `gitCheckoutBranch`, `gitStashPush`, `gitStashPop`, `gitRebaseContinue`, `gitRebaseAbort`, and `gitMergeAbort`. `gitGuard` / `conflictGuard` turn the throw into a `{ success: false, error }` naming `listLanes`, so the CTO recovers by retrying with a lane instead of failing the turn. ### Session lifecycle tools @@ -190,6 +235,7 @@ Registered by `registerCtoRemoteCommands` in `apps/ade-cli/src/services/sync/syn - `cto.ensureSession`, `cto.getState`, `cto.updateIdentity`. - `cto.getMemory` — returns the `CtoMemorySnapshot` (durable memory + thread state + today's daily log) the iOS Memory card decodes. +- `cto.getAttention` — the mobile transport for the attention probe. `viewerAllowed`, strictly read-only (it delegates to `agentChatService.getCtoAttention()`, which never calls `ensureIdentitySession`, so a phone drawing a badge cannot materialize a primary lane and a chat session as a side effect), and advertised as an **optional** mobile capability so an older brain omitting it never flips a phone into `limited` mode. - `cto.getLinearConnectionStatus`, `cto.getLinearQuickView`, `cto.getLinearIssuePickerData`, `cto.searchLinearIssues`, `cto.getLinearIssueComments` — the Linear read surface. - `cto.startLinearMobileOAuth`, `cto.completeLinearMobileOAuth`, `cto.setLinearToken`, `cto.clearLinearToken` — the Linear **connection-management** surface the iOS Linear pane uses to connect (worker-bounce OAuth or API key), reconnect, and disconnect. All four are `viewerAllowed` and advertised as **optional** mobile capabilities (`MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS` in `syncMobileCompatibility.ts`), so older brains omit them and the phone gates the affordances locally. See [Linear integration](../linear-integration/README.md#connecting-and-managing-from-mobile). @@ -206,6 +252,8 @@ First run is one card. The user picks a personality preset, optionally adjusts t - **Injected memory is authoritative.** The prompt tells the CTO never to claim memory it does not have injected — changes to injection caps or ordering in `ctoMemoryService`/`ctoStateService` directly change what the CTO "knows." - **Capability knowledge has two live sources.** `ctoPromptContent.buildCtoCapabilityManifest()` is generated from `createCtoOperatorTools()`. For service actions outside that curated tool set, the CTO prompt directs the model to the installed runtime's `ade actions list --text` catalog and bundled `ade-*` skills instead of a stale hard-coded inventory. - **One CTO session.** Do not create a second CTO session on a foreign lane; `ensureIdentitySession` rebinds the existing one. Session-creation paths that bypass it would fork the thread. +- **Never add a defaulting lane to a mutating tool.** The CTO session's lane *is* the primary lane. A convenience default on a new write tool means "act on the primary worktree" — follow `requireMutationLaneId`, not `resolveReadLaneId`. +- **Codex tool sets share one refresher.** Adding a third dynamic tool set means extending `refreshCodexDynamicTools`, not writing a second refresher: it clears the runtime's dynamic-tool map first, so a parallel refresher silently deletes the other set's tools. ## Cross-links diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 8f8d26d25..97db52833 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -205,7 +205,12 @@ must not put a host in `limited`. The four Linear connection commands `cto.setLinearToken`, `cto.clearLinearToken`) that let the phone connect, reconnect, and disconnect Linear are optional: a brain that predates them simply doesn't advertise them, and the iOS Linear pane hides those affordances locally -instead of erroring. The session-lifecycle commands +instead of erroring. `cto.getAttention` — the read-only probe behind the phone's +CTO tab badge, needed because the CTO chat is excluded from every session roster +and cannot be derived from the chat list — is optional on the same logic: the +phone feature-detects it and otherwise leaves the badge dark, and requiring it +would flip every already-shipped brain into `limited` mode. The +session-lifecycle commands (`session.settleSessions`, `session.unsettleSessions`, `session.setSettleOverride`, `session.snoozeSession`, `session.wakeSession`, `session.clearWokeMarker`) are optional for the same reason: the phone diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 0e32f461c..a0f6eb405 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1388,13 +1388,16 @@ approval intents. ## Tab structure -The root shell is a `TabView` whose system tab bar is suppressed -(`toolbar(.hidden, for: .tabBar)`) in favour of a hand-rolled -`ADERootBottomTabBar` injected as a bottom safe-area inset. The custom -bar exposes the five shipped tabs (Work / Lanes / PRs / Files / CTO), -renders a per-tab selection highlight, and shows a red `Capsule` badge on -the Work tab driven by `SyncService.runningChatSessionCount` -(`min(count, 99)`). Detail screens that should claim the full height — +The root shell is a `TabView` (`ContentView.rootTabs`) exposing the five +shipped tabs (Work / Lanes / PRs / Files / CTO) as `.tabItem` labels. Two +tabs carry badges: Work counts running chats +(`SyncService.runningChatSessionCount`), and CTO shows a marker when +`SyncService.ctoAttention.awaitingInput` is true — a *string* badge, so it +renders dot-sized and hides itself when idle, with a matching accessibility +label. The CTO needs its own badge source because its chat is excluded from +every session roster and so can never contribute to the Work count; see +[CTO › Hidden from rosters, but never silent](../cto/README.md#hidden-from-rosters-but-never-silent). +Detail screens that should claim the full height — new-chat / model-setup / advanced flows — opt out by emitting an `ADERootTabBarHiddenPreferenceKey` value via the `.adeRootTabBarHidden()` modifier. `ADEUIKitAppearance.configureTabBar()` (called from @@ -1707,7 +1710,7 @@ any non-primary-key unique index. | **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/read. Search is a single full-screen page (`FilesSearchScreen`) opened from the magnifying-glass button in the Files top bar (desktop `SearchOverlay` parity): one query searches file *names* (quick open) and file *contents* (text search) together — name matches surface first under "Files", content hits are grouped per file with collapsible line previews, and tapping a line opens the file at that line. The inline `FilesQueryCard` quick-open / text-search cards (and their 40-row caps) were removed. Files are freely editable — the mobile read-only file-mutation gate (`mobileReadOnly` / edit-protection) was removed on both the host and the phone, matching the desktop change. | | **Work** | `terminal` | `/work` | Terminal + chat session list (standalone CLI sessions stay listed after they end, matching desktop — `workSessionShouldAppearInWorkList` in `WorkBrowserHelpers.swift` hides orphaned chat-owned child shells that are no longer live), cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. The composer's last-used selection (model + access mode + reasoning effort + fast mode) persists across surfaces through `WorkComposerPreferences` (App Group `UserDefaults`, versioned key): the New Chat screen seeds its initial state from the saved selection instead of hardcoded defaults, and every change or send — from the New Chat composer, the in-session inline picker (`WorkSessionDestinationView`), or the session settings sheet — writes it back. Because the inline picker is cross-provider, the persisted provider is re-derived from the picked model, and a provider change resets the coupled access mode / sub-settings to that provider's defaults. Droid (Factory) is in the new-chat provider allowlist (`workNormalizedNewChatProvider`), so Droid Core models (GLM / Kimi / MiniMax) keep the `droid` provider instead of silently collapsing to the Claude runtime. The new-chat send button is the shared `ADEComposerSendButton` (an arrow-in-circle disc matching the in-session composer), replacing the earlier paperplane capsule. Each session row carries a minimal per-lane PR status indicator (`WorkLanePrIndicator`: a state-colored dot + `#num` + Open/Draft/Closed/Merged) beside the lane name. It and the Lanes tab chip both render the unified `LanePrTag` (`LaneHelpers.swift`, `selectLaneTabPrTag`, desktop parity), which merges ADE-mapped PRs (the synced `pull_requests` table) with GitHub PRs opened outside ADE — matched to a lane by branch and fetched into the shared `SyncService.laneGithubPrItems` cache (`refreshLaneGithubPrItems`, best-effort, throttled, reset on project switch / reconnect). When a row resolves a `LanePrTag` (mapped or GitHub-by-branch), its long-press context menu (`WorkSessionListRow`) also offers **"Open in PRs tab"**; `WorkRootScreen+Actions.openPullRequest` waits out the menu-dismiss animation, then publishes `syncService.requestedPrNavigation` (a `PrNavigationRequest` carrying the PR id + number + lane id, or just the GitHub PR number for an unmapped tag), and `ContentView`'s `onChange(of: requestedPrNavigation?.id)` flips the app to the PRs tab and opens that PR — the same cross-tab handoff the deep-link router and the in-chat PR menu use. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. Only real user drags can un-pin the viewport: layout-driven geometry changes (keyboard show/hide, key bar, pinch font changes) re-assert the live tail after the pass settles, so a pinned terminal with large scrollback keeps the prompt visible above the keyboard instead of stranding it (SwiftTerm only re-snaps when cols/rows change, and a mouse-mode TUI repainting in place emits no scroll events to self-heal). When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). The chat composer input is a `UITextView`-backed field (`WorkComposerTextView` in `WorkComposerTypedTriggers.swift`) rather than a plain SwiftUI `TextField`, because it needs the cursor position and inline styled runs. `WorkComposerTriggerDetector` runs the same cursor-relative regexes as the shared desktop/TUI `composerTriggers.ts` (slash `(?:^|\s)/([^\s/]*)$`, at `(?:^|\s)@([^\s@]*)$`), so a `/command` or `@file` trigger is detected anywhere in the draft, not just at position 0. `WorkComposerSuggestionController` drives an inline suggestion strip (`WorkComposerSuggestionStrip`) above the input — a curated per-provider slash catalog (`WorkComposerSlashCatalog`) resolved locally, and `@file` quick-open resolved over sync via `SyncService.quickOpen` against the lane's files workspace (40 ms debounce, workspace id cached per lane, invalidated on lane change). Its visibility derives purely from the active trigger match, never from `@FocusState`. Committing a suggestion splices exactly the trigger span on the live text view, and confirmed `/command` / `@path` tokens render as tinted chip pills drawn by a custom TextKit 1 `WorkComposerChipLayoutManager` (provider-accent tint, monospace for slash, semibold for at) while `draftState.text` stays the plain-text source of truth that is sent. `WorkSmartLinkDetector` styles GitHub, Linear, ADE, and generic web URLs with the same chip layout manager in both new-chat and in-session composers; Backspace/Delete removes an intersected URL atomically, and long press offers Copy link and Remove link. The raw URL remains the SwiftUI draft and sent prompt. This replaced the modal `WorkMentionsPickerSheet` and `WorkSlashCommandsSheet` (both deleted). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: GitHub stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, Integration/Rebase workflow cards rendered from `PrWorkflowCard`, and per-PR action capabilities. The PR detail screen (`PrDetailView`) is a single-column adaptation of the desktop Timeline+Rails layout — its Overview is emitted as sibling `List` rows so the list virtualizes offscreen content, and it stays live off a warm-cache freshness gate (see [PR detail screen](#pr-detail-screen)). | -| **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`) with a compact one-line voice/send composer. The top-bar gear opens settings for identity/personality, live model/reasoning/Fast selection, read-only Linear status, memory via `cto.getMemory`, and re-run setup. | +| **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`) with a compact one-line voice/send composer. The top-bar gear opens settings for identity/personality, live model/reasoning/Fast selection, read-only Linear status, memory via `cto.getMemory`, and re-run setup. The tab badges when the thread is blocked on the user: `SyncService.refreshCtoAttentionIfNeeded()` rides the same change pulse that rebuilds the session roster, calls the optional `cto.getAttention` command (5 s debounce, gated on `supportsRemoteAction`), and publishes `ctoAttention`. A failed probe keeps the last known value; an older brain that does not advertise the action clears it. | | **Settings** | `gearshape` | `/settings` (sync subset) | Connections — account sign-in (primary, PIN-less directory + Relay adoption), account-wide machine rename/clear, scan the QR (`SettingsPairingScannerSheet`) + PIN, or Nearby + PIN — plus advanced SSH bootstrap, appearance, diagnostics, reconnect, forget, and a **Push delivery** panel (`SettingsPushDeliverySection`: registration/permission state, APNs environment, relay reachability from `push.getStatus`, and notification / Live-Activity / quiet-hours toggles). `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` / `SettingsPushDeliverySnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`, `SettingsPushDeliverySection`) instead of having them reach into `SyncService` directly. The About row formats the marketing and build versions together as `v ()`. | `WorkModelPickerSheet` shows the same Claude authentication affordance diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 44a2800ca..7f580a00d 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -141,6 +141,11 @@ the phone leaves Linear connect/reconnect/disconnect hidden (falling back to the API-key path or an "update ADE on your Mac" hint) rather than treating their absence as a broken connection. Their omission never flips a host to `limited`. +`cto.getAttention` is optional for the same reason. The phone checks +`supportsRemoteAction("cto.getAttention")` before probing, so against an older +brain it simply never lights the CTO badge; putting it in the required set would +flip every already-shipped brain into `limited` mode over a dot. + The session-lifecycle commands sit in the same optional list: `session.settleSessions`, `session.unsettleSessions`, `session.setSettleOverride`, `session.snoozeSession`, `session.wakeSession`, and @@ -489,6 +494,17 @@ a boolean. chat session and read/patch its identity. - `getMemory` — return the CTO's memory snapshot (durable `MEMORY.md`, rolling thread state, and today's daily log) for the phone's Memory card. +- `getAttention` — whether the CTO thread is blocked on the user, for the + badge on the phone's CTO tab. It delegates to the canonical + `agentChatService.getCtoAttention()` (the same implementation behind + `IPC.ctoGetAttention` and the `cto_state.getAttention` action), so no + surface derives "needs you" differently. `viewerAllowed` and strictly + read-only: `getCtoAttention` never calls `ensureIdentitySession`, so + drawing a badge cannot materialize a primary lane and a CTO chat session + as a side effect. The phone needs its own command here because the CTO + chat is excluded from every session roster and cannot be derived from the + chat list. It is an **optional** mobile capability (see the compatibility + note above). - `getLinearConnectionStatus`, `getLinearQuickView`, `getLinearIssuePickerData`, `searchLinearIssues`, `getLinearIssueComments` — the Linear read surface. The former worker-management commands From 33a264833b35c0606002dae6b0b632ebc05589f6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:07:53 -0400 Subject: [PATCH 2/2] fix(cto): wire the last dead tools, fix the iOS probe, collapse the lease layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from /quality and /test on this lane. The iOS attention probe could not fire for the case it exists for. refreshCtoAttentionIfNeeded() sat below a guard keyed on the roster signature, and the CTO is excluded from that roster by design — so a turn where only the CTO changed left the signature identical and the probe never ran. Once lit by an unrelated change, nothing cleared it either. Hoisted above the guard, and the previously-dead `force` parameter now drives the first probe after a (re)connect, when the host's command descriptors finally arrive. Four operator tools were still dead. steerChat, cancelSteer, listSubagents and approveToolUse were wired to `undefined`, so they were advertised in the prompt manifest and registered on every transport but could only answer "not available" — the exact defect this lane set out to remove, fixed for ~75 tools and left for these. They are now required deps, so a future caller cannot ship them unwired. handoffChat is deleted rather than wired: it targeted "a different agent identity", a subsystem that no longer exists. The headless agentChatService stub was missing three of them, which would have thrown a raw TypeError through `ade actions run chat.*` and the matching sync commands. Same bug class this lane already hit once with getCtoAttention. Structural: the HttpMcpToolSet union spent itself on four string-switch helpers and a duplicated session field. Replaced with a descriptor table and a single keyed lease record, which also collapsed the three transport call sites and the codex namespace fallback. closeOrchestrationHttpMcpServer closed both leases despite its name; it is closeHttpMcpServers now. Also: five mutating git tools still advertised laneId as optional while throwing at runtime, and two error messages named tools that do not exist; createTerminal relied on an invisible pty clamp instead of passing dimensions. Co-Authored-By: Claude Opus 5 --- apps/ade-cli/src/adeRpcServer.ts | 6 +- .../src/headlessLinearServices.test.ts | 41 +++ apps/ade-cli/src/headlessLinearServices.ts | 49 ++++ .../ai/tools/ctoOperatorTools.test.ts | 89 +++++- .../services/ai/tools/ctoOperatorTools.ts | 68 ++--- .../main/services/chat/agentChatService.ts | 272 +++++++++--------- apps/ios/ADE/App/ContentView.swift | 10 +- apps/ios/ADE/Services/SyncService.swift | 21 +- apps/ios/ADETests/ADETests.swift | 28 ++ docs/features/agents/tool-registration.md | 9 +- docs/features/chat/tool-system.md | 58 ++-- docs/features/cto/README.md | 33 ++- .../sync-and-multi-device/ios-companion.md | 2 +- 13 files changed, 452 insertions(+), 234 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index a330005bf..50e0f4827 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2934,11 +2934,15 @@ async function runCtoOperatorBridgeTool( getChatTranscript: agentChatService.getChatTranscript, createChat: agentChatService.createSession, updateChatSession: agentChatService.updateSession, - previewSessionToolNames: agentChatService.previewSessionToolNames, sendChatMessage: agentChatService.sendMessage, interruptChat: async (args) => { await agentChatService.interrupt(args); }, + steerChat: ({ sessionId, instruction }) => agentChatService.steer({ sessionId, text: instruction }), + cancelSteer: ({ sessionId, steerId }) => agentChatService.cancelSteer({ sessionId, steerId }), + listSubagents: ({ sessionId }) => agentChatService.listSubagents({ sessionId }), + approveToolUse: ({ sessionId, toolUseId, decision }) => + agentChatService.approveToolUse({ sessionId, itemId: toolUseId, decision }), ensureCtoSession: async ({ laneId, modelId, reasoningEffort, reuseExisting }) => agentChatService.ensureIdentitySession({ identityKey: "cto", diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index a58bb884c..417b4a1fa 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -466,6 +466,47 @@ describe("headlessLinearServices", () => { services.dispose(); }); + it("answers the CTO operator tool chat surface instead of throwing TypeError", async () => { + // The CTO operator tools and the chat.* sync commands call these on + // `runtime.agentChatService` unconditionally — the call sites only guard a + // null service, not a missing method. A stub missing any of them turns + // `ade actions run` / a phone tap into "x is not a function". + const services = createHeadlessLinearServices(createDeps()); + const session = await services.agentChatService.createSession({ laneId: "lane-1" }); + + expect(await services.agentChatService.getCtoAttention()).toEqual({ + awaitingInput: false, + since: null, + }); + expect(await services.agentChatService.listSubagents({ sessionId: session.id })).toEqual([]); + + // Headless steers are delivered immediately (queued: false), so there is + // never a queued steer to pull back: a plain cancel is a no-op, and a + // requireQueued cancel must fail the way the desktop service does. + await expect( + services.agentChatService.cancelSteer({ sessionId: session.id, steerId: "steer-missing" }), + ).resolves.toBeUndefined(); + await expect( + services.agentChatService.cancelSteer({ + sessionId: session.id, + steerId: "steer-missing", + requireQueued: true, + }), + ).rejects.toThrow(/no longer queued/); + + // Headless raises no approvals, so approving one must be an honest error + // rather than a silent success the caller would misread as "approved". + await expect( + services.agentChatService.approveToolUse({ + sessionId: session.id, + itemId: "tool-1", + decision: "accept", + }), + ).rejects.toThrow(/No pending approval/); + + services.dispose(); + }); + it("dispose removes session and transcript data", async () => { const services = createHeadlessLinearServices(createDeps()); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 27dd64d3e..765d93806 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -207,6 +207,25 @@ type HeadlessLinearServices = { reason?: "queue_full"; }>; interrupt: (args: { sessionId: string }) => Promise; + /** + * Mirrors the desktop chat service so the CTO operator tools + * (`cancelSteer`) and the `chat.cancelSteer` sync command resolve + * headlessly instead of throwing "not a function". + */ + cancelSteer: (args: { + sessionId: string; + steerId: string; + requireQueued?: boolean; + }) => Promise; + /** Headless runs spawn no sub-agents; kept so `chat.listSubagents` answers. */ + listSubagents: (args: { sessionId: string }) => Promise; + /** Headless runs never raise approval requests; kept so callers get a real error. */ + approveToolUse: (args: { + sessionId: string; + itemId: string; + decision: "accept" | "accept_for_session" | "decline" | "cancel"; + responseText?: string | null; + }) => Promise; resumeSession: (args: { sessionId: string; }) => Promise; @@ -2090,6 +2109,36 @@ function createHeadlessAgentChatService( async interrupt(args: { sessionId: string }) { touchSession(args.sessionId); }, + async cancelSteer(args: { + sessionId: string; + steerId: string; + requireQueued?: boolean; + }) { + // `steerMessage` appends immediately and reports `queued: false`, so a + // headless steer is never sitting in a queue to pull back. This mirrors + // the desktop service's "no live runtime" branch exactly: reject only + // when the caller demanded the steer still be queued. + if (args.requireQueued) throw new Error("This message is no longer queued."); + touchSession(args.sessionId); + }, + async listSubagents(_args: { sessionId: string }) { + // No turn loop, so no sub-agents are ever tracked. Empty is the truthful + // answer, not a placeholder. + return [] as never[]; + }, + async approveToolUse(args: { + sessionId: string; + itemId: string; + decision: "accept" | "accept_for_session" | "decline" | "cancel"; + responseText?: string | null; + }) { + // Headless sessions never emit approval requests, so any itemId a caller + // passes is unresolvable. Throw rather than silently succeed — a caller + // that believes it approved a tool use would be wrong. + throw new Error( + `No pending approval found for item '${args.itemId}' (headless chat runtime raises no approvals).`, + ); + }, async resumeSession(args: { sessionId: string }) { return ensureSession({ sessionId: args.sessionId, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index 785bd84f6..8e92fc402 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -62,15 +62,12 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo truncated: false, totalEntries: 1, }), + steerChat: vi.fn().mockResolvedValue({ steerId: "steer-1", queued: true }), + cancelSteer: vi.fn().mockResolvedValue(undefined), + listSubagents: vi.fn().mockResolvedValue([]), + approveToolUse: vi.fn().mockResolvedValue(undefined), createChat: vi.fn().mockResolvedValue(baseSession), updateChatSession: vi.fn().mockResolvedValue(baseSession), - previewSessionToolNames: vi.fn(() => [ - "prRefreshIssueInventory", - "prGetReviewComments", - "prRerunFailedChecks", - "prReplyToReviewThread", - "prResolveReviewThread", - ]), sendChatMessage: vi.fn().mockResolvedValue(undefined), interruptChat: vi.fn().mockResolvedValue(undefined), ensureCtoSession: vi.fn().mockResolvedValue({ ...baseSession, id: "cto-session" }), @@ -246,6 +243,84 @@ describe("createCtoOperatorTools", () => { }); }); + // These four were wired to `undefined`, so they were advertised in the prompt + // manifest and registered on every transport but could only ever answer + // "not available" — the same defect the live-registration work exists to fix. + describe("supervision tools reach their real implementations", () => { + it("steerChat forwards the instruction as the steer text", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.steerChat as any).execute({ sessionId: "chat-7", instruction: "focus on the failing shard" }); + + expect(deps.steerChat).toHaveBeenCalledWith({ sessionId: "chat-7", instruction: "focus on the failing shard" }); + expect(result).toMatchObject({ success: true }); + expect(result.error).toBeUndefined(); + }); + + it("cancelSteer requires the steerId the underlying API needs", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.cancelSteer as any).execute({ sessionId: "chat-7", steerId: "steer-1" }); + + expect(deps.cancelSteer).toHaveBeenCalledWith({ sessionId: "chat-7", steerId: "steer-1" }); + expect(result).toMatchObject({ success: true, sessionId: "chat-7" }); + }); + + it("approveToolUse maps toolUseId onto the service's itemId", async () => { + const deps = buildDeps(); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.approveToolUse as any).execute({ + sessionId: "chat-7", toolUseId: "item-3", decision: "accept", + }); + + expect(deps.approveToolUse).toHaveBeenCalledWith({ + sessionId: "chat-7", toolUseId: "item-3", decision: "accept", + }); + expect(result).toMatchObject({ success: true }); + }); + + it("no longer exposes handoffChat", () => { + // Handoff targeted "a different agent identity" — a subsystem that was + // removed; AgentChatIdentityKey is now just "cto". Advertising it was + // advertising a capability that could not exist. + const tools = createCtoOperatorTools(buildDeps()); + + expect(Object.keys(tools)).not.toContain("handoffChat"); + expect(Object.keys(tools)).toContain("spawnChat"); + }); + }); + + it("createTerminal passes explicit pty dimensions instead of relying on the clamp", async () => { + const ptyService = { create: vi.fn().mockResolvedValue({ sessionId: "pty-1" }) }; + const deps = buildDeps({ ptyService: ptyService as any }); + const tools = createCtoOperatorTools(deps); + + await (tools.createTerminal as any).execute({ laneId: "lane-2" }); + + // PtyCreateArgs requires these; omitting them was silently clamped inside + // the pty service, which was invisible until these bodies started executing. + expect(ptyService.create).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-2", cols: 100, rows: 30, title: "CTO terminal", + })); + }); + + it("rejects a missing lane at the schema layer, not just at execute()", () => { + const tools = createCtoOperatorTools(buildDeps()); + + // The model reads the schema as the contract. Enforcement that lives only + // in execute() lets it call, fail, and burn a turn. + for (const name of ["gitCommit", "gitPush", "gitPull", "gitUndoLastHeadChange", "gitMergeAbort"]) { + const parsed = (tools[name] as any).inputSchema.safeParse({ message: "x", branch: "y" }); + expect(parsed.success, `${name} must require laneId`).toBe(false); + } + + // Read-only inspection still accepts an omitted lane. + expect((tools.gitStatus as any).inputSchema.safeParse({}).success).toBe(true); + }); + // ── Chat tools ────────────────────────────────────────────────── describe("chat tools", () => { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 158981164..4688877fb 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -98,11 +98,10 @@ export interface CtoOperatorToolDeps { applyProposal: (args: any) => Promise; undoProposal: (args: any) => Promise; } | null; - steerChat?: (args: { sessionId: string; instruction: string }) => Promise<{ steerId: string; queued: boolean }>; - cancelSteer?: (args: { sessionId: string }) => Promise; - handoffChat?: (args: { sessionId: string; targetIdentityKey?: string; reason?: string }) => Promise; - listSubagents?: (args: { sessionId: string }) => Promise; - approveToolUse?: (args: { sessionId: string; toolUseId: string; decision: "accept" | "accept_for_session" | "decline" | "cancel" }) => Promise; + steerChat: (args: { sessionId: string; instruction: string }) => Promise<{ steerId: string; queued: boolean }>; + cancelSteer: (args: { sessionId: string; steerId: string }) => Promise; + listSubagents: (args: { sessionId: string }) => Promise; + approveToolUse: (args: { sessionId: string; toolUseId: string; decision: "accept" | "accept_for_session" | "decline" | "cancel" }) => Promise; computerUseArtifactBrokerService?: { listArtifacts: (args?: any) => any[]; updateArtifactReview: (args: any) => any; @@ -135,10 +134,6 @@ export interface CtoOperatorToolDeps { sessionId: string; title?: string | null; }) => Promise; - previewSessionToolNames: (args: { - laneId: string; - sessionProfile?: AgentChatCreateArgs["sessionProfile"]; - }) => string[]; sendChatMessage: (args: AgentChatSendArgs) => Promise; interruptChat: (args: AgentChatInterruptArgs) => Promise; ensureCtoSession: (args: { @@ -1227,9 +1222,14 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { if (!deps.ptyService) return { success: false, error: "Terminal service is not available." }; try { + // cols/rows/title are required by the real PtyCreateArgs. They used to + // be omitted and silently clamped inside the pty service — invisible + // while these tool bodies were unreachable, live now that they execute. const result = await deps.ptyService.create({ laneId, - ...(title?.trim() ? { title: title.trim() } : {}), + title: title?.trim() || "CTO terminal", + cols: 100, + rows: 30, ...(startupCommand?.trim() ? { startupCommand: startupCommand.trim() } : {}), toolType: "shell", tracked: true, @@ -1445,14 +1445,14 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.undoLastHeadChange({ laneId: requireMutationLaneId(laneId, "gitUndo") })), + inputSchema: z.object({ laneId: z.string().min(1).describe("Lane to undo in. Required — there is no default.") }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.undoLastHeadChange({ laneId: requireMutationLaneId(laneId, "gitUndoLastHeadChange") })), }); tools.gitRedoLastHeadChange = tool({ description: "Redo the latest successful ADE git undo for a named lane. This resets the lane with git reset --hard, so laneId is required — there is no default.", - inputSchema: z.object({ laneId: z.string().optional() }), - execute: ({ laneId }) => gitGuard(() => deps.gitService!.redoLastHeadChange({ laneId: requireMutationLaneId(laneId, "gitRedo") })), + inputSchema: z.object({ laneId: z.string().min(1).describe("Lane to redo in. Required — there is no default.") }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.redoLastHeadChange({ laneId: requireMutationLaneId(laneId, "gitRedoLastHeadChange") })), }); tools.gitFetch = tool({ @@ -1544,20 +1544,20 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.rebaseContinue({ laneId: requireMutationLaneId(laneId, "gitRebaseContinue") })), }); tools.gitRebaseAbort = tool({ - description: "Abort an in-progress rebase.", - inputSchema: z.object({ laneId: z.string().optional() }), + description: "Abort an in-progress rebase in a named lane. laneId is required — there is no default.", + inputSchema: z.object({ laneId: z.string().min(1).describe("Lane to abort the rebase in. Required — there is no default.") }), execute: ({ laneId }) => gitGuard(() => deps.gitService!.rebaseAbort({ laneId: requireMutationLaneId(laneId, "gitRebaseAbort") })), }); tools.gitMergeAbort = tool({ - description: "Abort an in-progress merge.", - inputSchema: z.object({ laneId: z.string().optional() }), + description: "Abort an in-progress merge in a named lane. laneId is required — there is no default.", + inputSchema: z.object({ laneId: z.string().min(1).describe("Lane to abort the merge in. Required — there is no default.") }), execute: ({ laneId }) => gitGuard(() => deps.gitService!.mergeAbort({ laneId: requireMutationLaneId(laneId, "gitMergeAbort") })), }); @@ -1639,7 +1639,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - if (!deps.steerChat) return { success: false, error: "Chat steering is not available." }; try { await deps.steerChat({ sessionId, instruction }); return { success: true, sessionId }; @@ -1650,14 +1649,14 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - if (!deps.cancelSteer) return { success: false, error: "Chat steering is not available." }; + execute: async ({ sessionId, steerId }) => { try { - await deps.cancelSteer({ sessionId }); + await deps.cancelSteer({ sessionId, steerId }); return { success: true, sessionId }; } catch (error) { return { success: false, error: getErrorMessage(error) }; @@ -1665,30 +1664,12 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - if (!deps.handoffChat) return { success: false, error: "Chat handoff is not available." }; - try { - return { success: true, ...(await deps.handoffChat({ sessionId, targetIdentityKey, reason })) }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - tools.listSubagents = tool({ description: "List sub-agents spawned by a chat session.", inputSchema: z.object({ sessionId: z.string().min(1), }), execute: async ({ sessionId }) => { - if (!deps.listSubagents) return { success: false, error: "Sub-agent listing is not available." }; try { const subagents = await deps.listSubagents({ sessionId }); return { success: true, count: subagents.length, subagents }; @@ -1706,7 +1687,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { - if (!deps.approveToolUse) return { success: false, error: "Tool use approval is not available." }; try { await deps.approveToolUse({ sessionId, toolUseId, decision }); return { success: true, sessionId, toolUseId, decision }; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 37214799a..1822831e4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2570,18 +2570,8 @@ type ManagedChatSession = { responseText?: string | null; }) => void; }>; - orchestrationHttpMcpServer: { - config: { type?: string; name?: string; url?: string; headers?: unknown }; - close: () => Promise; - } | null; - /** - * Separate lease from the orchestration one: each HTTP MCP server carries - * exactly one tool set, and a session can in principle want both. - */ - ctoHttpMcpServer: { - config: { type?: string; name?: string; url?: string; headers?: unknown }; - close: () => Promise; - } | null; + /** Live HTTP MCP leases, at most one per tool set. */ + httpMcpServers: Partial>; activeBashControllers: Set; eventSequence: number; lastActivityTimestamp: number; @@ -6030,6 +6020,12 @@ const ORCHESTRATION_CODEX_TOOL_NAMESPACE = "ade_orchestration"; const CTO_MCP_SERVER_NAME = "ade-cto"; const CTO_CODEX_TOOL_NAMESPACE = "ade_cto"; +/** A started HTTP MCP server bound to one session and one tool set. */ +type HttpMcpLease = { + config: { type?: string; name?: string; url?: string; headers?: unknown }; + close: () => Promise; +}; + function stripJsonSchemaMeta(schema: unknown): unknown { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; const record = { ...(schema as Record) }; @@ -8323,11 +8319,14 @@ export function createAgentChatService(args: { gitService: getGitService?.() ?? null, conflictService: conflictService ?? null, computerUseArtifactBrokerService: computerUseArtifactBrokerRef ?? null, - steerChat: undefined, - cancelSteer: undefined, - handoffChat: undefined, - listSubagents: undefined, - approveToolUse: undefined, + // These were `undefined`, so the CTO was advertised four tools that + // could only ever answer "not available" — the same defect this whole + // change set exists to remove. + steerChat: ({ sessionId, instruction }) => steer({ sessionId, text: instruction }), + cancelSteer: ({ sessionId, steerId }) => cancelSteer({ sessionId, steerId }), + listSubagents: ({ sessionId }) => listSubagents({ sessionId }), + approveToolUse: ({ sessionId, toolUseId, decision }) => + approveToolUse({ sessionId, itemId: toolUseId, decision }), issueTracker: linearIssueTracker ?? null, ctoStateService: ctoStateService ?? null, ctoMemoryService: ctoMemoryService ?? null, @@ -8350,10 +8349,12 @@ export function createAgentChatService(args: { reuseExisting, permissionMode: "full-auto", }), - previewSessionToolNames, - } as Parameters[0] & { - previewSessionToolNames: typeof previewSessionToolNames; - }; + // The cast covers one remaining structural gap: `CtoOperatorToolDeps` + // restates `ptyService.create` with optional cols/rows/title while the real + // `PtyCreateArgs` requires them (the pty service clamps to 80x24). Harmless + // while these tool bodies were unreachable; now that they execute, it is + // worth reconciling — tracked as a follow-up rather than widened blind here. + } as Parameters[0]; }; /** @@ -10833,20 +10834,12 @@ export function createAgentChatService(args: { // Non-fatal — provider may be offline } } - const orchestrationMcp = await ensureOrchestrationHttpMcpServer(managed); - const ctoMcp = await ensureCtoHttpMcpServer(managed); - const opencodeRemoteMcpEntry = (name: string, url: string) => ({ - [name]: { type: "remote" as const, url, enabled: true, timeout: 10_000 }, - }); - const opencodeOrchestrationMcp = orchestrationMcp?.config.url || ctoMcp?.config.url - ? { - ...(orchestrationMcp?.config.url - ? opencodeRemoteMcpEntry(ORCHESTRATION_CLAUDE_SERVER_NAME, orchestrationMcp.config.url) - : {}), - ...(ctoMcp?.config.url - ? opencodeRemoteMcpEntry(CTO_MCP_SERVER_NAME, ctoMcp.config.url) - : {}), - } + const opencodeMcpLeases = await ensureHttpMcpLeases(managed); + const opencodeOrchestrationMcp = opencodeMcpLeases.length + ? Object.fromEntries(opencodeMcpLeases.map((lease) => [ + lease.serverName, + { type: "remote" as const, url: lease.url, enabled: true, timeout: 10_000 }, + ])) : undefined; let handle: OpenCodeSessionHandle; try { @@ -10864,7 +10857,7 @@ export function createAgentChatService(args: { logger, }); } catch (error) { - closeOrchestrationHttpMcpServer(managed); + closeHttpMcpServers(managed); throw error; } adoptRuntimeSessionTitle(managed, handle.initialTitle, "opencode_session_create"); @@ -15471,46 +15464,36 @@ export function createAgentChatService(args: { }; /** - * One HTTP MCP lease per tool set. Orchestration and CTO tools are different - * surfaces on potentially the same session, and each lease carries exactly one - * tool set, so they get separate server names and separate cache fields. + * The runtime tool sets ADE can register on a chat session. Each HTTP MCP + * lease carries exactly one of them, so they get distinct server names, + * distinct Codex namespaces, and distinct lease slots. */ - type HttpMcpToolSet = "orchestration" | "cto"; - - const httpMcpServerName = (toolSet: HttpMcpToolSet): string => - toolSet === "cto" ? CTO_MCP_SERVER_NAME : ORCHESTRATION_CLAUDE_SERVER_NAME; - - const httpMcpToolMap = ( - managed: ManagedChatSession, - toolSet: HttpMcpToolSet, - ): OrchestrationToolMap | null => - toolSet === "cto" ? createCtoRuntimeToolMap(managed) : createOrchestrationRuntimeToolMap(managed); + const HTTP_MCP_TOOL_SETS = { + orchestration: { + serverName: ORCHESTRATION_CLAUDE_SERVER_NAME, + codexNamespace: ORCHESTRATION_CODEX_TOOL_NAMESPACE, + buildTools: (managed: ManagedChatSession) => createOrchestrationRuntimeToolMap(managed), + }, + cto: { + serverName: CTO_MCP_SERVER_NAME, + codexNamespace: CTO_CODEX_TOOL_NAMESPACE, + buildTools: (managed: ManagedChatSession) => createCtoRuntimeToolMap(managed), + }, + } as const; - const readHttpMcpLease = ( - managed: ManagedChatSession, - toolSet: HttpMcpToolSet, - ): ManagedChatSession["orchestrationHttpMcpServer"] => - toolSet === "cto" ? managed.ctoHttpMcpServer : managed.orchestrationHttpMcpServer; - - const writeHttpMcpLease = ( - managed: ManagedChatSession, - toolSet: HttpMcpToolSet, - lease: ManagedChatSession["orchestrationHttpMcpServer"], - ): void => { - if (toolSet === "cto") managed.ctoHttpMcpServer = lease; - else managed.orchestrationHttpMcpServer = lease; - }; + type HttpMcpToolSet = keyof typeof HTTP_MCP_TOOL_SETS; + const HTTP_MCP_TOOL_SET_KEYS = Object.keys(HTTP_MCP_TOOL_SETS) as HttpMcpToolSet[]; const ensureHttpMcpServer = async ( managed: ManagedChatSession, toolSet: HttpMcpToolSet, - ): Promise => { - const existing = readHttpMcpLease(managed, toolSet); + ): Promise => { + const existing = managed.httpMcpServers[toolSet]; if (existing) return existing; - const tools = httpMcpToolMap(managed, toolSet); + const tools = HTTP_MCP_TOOL_SETS[toolSet].buildTools(managed); if (!tools) return null; const server = createDroidSdkMcpServer({ - name: httpMcpServerName(toolSet), + name: HTTP_MCP_TOOL_SETS[toolSet].serverName, version: appVersion, tools: Object.entries(tools).map(([name, toolDefinition]) => createDroidSdkTool( @@ -15530,38 +15513,54 @@ export function createAgentChatService(args: { ), }); const config = await server.start(); - const lease = { config, close: () => server.close() }; - writeHttpMcpLease(managed, toolSet, lease); + const lease: HttpMcpLease = { config, close: () => server.close() }; + managed.httpMcpServers[toolSet] = lease; return lease; }; const ensureOrchestrationHttpMcpServer = ( managed: ManagedChatSession, - ): Promise => - ensureHttpMcpServer(managed, "orchestration"); + ): Promise => ensureHttpMcpServer(managed, "orchestration"); const ensureCtoHttpMcpServer = ( managed: ManagedChatSession, - ): Promise => - ensureHttpMcpServer(managed, "cto"); - - const closeHttpMcpServer = (managed: ManagedChatSession, toolSet: HttpMcpToolSet): void => { - const lease = readHttpMcpLease(managed, toolSet); - writeHttpMcpLease(managed, toolSet, null); - if (!lease) return; - lease.close().catch((error) => { - logger.warn("agent_chat.orchestration_mcp_close_failed", { - sessionId: managed.session.id, + ): Promise => ensureHttpMcpServer(managed, "cto"); + + /** + * Resolves every tool set that has a live HTTP lease, in table order. The + * per-SDK config shapes differ (record-of-http, record-of-remote, array), so + * each transport does its own one-line shaping — but lease resolution and the + * "is anything present" test live here once. + */ + const ensureHttpMcpLeases = async ( + managed: ManagedChatSession, + ): Promise> => { + const leases = await Promise.all( + HTTP_MCP_TOOL_SET_KEYS.map(async (toolSet) => ({ toolSet, - error: error instanceof Error ? error.message : String(error), + lease: await ensureHttpMcpServer(managed, toolSet), + })), + ); + return leases.flatMap(({ toolSet, lease }) => + lease?.config.url + ? [{ serverName: HTTP_MCP_TOOL_SETS[toolSet].serverName, url: lease.config.url, config: lease.config }] + : []); + }; + + /** Drops every HTTP MCP lease held by a session. All teardown paths use this. */ + const closeHttpMcpServers = (managed: ManagedChatSession): void => { + for (const toolSet of HTTP_MCP_TOOL_SET_KEYS) { + const lease = managed.httpMcpServers[toolSet]; + if (!lease) continue; + managed.httpMcpServers[toolSet] = undefined; + lease.close().catch((error) => { + logger.warn("agent_chat.orchestration_mcp_close_failed", { + sessionId: managed.session.id, + toolSet, + error: error instanceof Error ? error.message : String(error), + }); }); - }); - }; - - /** Closes both leases. Every teardown path must drop the CTO one too. */ - const closeOrchestrationHttpMcpServer = (managed: ManagedChatSession): void => { - closeHttpMcpServer(managed, "orchestration"); - closeHttpMcpServer(managed, "cto"); + } }; const codexDynamicToolKey = (namespace: string | null | undefined, name: string): string => @@ -15593,11 +15592,9 @@ export function createAgentChatService(args: { runtime.dynamicTools.clear(); runtime.dynamicToolSpecs = []; const specs: CodexDynamicToolSpec[] = []; - const toolSets: Array<[string, OrchestrationToolMap | null]> = [ - [ORCHESTRATION_CODEX_TOOL_NAMESPACE, createOrchestrationRuntimeToolMap(managed)], - [CTO_CODEX_TOOL_NAMESPACE, createCtoRuntimeToolMap(managed)], - ]; - for (const [namespace, tools] of toolSets) { + for (const toolSet of HTTP_MCP_TOOL_SET_KEYS) { + const { codexNamespace: namespace, buildTools } = HTTP_MCP_TOOL_SETS[toolSet]; + const tools = buildTools(managed); if (!tools) continue; for (const [name, toolDefinition] of Object.entries(tools)) { runtime.dynamicTools.set(codexDynamicToolKey(namespace, name), toolDefinition); @@ -15612,7 +15609,7 @@ export function createAgentChatService(args: { managed: ManagedChatSession, toolSet: HttpMcpToolSet, ): ReturnType | null => { - const tools = httpMcpToolMap(managed, toolSet); + const tools = HTTP_MCP_TOOL_SETS[toolSet].buildTools(managed); if (!tools) return null; const sdkTools = Object.entries(tools).map(([name, toolDefinition]) => createClaudeSdkTool( @@ -15649,21 +15646,13 @@ export function createAgentChatService(args: { ), ); return createSdkMcpServer({ - name: httpMcpServerName(toolSet), + name: HTTP_MCP_TOOL_SETS[toolSet].serverName, version: appVersion, tools: sdkTools, alwaysLoad: true, }); }; - const buildClaudeOrchestrationMcpServer = ( - managed: ManagedChatSession, - ): ReturnType | null => buildClaudeSdkMcpServer(managed, "orchestration"); - - const buildClaudeCtoMcpServer = ( - managed: ManagedChatSession, - ): ReturnType | null => buildClaudeSdkMcpServer(managed, "cto"); - const setOpenCodeRuntimeBusy = (runtime: OpenCodeRuntime, busy: boolean): void => { runtime.busy = busy; runtime.handle.setBusy(busy); @@ -15679,7 +15668,7 @@ export function createAgentChatService(args: { ): void => { flushBufferedReasoning(managed); flushBufferedText(managed); - closeOrchestrationHttpMcpServer(managed); + closeHttpMcpServers(managed); const reasonAllowsPreservation = openCodeReason === "idle_ttl" @@ -16230,8 +16219,7 @@ export function createAgentChatService(args: { ...(entry.turnId ? { turnId: entry.turnId } : {}), })) ?? [], localPendingInputs: new Map(), - orchestrationHttpMcpServer: null, - ctoHttpMcpServer: null, + httpMcpServers: {}, activeBashControllers: new Set(), eventSequence: 0, lastActivityTimestamp: Date.now(), @@ -22150,12 +22138,17 @@ export function createAgentChatService(args: { if (runtime.dynamicTools.size === 0) { refreshCodexDynamicTools(managed, runtime); } - // Namespace-less or mis-namespaced calls fall back by name across both - // registered tool sets rather than failing the turn. + // Namespace-less or mis-namespaced calls fall back by name across every + // registered tool set rather than failing the turn. const toolDefinition = runtime.dynamicTools.get(codexDynamicToolKey(namespace, toolName)) - ?? runtime.dynamicTools.get(codexDynamicToolKey(ORCHESTRATION_CODEX_TOOL_NAMESPACE, toolName)) - ?? runtime.dynamicTools.get(codexDynamicToolKey(CTO_CODEX_TOOL_NAMESPACE, toolName)); + ?? HTTP_MCP_TOOL_SET_KEYS.reduce( + (found, toolSet) => + found ?? runtime.dynamicTools.get( + codexDynamicToolKey(HTTP_MCP_TOOL_SETS[toolSet].codexNamespace, toolName), + ), + undefined, + ); if (!toolDefinition) { runtime.sendResponse(id, { success: false, @@ -27075,11 +27068,11 @@ export function createAgentChatService(args: { // The CTO tool server is injected without the `allowManagedMcpServersOnly` // isolation the orchestration lead uses — the CTO is a daily driver chat and // must keep the user's own MCP servers. - const ctoMcpServer = buildClaudeCtoMcpServer(managed); + const ctoMcpServer = buildClaudeSdkMcpServer(managed, "cto"); if (ctoMcpServer) { opts.mcpServers = { ...(opts.mcpServers ?? {}), [CTO_MCP_SERVER_NAME]: ctoMcpServer }; } - const orchestrationMcpServer = buildClaudeOrchestrationMcpServer(managed); + const orchestrationMcpServer = buildClaudeSdkMcpServer(managed, "orchestration"); if (orchestrationMcpServer) { opts.mcpServers = { ...(opts.mcpServers ?? {}), @@ -27089,11 +27082,20 @@ export function createAgentChatService(args: { const hasOrchestrationMcpServer = existingAllowedMcpServers.some( (server) => server.serverName === ORCHESTRATION_CLAUDE_SERVER_NAME, ); + // If a session ever carried both tool sets, `allowManagedMcpServersOnly` + // below would block `ade-cto` even though it is in `opts.mcpServers` — a + // silent capability loss. Unreachable today (a CTO session has no + // orchestration run), but the invariant is implicit, so allow it too. + const managedServerNames = [ + ...(hasOrchestrationMcpServer ? [] : [{ serverName: ORCHESTRATION_CLAUDE_SERVER_NAME }]), + ...(ctoMcpServer + && !existingAllowedMcpServers.some((server) => server.serverName === CTO_MCP_SERVER_NAME) + ? [{ serverName: CTO_MCP_SERVER_NAME }] + : []), + ]; opts.managedSettings = { ...(opts.managedSettings ?? {}), - allowedMcpServers: hasOrchestrationMcpServer - ? existingAllowedMcpServers - : [...existingAllowedMcpServers, { serverName: ORCHESTRATION_CLAUDE_SERVER_NAME }], + allowedMcpServers: [...existingAllowedMcpServers, ...managedServerNames], allowManagedMcpServersOnly: true, strictPluginOnlyCustomization: ["mcp"], }; @@ -28345,8 +28347,7 @@ export function createAgentChatService(args: { bufferedText: null, recentConversationEntries: [], localPendingInputs: new Map(), - orchestrationHttpMcpServer: null, - ctoHttpMcpServer: null, + httpMcpServers: {}, activeBashControllers: new Set(), eventSequence: 0, lastActivityTimestamp: Date.now(), @@ -29016,8 +29017,7 @@ export function createAgentChatService(args: { bufferedText: null, recentConversationEntries: [], localPendingInputs: new Map(), - orchestrationHttpMcpServer: null, - ctoHttpMcpServer: null, + httpMcpServers: {}, activeBashControllers: new Set(), eventSequence: 0, lastActivityTimestamp: Date.now(), @@ -33006,17 +33006,12 @@ export function createAgentChatService(args: { ); } - const orchestrationMcp = await ensureOrchestrationHttpMcpServer(managed); - const cursorCtoMcp = await ensureCtoHttpMcpServer(managed); - const cursorOrchestrationMcpServers = orchestrationMcp?.config.url || cursorCtoMcp?.config.url - ? { - ...(orchestrationMcp?.config.url - ? { [ORCHESTRATION_CLAUDE_SERVER_NAME]: { type: "http" as const, url: orchestrationMcp.config.url } } - : {}), - ...(cursorCtoMcp?.config.url - ? { [CTO_MCP_SERVER_NAME]: { type: "http" as const, url: cursorCtoMcp.config.url } } - : {}), - } + const cursorMcpLeases = await ensureHttpMcpLeases(managed); + const cursorOrchestrationMcpServers = cursorMcpLeases.length + ? Object.fromEntries(cursorMcpLeases.map((lease) => [ + lease.serverName, + { type: "http" as const, url: lease.url }, + ])) : undefined; const throwIfCursorSetupInterrupted = (): void => { @@ -33090,7 +33085,7 @@ export function createAgentChatService(args: { reportProviderRuntimeFailure("cursor", errorMessage); } } - closeOrchestrationHttpMcpServer(managed); + closeHttpMcpServers(managed); throw error; } const pooled = acquired.pooled; @@ -34277,13 +34272,10 @@ export function createAgentChatService(args: { try { const auth = await detectAuth(); throwIfDroidSetupInterrupted(); - const orchestrationMcp = await ensureOrchestrationHttpMcpServer(managed); - const droidCtoMcp = await ensureCtoHttpMcpServer(managed); - const droidMcpConfigs = [ - ...(orchestrationMcp?.config.url ? [orchestrationMcp.config] : []), - ...(droidCtoMcp?.config.url ? [droidCtoMcp.config] : []), - ]; - const droidOrchestrationMcpServers = droidMcpConfigs.length ? droidMcpConfigs : undefined; + const droidMcpLeases = await ensureHttpMcpLeases(managed); + const droidOrchestrationMcpServers = droidMcpLeases.length + ? droidMcpLeases.map((lease) => lease.config) + : undefined; const persisted = readPersistedState(managed.session.id); const acquired = await acquireDroidSdkConnection({ poolKey, @@ -34337,7 +34329,7 @@ export function createAgentChatService(args: { releaseDroidSdkConnection(poolKey, poolGeneration); } if (managed.runtime?.kind !== "droid") { - closeOrchestrationHttpMcpServer(managed); + closeHttpMcpServers(managed); } droidRuntimeSetupInterruptRequested.delete(managed); throw err; diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index bc9c12985..93188d59b 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -279,16 +279,20 @@ struct ContentView: View { CtoRootScreen(isTabActive: selectedTab == .cto) .tag(RootTab.cto) .tabItem { + // The accessibility label belongs on the Label inside .tabItem: SwiftUI + // lifts .tabItem/.badge out of the content view, but an + // .accessibilityLabel applied outside would stay on the content and + // never reach the tab-bar button VoiceOver actually focuses. Label("CTO", systemImage: "brain") + .accessibilityLabel( + syncService.ctoAttention.awaitingInput ? "CTO, waiting on you" : "CTO" + ) } // The CTO chat is excluded from every session roster, so it cannot borrow // the Work badge above — a question from the CTO would otherwise surface // nowhere on the phone. A string badge renders as a dot-sized marker and // hides itself when nil. .badge(syncService.ctoAttention.awaitingInput ? "!" : nil) - .accessibilityLabel( - syncService.ctoAttention.awaitingInput ? "CTO, waiting on you" : "CTO" - ) } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 9d3d3d8f0..cb4eedd5c 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -12593,6 +12593,10 @@ final class SyncService: ObservableObject { private func saveRemoteCommandDescriptors(_ descriptors: [SyncRemoteCommandDescriptor]) { remoteCommandDescriptors = descriptors + // `refreshCtoAttentionIfNeeded` no-ops until it knows the host supports the + // command, so the first probe after a (re)connect has to happen here — + // forced past the debounce, since a reconnect can land inside its window. + refreshCtoAttentionIfNeeded(force: true) if descriptors.isEmpty { UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) } else if let data = try? encoder.encode(descriptors) { @@ -18885,6 +18889,13 @@ extension SyncService { runningChatCount: runningChatCount, idleCount: idleCount ) + // Before the roster early-return, not after: the CTO is excluded from + // `allAgents` by design, so a turn where ONLY the CTO changed leaves the + // signature identical. Hanging the probe below the guard would mean the + // badge only ever updated when some unrelated session happened to change — + // and, once lit, never cleared. Its own debounce bounds the traffic. + refreshCtoAttentionIfNeeded() + guard nextSignature != activeSessionsSnapshotSignature else { return } activeSessionsSnapshotSignature = nextSignature @@ -18893,10 +18904,6 @@ extension SyncService { runningChatSessionCount = runningChatCount idleSessionsCount = idleCount - // The CTO is not in `allAgents` by design, so its dot has to be asked for - // separately off the same change pulse. - refreshCtoAttentionIfNeeded() - scheduleWorkspaceSnapshotWrite() } @@ -18924,7 +18931,11 @@ extension SyncService { let next = try await self.fetchCtoAttention() self.ctoAttention = next } catch { - // Keep the last known state. + // Keep the last known state on a TRANSPORT failure — dropping a pending + // question is worse than a stale dot. Note this does not cover a + // host-side probe failure: `getCtoAttention` swallows those into + // `idle`, so the badge would clear. Distinguishing them needs an + // explicit "unknown" in CtoAttentionState across all three transports. } } } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index e15066c79..beb3a88fa 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -16827,6 +16827,34 @@ final class ADETests: XCTestCase { ) } + func testCtoAttentionDecodesHostIdleAndWaitingPayloads() throws { + // `cto.getAttention` returns `{ awaitingInput, since }` and the host sends + // JSON `null` for `since` whenever nothing is waiting — a non-optional + // `since` would throw there and the tab badge would silently never light. + let waitingData = try JSONSerialization.data(withJSONObject: [ + "awaitingInput": true, + "since": "2026-07-31T00:00:00Z", + ]) + let waiting = try JSONDecoder().decode(CtoAttention.self, from: waitingData) + XCTAssertTrue(waiting.awaitingInput) + XCTAssertEqual(waiting.since, "2026-07-31T00:00:00Z") + + let idleData = try JSONSerialization.data(withJSONObject: [ + "awaitingInput": false, + "since": NSNull(), + ]) + let idle = try JSONDecoder().decode(CtoAttention.self, from: idleData) + XCTAssertFalse(idle.awaitingInput) + XCTAssertNil(idle.since) + XCTAssertEqual(idle, CtoAttention.idle) + + // An older/leaner host may omit the key entirely rather than send null. + let omittedData = try JSONSerialization.data(withJSONObject: ["awaitingInput": true]) + let omitted = try JSONDecoder().decode(CtoAttention.self, from: omittedData) + XCTAssertTrue(omitted.awaitingInput) + XCTAssertNil(omitted.since) + } + func testCtoOnboardingDismissedOnDesktopDoesNotBlockIosTab() { func identity(_ state: CtoOnboardingState?) -> CtoIdentity { CtoIdentity( diff --git a/docs/features/agents/tool-registration.md b/docs/features/agents/tool-registration.md index ff2df064f..e4cf5a6d5 100644 --- a/docs/features/agents/tool-registration.md +++ b/docs/features/agents/tool-registration.md @@ -46,11 +46,14 @@ then hands them to the provider adapter: - **CTO sessions:** `createCtoRuntimeToolMap` (gated on `identityKey === "cto"`) registers `ctoOperatorTools.ts` on the live session through the same per-provider transports the orchestration set - uses: an `ade-cto` SDK MCP server for Claude (injected without + uses. Both sets are described by one `HTTP_MCP_TOOL_SETS` table (server + name, Codex namespace, tool factory), and the CTO entry resolves to an + `ade-cto` SDK MCP server for Claude (injected without `allowManagedMcpServersOnly`, so the user's own MCP servers survive), the `ade_cto` dynamic-tool namespace inside `refreshCodexDynamicTools` for - Codex, and a dedicated HTTP MCP lease (`ctoHttpMcpServer`) for Cursor, - Droid, and OpenCode. `buildCtoOperatorToolDeps` is shared with + Codex, and a dedicated HTTP MCP lease (`ensureHttpMcpServer(managed, + "cto")`, cached in `managed.httpMcpServers`) for Cursor, Droid, and + OpenCode. `buildCtoOperatorToolDeps` is shared with `previewSessionToolNames`, so the prompt manifest and the callable tools come from one definition. See [chat/tool-system.md](../chat/tool-system.md#cto-operator-tools). diff --git a/docs/features/chat/tool-system.md b/docs/features/chat/tool-system.md index b92bb8c85..57e63c34a 100644 --- a/docs/features/chat/tool-system.md +++ b/docs/features/chat/tool-system.md @@ -124,8 +124,8 @@ uses to act on ADE itself: | Tool family | Purpose | |---|---| | `spawnChat` | Spawn a new chat session with an explicit model, reasoning effort, and initial prompt. Lane resolution goes through `resolveExecutionLane`: an explicit `laneId` wins, and omitting it creates a **fresh** lane (`freshLaneName` / `freshLaneDescription`) rather than reusing the caller's. For the CTO that is load-bearing — its session is pinned to the project's primary lane, so a fallback would run spawned agents against the primary worktree. | -| `interruptChat`, `handoffChat` | Mid-session control over other chat sessions. | -| `createTerminal`, `runCommand` | Create untracked shells or run fire-and-forget commands. | +| `interruptChat`, `steerChat`, `cancelSteer`, `listSubagents`, `approveToolUse` | Mid-session control over other chat sessions: interrupt a turn, inject a steer instruction, cancel a pending steer by its `steerId`, enumerate spawned sub-agents, and answer a pending permission prompt. Each is a **required** dep on `CtoOperatorToolDeps` and maps onto the corresponding `agentChatService` method (`approveToolUse` translates the tool's `toolUseId` to the service's `itemId`), so none of them can be advertised without an implementation behind it. | +| `createTerminal`, `runCommand` | Create untracked shells or run fire-and-forget commands. `createTerminal` passes explicit `cols: 100`, `rows: 30`, and a title, rather than relying on the pty service's default clamp. | | `listLanes`, `createLane`, `renameLane`, `archiveLane`, `inspectLane` | Lane management. | | `saveMemory`, `searchMemory`, `readMemory` | Durable CTO memory: save facts, search prior context, review what it knows. | | Linear tools (when connected) | Read and lightly update issues: list/inspect, comment, state, assignee, label. | @@ -142,24 +142,42 @@ renders only the sections the agent can act on. The CTO tool set is a second consumer of the same per-provider tool transports the orchestration tool set uses, gated on `identityKey === "cto"` -via `createCtoRuntimeToolMap(managed)` in `agentChatService.ts`: - -- **Claude** — `buildClaudeCtoMcpServer` produces an SDK MCP server named - `ade-cto` merged into `opts.mcpServers`. It is deliberately injected - *without* the orchestration lead's `allowManagedMcpServersOnly` lockdown, - because the CTO is a daily-driver chat that must keep the user's own MCP - servers. -- **Codex** — `refreshCodexDynamicTools` registers them as dynamic tools - under the `ade_cto` namespace next to orchestration's `ade_orchestration`. - Both sets must register in that one function: it clears the runtime's - dynamic-tool map before rebuilding, so a second refresher would clobber the - first. Dispatch falls back by bare name across both namespaces when a call - arrives un-namespaced. -- **Cursor, Droid, OpenCode** — a second HTTP MCP lease - (`ensureCtoHttpMcpServer`, cached on `managed.ctoHttpMcpServer`) advertised - under the `ade-cto` server name. It is a separate lease from - `orchestrationHttpMcpServer` because each lease carries exactly one tool - set; `closeOrchestrationHttpMcpServer` closes both. +via `createCtoRuntimeToolMap(managed)` in `agentChatService.ts`. + +A single descriptor table, `HTTP_MCP_TOOL_SETS`, names the tool sets ADE can +register on a session. Each entry carries a `serverName`, a `codexNamespace`, +and a `buildTools(managed)` factory, so every transport below reads its +identifiers from one place instead of restating them: + +| Tool set | `serverName` | `codexNamespace` | `buildTools` | +| --- | --- | --- | --- | +| `orchestration` | `ade-orchestration` | `ade_orchestration` | `createOrchestrationRuntimeToolMap` | +| `cto` | `ade-cto` | `ade_cto` | `createCtoRuntimeToolMap` | + +- **Claude** — `buildClaudeSdkMcpServer(managed, "cto")` produces an SDK MCP + server named `ade-cto`, merged into `opts.mcpServers`. It is deliberately + injected *without* the orchestration lead's `allowManagedMcpServersOnly` + lockdown, because the CTO is a daily-driver chat that must keep the user's + own MCP servers. (When a session ever carries both sets, the orchestration + path adds `ade-cto` to `allowedMcpServers` too, so the lockdown cannot + silently drop it.) +- **Codex** — `refreshCodexDynamicTools` walks the whole table and registers + each set as dynamic tools under its own namespace (`ade_cto` next to + `ade_orchestration`). Both sets must register in that one function: it clears + the runtime's dynamic-tool map before rebuilding, so a second refresher would + clobber the first. Dispatch falls back by bare name across both namespaces + when a call arrives un-namespaced. +- **Cursor, Droid, OpenCode** — HTTP MCP leases. `ensureHttpMcpServer(managed, + toolSet)` starts one server per tool set and caches it in + `managed.httpMcpServers`, a `Partial>` + keyed by the same table. Each lease carries exactly one tool set, so the CTO + and orchestration servers stay distinct rather than merging into one. + Transports call `ensureHttpMcpLeases(managed)`, which resolves every live + lease in table order and returns `{ serverName, url, config }` for each — the + per-SDK config shapes differ (record-of-http, record-of-remote, array), so + each call site does its own one-line shaping over that list. + `closeHttpMcpServers(managed)` drops every lease, and all teardown paths use + it. `buildCtoOperatorToolDeps` builds the dependency set for both the runtime map and `previewSessionToolNames` (which enumerates the same names for the prompt), diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index c7d0516cd..5fbb6a15d 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -55,7 +55,7 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. - `CtoSettingsScreen.swift` — sections: Identity (including personality/work style via `CtoIdentityEditor`), Model (live model/reasoning/Fast selection), Integrations (read-only Linear connection status), Memory (durable facts + thread summary via `cto.getMemory`), and Advanced (re-run setup). - `CtoIdentityEditor.swift` / `CtoReloadHelpers.swift` — the identity edit sheet and reload plumbing. - `apps/ios/ADE/Models/RemoteModels.swift` — `CtoAttention` (`awaitingInput` + optional `since`, plus an `idle` constant), the Codable mirror of `CtoAttentionState`. -- `apps/ios/ADE/Services/SyncService.swift` — `fetchCtoAttention()` (the `cto.getAttention` call), the `@Published ctoAttention`, and `refreshCtoAttentionIfNeeded()`. +- `apps/ios/ADE/Services/SyncService.swift` — `fetchCtoAttention()` (the `cto.getAttention` call), the `@Published ctoAttention`, and `refreshCtoAttentionIfNeeded()`, called from `refreshActiveSessionsAndSnapshot()` above its roster-signature early return and from `saveRemoteCommandDescriptors` with `force: true`. The CTO tab icon is the SF Symbol `brain` (`apps/ios/ADE/App/ContentView.swift`), matching the desktop Phosphor Brain glyph; the same tab carries the attention badge described in [Hidden from rosters, but never silent](#hidden-from-rosters-but-never-silent). @@ -123,7 +123,7 @@ Hiding the row removes it from `terminalAttention`, which is what the Work dot a - It is **read-only**. It resolves the thread through the same `listIdentitySessions` helper `ensureIdentitySession` uses, but never calls `ensureIdentitySession` itself: rendering a badge must not materialize a primary lane and a chat session as a side effect. The predicate is `awaitingInput || pendingInputItemId || attentionRequestedAt` (the last being an explicit `ade chat ask` hand-raise) rather than `canonicalStatusBucket`, whose awaiting-input bucket folds in `idle` and `ready` and would light the dot whenever the CTO is merely sitting there. A probe failure logs and returns idle. - `useCtoAttention` (mounted once in `AppShell`) keeps `appStore.ctoAttention` fresh from chat events, focus, and a 15 s visible-tab interval; `TabNav` renders the dot on `/cto`. It filters chat events through `shouldRefreshSessionListForChatEvent` so a streaming turn does not re-run a full identity scan per delta, debounces to 1.5 s (0 on focus), and clears to idle on project switch so the previous project's state cannot linger. - `useAppWideSessionAttention` adds the CTO to the dock badge count so a question reaches a minimized window. It stays the single writer of `setDockBadgeCount`. -- **iOS** takes the same path. `SyncService.fetchCtoAttention()` calls `cto.getAttention` and publishes `ctoAttention`; `ContentView` badges the CTO tab (a string badge, so it renders as a dot-sized marker and disappears when idle) with a matching accessibility label. The refresh rides the same "something changed" pulse that rebuilds the session roster — the CTO is not *in* that roster, so it needs its own read — and is debounced to 5 s with `force` for an explicit refresh. It is gated on `supportsRemoteAction("cto.getAttention")`: an older brain never lights the dot, and a value left over from a newer host is cleared. A failed probe keeps the last known value rather than clearing, because falsely dropping a pending question is worse than a slightly stale dot. +- **iOS** takes the same path. `SyncService.fetchCtoAttention()` calls `cto.getAttention` and publishes `ctoAttention`; `ContentView` badges the CTO tab (a string badge, so it renders as a dot-sized marker and disappears when idle) with a matching accessibility label. `refreshCtoAttentionIfNeeded()` rides the same "something changed" pulse that rebuilds the session roster — the CTO is not *in* that roster, so it needs its own read. It is called from `refreshActiveSessionsAndSnapshot()` **above** the roster-signature early return, not below it: since the CTO is excluded from `allAgents`, a turn where only the CTO changed leaves the signature identical, so a probe hanging below the guard would fire only when some unrelated session happened to change — and, once lit, would never clear. It is also called with `force: true` from `saveRemoteCommandDescriptors`, because the probe no-ops until it knows the host advertises the command, so the first read after a (re)connect has to happen when the descriptors land and must skip the debounce a reconnect could land inside. Otherwise it is debounced to 5 s. It is gated on `supportsRemoteAction("cto.getAttention")`: an older brain never lights the dot, and a value left over from a newer host is cleared. A failed probe keeps the last known value rather than clearing, because falsely dropping a pending question is worse than a slightly stale dot. ### The opening turn @@ -145,24 +145,37 @@ the surface it can actually call come from one definition and cannot drift apart. (`buildCtoCapabilityManifest` renders its inventory from the same `createCtoOperatorTools()` factory, with stub deps, for the same reason.) +Every chat-control dep — `steerChat`, `cancelSteer`, `listSubagents`, +`approveToolUse` — is **required** on `CtoOperatorToolDeps` and wired to the +matching `agentChatService` method (`steer`, `cancelSteer`, `listSubagents`, +`approveToolUse`, the last translating the tool's `toolUseId` into the +service's `itemId`). Making them required is the guard: an optional dep left +unset advertises a tool whose only possible answer is "not available", which is +worse than not offering it. `cancelSteer` takes the `steerId` that `steerChat` +returned, so cancelling is unambiguous when several steers are pending. There +is no `handoffChat`: it targeted "a different agent identity" and +`AgentChatIdentityKey` is just `"cto"`. + Registration then goes through whichever transport the session's provider -speaks: +speaks. All three read their identifiers from one descriptor table, +`HTTP_MCP_TOOL_SETS`, whose `cto` entry names the `ade-cto` server, the +`ade_cto` Codex namespace, and `createCtoRuntimeToolMap` as its factory: | Provider | Transport | | --- | --- | -| Claude | `buildClaudeCtoMcpServer` returns an SDK MCP server named `ade-cto`, merged into `opts.mcpServers`. Unlike the orchestration lead's server it is injected **without** `allowManagedMcpServersOnly` — the CTO is a daily-driver chat and must keep the user's own MCP servers. | -| Codex | `refreshCodexDynamicTools` registers them as dynamic tools under the `ade_cto` namespace, alongside the orchestration set under `ade_orchestration`. Dispatch falls back by bare name across both namespaces when a call arrives un-namespaced. | -| Cursor / Droid / OpenCode | An HTTP MCP lease cached on `managed.ctoHttpMcpServer`, provisioned by `ensureCtoHttpMcpServer` and advertised to the runtime under the `ade-cto` server name. | +| Claude | `buildClaudeSdkMcpServer(managed, "cto")` returns an SDK MCP server named `ade-cto`, merged into `opts.mcpServers`. Unlike the orchestration lead's server it is injected **without** `allowManagedMcpServersOnly` — the CTO is a daily-driver chat and must keep the user's own MCP servers. | +| Codex | `refreshCodexDynamicTools` walks the table and registers each set as dynamic tools under its own namespace — `ade_cto` alongside orchestration's `ade_orchestration`. Dispatch falls back by bare name across both namespaces when a call arrives un-namespaced. | +| Cursor / Droid / OpenCode | An HTTP MCP lease from `ensureHttpMcpServer(managed, "cto")`, cached in `managed.httpMcpServers.cto` and advertised under the `ade-cto` server name. Transports resolve every live lease at once via `ensureHttpMcpLeases(managed)`. | Two invariants keep this from breaking quietly: - **One refresher per Codex runtime.** `refreshCodexDynamicTools` clears the dynamic-tool map before rebuilding it, so both tool sets must register inside that one function. A second refresher would clobber the first. -- **One tool set per HTTP MCP lease.** That is why `ctoHttpMcpServer` is a - separate field from `orchestrationHttpMcpServer` rather than a shared server - carrying both. `closeOrchestrationHttpMcpServer` closes both leases, so every - teardown path drops the CTO one too. +- **One tool set per HTTP MCP lease.** `managed.httpMcpServers` is a map keyed + by tool set rather than a single shared server carrying both, and + `ensureHttpMcpServer` starts one server per key. `closeHttpMcpServers(managed)` + drops every lease in the map, so every teardown path releases the CTO one too. ### Where CTO-launched work runs diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index a0f6eb405..2ddadd3ed 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1710,7 +1710,7 @@ any non-primary-key unique index. | **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/read. Search is a single full-screen page (`FilesSearchScreen`) opened from the magnifying-glass button in the Files top bar (desktop `SearchOverlay` parity): one query searches file *names* (quick open) and file *contents* (text search) together — name matches surface first under "Files", content hits are grouped per file with collapsible line previews, and tapping a line opens the file at that line. The inline `FilesQueryCard` quick-open / text-search cards (and their 40-row caps) were removed. Files are freely editable — the mobile read-only file-mutation gate (`mobileReadOnly` / edit-protection) was removed on both the host and the phone, matching the desktop change. | | **Work** | `terminal` | `/work` | Terminal + chat session list (standalone CLI sessions stay listed after they end, matching desktop — `workSessionShouldAppearInWorkList` in `WorkBrowserHelpers.swift` hides orphaned chat-owned child shells that are no longer live), cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. The composer's last-used selection (model + access mode + reasoning effort + fast mode) persists across surfaces through `WorkComposerPreferences` (App Group `UserDefaults`, versioned key): the New Chat screen seeds its initial state from the saved selection instead of hardcoded defaults, and every change or send — from the New Chat composer, the in-session inline picker (`WorkSessionDestinationView`), or the session settings sheet — writes it back. Because the inline picker is cross-provider, the persisted provider is re-derived from the picked model, and a provider change resets the coupled access mode / sub-settings to that provider's defaults. Droid (Factory) is in the new-chat provider allowlist (`workNormalizedNewChatProvider`), so Droid Core models (GLM / Kimi / MiniMax) keep the `droid` provider instead of silently collapsing to the Claude runtime. The new-chat send button is the shared `ADEComposerSendButton` (an arrow-in-circle disc matching the in-session composer), replacing the earlier paperplane capsule. Each session row carries a minimal per-lane PR status indicator (`WorkLanePrIndicator`: a state-colored dot + `#num` + Open/Draft/Closed/Merged) beside the lane name. It and the Lanes tab chip both render the unified `LanePrTag` (`LaneHelpers.swift`, `selectLaneTabPrTag`, desktop parity), which merges ADE-mapped PRs (the synced `pull_requests` table) with GitHub PRs opened outside ADE — matched to a lane by branch and fetched into the shared `SyncService.laneGithubPrItems` cache (`refreshLaneGithubPrItems`, best-effort, throttled, reset on project switch / reconnect). When a row resolves a `LanePrTag` (mapped or GitHub-by-branch), its long-press context menu (`WorkSessionListRow`) also offers **"Open in PRs tab"**; `WorkRootScreen+Actions.openPullRequest` waits out the menu-dismiss animation, then publishes `syncService.requestedPrNavigation` (a `PrNavigationRequest` carrying the PR id + number + lane id, or just the GitHub PR number for an unmapped tag), and `ContentView`'s `onChange(of: requestedPrNavigation?.id)` flips the app to the PRs tab and opens that PR — the same cross-tab handoff the deep-link router and the in-chat PR menu use. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. Only real user drags can un-pin the viewport: layout-driven geometry changes (keyboard show/hide, key bar, pinch font changes) re-assert the live tail after the pass settles, so a pinned terminal with large scrollback keeps the prompt visible above the keyboard instead of stranding it (SwiftTerm only re-snaps when cols/rows change, and a mouse-mode TUI repainting in place emits no scroll events to self-heal). When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). The chat composer input is a `UITextView`-backed field (`WorkComposerTextView` in `WorkComposerTypedTriggers.swift`) rather than a plain SwiftUI `TextField`, because it needs the cursor position and inline styled runs. `WorkComposerTriggerDetector` runs the same cursor-relative regexes as the shared desktop/TUI `composerTriggers.ts` (slash `(?:^|\s)/([^\s/]*)$`, at `(?:^|\s)@([^\s@]*)$`), so a `/command` or `@file` trigger is detected anywhere in the draft, not just at position 0. `WorkComposerSuggestionController` drives an inline suggestion strip (`WorkComposerSuggestionStrip`) above the input — a curated per-provider slash catalog (`WorkComposerSlashCatalog`) resolved locally, and `@file` quick-open resolved over sync via `SyncService.quickOpen` against the lane's files workspace (40 ms debounce, workspace id cached per lane, invalidated on lane change). Its visibility derives purely from the active trigger match, never from `@FocusState`. Committing a suggestion splices exactly the trigger span on the live text view, and confirmed `/command` / `@path` tokens render as tinted chip pills drawn by a custom TextKit 1 `WorkComposerChipLayoutManager` (provider-accent tint, monospace for slash, semibold for at) while `draftState.text` stays the plain-text source of truth that is sent. `WorkSmartLinkDetector` styles GitHub, Linear, ADE, and generic web URLs with the same chip layout manager in both new-chat and in-session composers; Backspace/Delete removes an intersected URL atomically, and long press offers Copy link and Remove link. The raw URL remains the SwiftUI draft and sent prompt. This replaced the modal `WorkMentionsPickerSheet` and `WorkSlashCommandsSheet` (both deleted). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: GitHub stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, Integration/Rebase workflow cards rendered from `PrWorkflowCard`, and per-PR action capabilities. The PR detail screen (`PrDetailView`) is a single-column adaptation of the desktop Timeline+Rails layout — its Overview is emitted as sibling `List` rows so the list virtualizes offscreen content, and it stays live off a warm-cache freshness gate (see [PR detail screen](#pr-detail-screen)). | -| **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`) with a compact one-line voice/send composer. The top-bar gear opens settings for identity/personality, live model/reasoning/Fast selection, read-only Linear status, memory via `cto.getMemory`, and re-run setup. The tab badges when the thread is blocked on the user: `SyncService.refreshCtoAttentionIfNeeded()` rides the same change pulse that rebuilds the session roster, calls the optional `cto.getAttention` command (5 s debounce, gated on `supportsRemoteAction`), and publishes `ctoAttention`. A failed probe keeps the last known value; an older brain that does not advertise the action clears it. | +| **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`) with a compact one-line voice/send composer. The top-bar gear opens settings for identity/personality, live model/reasoning/Fast selection, read-only Linear status, memory via `cto.getMemory`, and re-run setup. The tab badges when the thread is blocked on the user: `SyncService.refreshCtoAttentionIfNeeded()` calls the optional `cto.getAttention` command (5 s debounce, gated on `supportsRemoteAction`) and publishes `ctoAttention`. It rides the change pulse that rebuilds the session roster, but is invoked *before* `refreshActiveSessionsAndSnapshot`'s roster-signature early return — the CTO is excluded from that roster, so a CTO-only change leaves the signature unchanged and a probe below the guard could never fire. `saveRemoteCommandDescriptors` also calls it with `force: true`, so the first probe after a (re)connect happens as soon as the host advertises the command. A failed probe keeps the last known value; an older brain that does not advertise the action clears it. | | **Settings** | `gearshape` | `/settings` (sync subset) | Connections — account sign-in (primary, PIN-less directory + Relay adoption), account-wide machine rename/clear, scan the QR (`SettingsPairingScannerSheet`) + PIN, or Nearby + PIN — plus advanced SSH bootstrap, appearance, diagnostics, reconnect, forget, and a **Push delivery** panel (`SettingsPushDeliverySection`: registration/permission state, APNs environment, relay reachability from `push.getStatus`, and notification / Live-Activity / quiet-hours toggles). `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` / `SettingsPushDeliverySnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`, `SettingsPushDeliverySection`) instead of having them reach into `SyncService` directly. The About row formats the marketing and build versions together as `v ()`. | `WorkModelPickerSheet` shows the same Claude authentication affordance