From cd5660e7722bf5c846b8830fb7645145b0f181ee Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:56:18 -0400 Subject: [PATCH] feat(cto): keep CTO work off the primary lane, surface its attention, seed an opening turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CTO thread is pinned to the project's primary lane and hidden from every session roster. Three consequences of that design were unhandled: Work landed on the primary worktree. The capability manifest instructed the CTO to "always default laneId to the CTO's current lane" — which is the primary lane — so every agent it launched ran against the primary worktree. The prompt now reserves the CTO's own lane for read-only inspection, and resolveCtoExecutionLane creates a dedicated lane when none is requested instead of falling back to the CTO's. It never re-targets primary on failure; the error surfaces to the caller. A hidden thread could ask silently. Being filtered out of every roster also filters the CTO out of terminalAttention and the dock badge, so a question from the CTO surfaced nowhere. getCtoAttention is a canonical, strictly read-only probe — it never calls ensureIdentitySession, which would materialize a lane and a session as a side effect of drawing a badge. Both transports (plain IPC and the cto_state action domain) delegate to it so they cannot drift. It feeds a dot on the CTO tab and the dock badge count. First run opened on a blank screen. seedCtoIntroTurn sends one real, visible first user turn when a CTO session is created. Visible rather than hidden or canned: ADE has no hidden-turn mechanism, and a fabricated assistant message would feed back into the model's context on every later turn. The once-only marker persists after the send succeeds, so an unauthenticated first run retries instead of burning its one shot. iOS previously replaced onboardingState wholesale when saving CTO setup, which would erase that marker and re-seed a duplicate intro turn; it now unions. Co-Authored-By: Claude Opus 5 --- apps/ade-cli/src/headlessLinearServices.ts | 11 ++ .../src/main/services/adeActions/registry.ts | 10 ++ .../services/ai/tools/ctoOperatorTools.ts | 2 +- .../services/chat/agentChatService.test.ts | 138 +++++++++++++++++- .../main/services/chat/agentChatService.ts | 135 ++++++++++++++++- .../src/main/services/cto/ctoPromptContent.ts | 24 ++- .../src/main/services/cto/ctoState.test.ts | 23 +++ .../src/main/services/ipc/registerIpc.ts | 15 ++ apps/desktop/src/preload/global.d.ts | 2 + apps/desktop/src/preload/preload.ts | 5 + .../src/renderer/components/app/AppShell.tsx | 2 + .../src/renderer/components/app/TabNav.tsx | 13 ++ .../hooks/useAppWideSessionAttention.ts | 14 +- .../src/renderer/hooks/useCtoAttention.ts | 111 ++++++++++++++ apps/desktop/src/renderer/state/appStore.ts | 22 ++- apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/cto.ts | 14 ++ apps/ios/ADE/Models/RemoteModels.swift | 13 ++ apps/ios/ADE/Views/Cto/CtoSetup.swift | 6 +- apps/ios/ADETests/ADETests.swift | 17 +++ docs/ARCHITECTURE.md | 17 ++- docs/features/chat/agent-routing.md | 14 ++ docs/features/chat/tool-system.md | 2 +- docs/features/cto/README.md | 38 ++++- 24 files changed, 629 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/useCtoAttention.ts diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 9f52369546..27dd64d3e1 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -145,6 +145,8 @@ type HeadlessLinearServices = { getSessionSummary: ( sessionId: string, ) => Promise | null>; + /** Mirrors the desktop chat service so `cto_state.getAttention` resolves headlessly. */ + getCtoAttention: () => Promise<{ awaitingInput: boolean; since: string | null }>; getChatTranscript: (args: { sessionId: string; limit?: number; @@ -1929,6 +1931,15 @@ function createHeadlessAgentChatService( async getSessionSummary(sessionId: string) { return sessions.get(sessionId.trim()) ?? null; }, + async getCtoAttention() { + // The `cto_state.getAttention` action calls this unconditionally + // (`runtime.agentChatService?.getCtoAttention()` only guards a null + // service, not a missing method), so the headless runtime must answer or + // `ade actions run cto_state.getAttention` throws a TypeError. Headless + // sessions never block on user input — there is no turn loop to block — + // so "not waiting" is the truthful answer, not a placeholder. + return { awaitingInput: false, since: null }; + }, async getChatTranscript({ sessionId, limit, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 6d08b76be9..a9073c3e06 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -86,6 +86,7 @@ import type { ProxyStatus, AiFeatureKey, AiSettingsStatus, + CtoAttentionState, CtoRunProjectScanResult, CtoLinearQuickView, LinearConnectionStatus, @@ -692,6 +693,7 @@ export const ADE_ACTION_ALLOWLIST: Partial null) ?? null; return { detection }; }, + /** + * Read-only attention probe for the hidden CTO thread. Delegates to the + * chat service so this transport cannot derive "needs you" differently from + * the plain-IPC one. + */ + getAttention: async (): Promise => + (await runtime.agentChatService?.getCtoAttention()) + ?? { awaitingInput: false, since: null }, }; } diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index b978eaa974..b532d6c3f9 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -423,7 +423,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record( throw new Error("Timed out waiting for agent chat event."); } +async function waitForCondition( + predicate: () => boolean, + description: string, +): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for ${description}.`); +} + async function waitForFakeTimerCondition( predicate: () => boolean, description: string, @@ -7810,7 +7821,7 @@ describe("createAgentChatService", () => { }); describe("CTO memory + model-switch-safe thread", () => { - async function createCtoServices() { + async function createCtoServices({ seedIntro = false }: { seedIntro?: boolean } = {}) { const adeDir = path.join(tmpRoot, ".ade"); fs.mkdirSync(adeDir, { recursive: true }); const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger() as any); @@ -7821,6 +7832,20 @@ describe("createAgentChatService", () => { adeDir, ctoMemoryService, }); + if (seedIntro) { + // The opening turn is dispatched for real, so it needs a runtime stream. + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "finish", totalUsage: { inputTokens: 1, outputTokens: 1 } }; + })(), + } as any); + } else { + // Creating a CTO session dispatches its opening turn. Tests that are not + // about the intro must opt out: the send is fire-and-forget, so it would + // otherwise outlive the test and hit a `streamText` mock that beforeEach + // has already reset — surfacing as an unhandled rejection. + ctoStateService.completeOnboardingStep("intro"); + } return { db, ctoStateService, ctoMemoryService }; } @@ -7892,6 +7917,117 @@ describe("createAgentChatService", () => { db.close(); }); + + // A freshly created CTO thread used to open on a blank screen. It now seeds + // one real, visible first turn. The flag lives in onboarding state so it + // survives restarts and cannot fire twice. + it("seeds a visible intro turn when the CTO thread is first created", async () => { + const { db, ctoStateService, ctoMemoryService } = await createCtoServices({ seedIntro: true }); + const { service } = createService({ ctoStateService, ctoMemoryService }); + + const session = await service.ensureIdentitySession({ identityKey: "cto", laneId: "lane-1" }); + + await waitForCondition( + () => ctoStateService.getOnboardingState().completedSteps.includes("intro"), + "CTO intro turn to be seeded", + ); + + // The turn is a real, visible user message — not a fabricated assistant + // entry — so it must show up in the transcript as the user's own text. + const { entries } = await service.getChatTranscript({ sessionId: session.id }); + const introTurns = entries.filter( + (entry) => entry.role === "user" && entry.text.includes("Introduce yourself"), + ); + expect(introTurns).toHaveLength(1); + + db.close(); + }); + + it("does not re-seed the intro turn when an existing CTO thread is reused", async () => { + const { db, ctoStateService, ctoMemoryService } = await createCtoServices({ seedIntro: true }); + const { service } = createService({ ctoStateService, ctoMemoryService }); + + const first = await service.ensureIdentitySession({ identityKey: "cto", laneId: "lane-1" }); + await waitForCondition( + () => ctoStateService.getOnboardingState().completedSteps.includes("intro"), + "CTO intro turn to be seeded", + ); + + const reused = await service.ensureIdentitySession({ identityKey: "cto", laneId: "lane-1" }); + expect(reused.id).toBe(first.id); + + // Assert on dispatched turns, not the onboarding marker: + // completeOnboardingStep is idempotent, so a marker count would hold even + // if a second intro were sent. A reused thread already has content, so a + // second opening turn would land mid-conversation. + const { entries } = await service.getChatTranscript({ sessionId: reused.id }); + const introTurns = entries.filter( + (entry) => entry.role === "user" && entry.text.includes("Introduce yourself"), + ); + expect(introTurns).toHaveLength(1); + + db.close(); + }); + + // The CTO chat is filtered out of every session roster, so it never reaches + // `terminalAttention` or the dock badge. `getCtoAttention` is the only thing + // standing between a hidden thread and a silently unanswered question. + describe("getCtoAttention", () => { + it("reports idle without creating a CTO session or a primary lane", async () => { + const { db, ctoStateService, ctoMemoryService } = await createCtoServices(); + const { service, sessionService } = createService({ ctoStateService, ctoMemoryService }); + + const before = sessionService.list({}).length; + const attention = await service.getCtoAttention(); + + expect(attention).toEqual({ awaitingInput: false, since: null }); + // The invariant that matters: drawing a badge must not materialize a + // lane and a chat session as a side effect. + expect(sessionService.list({}).length).toBe(before); + + db.close(); + }); + + it("reports awaiting input when the CTO thread raises a hand", async () => { + const { db, ctoStateService, ctoMemoryService } = await createCtoServices(); + const { service, sessionService } = createService({ ctoStateService, ctoMemoryService }); + const session = await service.ensureIdentitySession({ identityKey: "cto", laneId: "lane-1" }); + + expect(await service.getCtoAttention()).toEqual({ awaitingInput: false, since: null }); + + // `ade chat ask` raises a hand on the backing session row — a separate + // signal from the chat-level `awaitingInput` waiter, and the one a + // hidden thread would otherwise have no way to surface. + const row = sessionService.get(session.id)!; + expect(row, "CTO session row").toBeTruthy(); + row.attentionRequestedAt = new Date().toISOString(); + const attention = await service.getCtoAttention(); + + expect(attention.awaitingInput).toBe(true); + expect(attention.since).toBeTruthy(); + + db.close(); + }); + + it("clears once the hand-raise is resolved", async () => { + const { db, ctoStateService, ctoMemoryService } = await createCtoServices(); + const { service, sessionService } = createService({ ctoStateService, ctoMemoryService }); + const session = await service.ensureIdentitySession({ identityKey: "cto", laneId: "lane-1" }); + + const row = sessionService.get(session.id)!; + expect(row, "CTO session row").toBeTruthy(); + row.attentionRequestedAt = new Date().toISOString(); + expect((await service.getCtoAttention()).awaitingInput).toBe(true); + + row.attentionRequestedAt = null; + const attention = await service.getCtoAttention(); + + expect(attention.awaitingInput).toBe(false); + expect(attention.since).toBeNull(); + + db.close(); + }); + }); }); describe("identity continuity", () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index e52a76ddc6..4567949829 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -330,6 +330,7 @@ import type { ComputerUseBackendStatus, TerminalSessionStatus, TerminalToolType, + CtoAttentionState, CtoCapabilityMode, LaneLinearIssue, SessionLinearIssueLink, @@ -412,6 +413,7 @@ import type { ExecutableTool } from "../ai/tools/executableTool"; import { createWorkflowTools } from "../ai/tools/workflowTools"; import { createLinearTools } from "../ai/tools/linearTools"; import { createCtoOperatorTools, type CtoOperatorToolDeps } from "../ai/tools/ctoOperatorTools"; +import { CTO_INTRO_ONBOARDING_STEP, CTO_INTRO_PROMPT } from "../cto/ctoPromptContent"; import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; import { resolveClaudeCliModel } from "../ai/claudeModelUtils"; import { @@ -8203,6 +8205,37 @@ export function createAgentChatService(args: { trackSubagentEventInMap(map, event, nowIso()); }; + /** + * Where CTO-launched work runs. An explicit lane wins; otherwise the work + * gets a *fresh* lane. It deliberately never falls back to the CTO's own + * lane: that lane is the project's primary lane, so the old fallback landed + * every spawned agent on the primary worktree. If lane creation fails we let + * the error surface (spawnChat reports it) rather than quietly re-targeting + * primary, which is the failure mode this exists to prevent. + */ + const resolveCtoExecutionLane: CtoOperatorToolDeps["resolveExecutionLane"] = async ({ + requestedLaneId, + purpose, + freshLaneName, + freshLaneDescription, + }) => { + const explicit = requestedLaneId?.trim(); + if (explicit) return explicit; + const name = freshLaneName?.trim() || purpose?.trim() || "CTO work"; + const lane = await laneService.create({ + name, + ...(freshLaneDescription?.trim() + ? { description: freshLaneDescription.trim() } + : {}), + }); + logger.info("agent_chat.cto_execution_lane_created", { + laneId: lane.id, + name, + purpose: purpose ?? null, + }); + return lane.id; + }; + const previewSessionToolNames = ({ laneId, sessionProfile, @@ -8243,7 +8276,7 @@ export function createAgentChatService(args: { defaultLaneId: laneId, defaultModelId: null, defaultReasoningEffort: null, - resolveExecutionLane: async ({ requestedLaneId }) => requestedLaneId?.trim() || laneId, + resolveExecutionLane: resolveCtoExecutionLane, laneService, prService: prService ?? null, fileService: fileService ?? null, @@ -38067,6 +38100,94 @@ export function createAgentChatService(args: { return disposed; }; + /** Sessions whose intro send is in flight, so a racing ensureSession cannot double-send. */ + const ctoIntroInFlight = new Set(); + + /** + * Seeds the opening turn for a freshly created CTO thread so first-run users + * do not land on a blank screen. Only fires for a *new* session: a reused + * thread already has content, so there is nothing to fill. + * + * The completion flag is persisted only after the send succeeds — if the + * provider is unauthenticated on first run the thread stays empty and the + * next session creation retries rather than silently burning the one shot. + */ + const seedCtoIntroTurn = async (sessionId: string): Promise => { + if (!ctoStateService) return; + if (ctoIntroInFlight.has(sessionId)) return; + ctoIntroInFlight.add(sessionId); + try { + // Inside the try: this runs on a `void`-called path, so a KV read throw + // here would surface as an unhandled rejection in the main process. + const onboarding = ctoStateService.getOnboardingState(); + if (onboarding.completedSteps.includes(CTO_INTRO_ONBOARDING_STEP)) return; + // awaitDispatch so a first-run dispatch failure (unauthenticated provider, + // no model configured) lands in the catch below instead of escaping as an + // unhandled rejection from a fire-and-forget turn. + await sendMessage({ sessionId, text: CTO_INTRO_PROMPT }, { awaitDispatch: true }); + ctoStateService.completeOnboardingStep(CTO_INTRO_ONBOARDING_STEP); + logger.info("agent_chat.cto_intro_seeded", { sessionId }); + } catch (error) { + logger.warn("agent_chat.cto_intro_seed_failed", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + ctoIntroInFlight.delete(sessionId); + } + }; + + /** + * Identity sessions for a key, newest activity first. Single source of truth + * for "which session is the CTO thread" — `ensureIdentitySession` and the + * attention probe must not answer that question differently. + */ + const listIdentitySessions = async ( + identityKey: AgentChatIdentityKey, + ): Promise => { + const existing = await listSessions(undefined, { includeIdentity: true }); + return existing + .filter((entry) => entry.identityKey === identityKey) + .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt)); + }; + + /** + * Is the CTO thread blocked on the user? + * + * Canonical because the CTO chat is hidden from every session roster: it never + * reaches `terminalAttention`, so this is the only thing standing between a + * hidden thread and a silently unanswered question. Both transports (plain IPC + * and the daemon action domain) delegate here so they cannot drift. + * + * Strictly read-only. It must never call `ensureIdentitySession` — that would + * materialize a primary lane and a chat session as a side effect of drawing a + * badge. + * + * The predicate deliberately does not reuse `canonicalStatusBucket`: its + * "awaiting-input" bucket folds in `idle` and `ready`, which would light the + * badge whenever the CTO is merely sitting there. It covers the chat-level + * waiters plus an explicit hand-raise on the backing session row (`ade chat + * ask`), which is a separate signal from `awaitingInput`. + */ + const getCtoAttention = async (): Promise => { + const idle: CtoAttentionState = { awaitingInput: false, since: null }; + try { + const cto = (await listIdentitySessions("cto"))[0]; + if (!cto) return idle; + const handRaisedAt = sessionService.get(cto.sessionId)?.attentionRequestedAt ?? null; + const awaitingInput = Boolean(cto.awaitingInput || cto.pendingInputItemId || handRaisedAt); + if (!awaitingInput) return idle; + return { awaitingInput: true, since: handRaisedAt ?? cto.lastActivityAt ?? null }; + } catch (error) { + // A probe failure must not break the caller; the renderer keeps its last + // known state rather than falsely clearing a pending question. + logger.warn("agent_chat.cto_attention_probe_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return idle; + } + }; + const ensureIdentitySession = async (args: { identityKey: AgentChatIdentityKey; laneId: string; @@ -38086,10 +38207,7 @@ export function createAgentChatService(args: { requestedLaneId, canonicalLaneId, ); - const existing = await listSessions(undefined, { includeIdentity: true }); - const identitySessions = existing - .filter((entry) => entry.identityKey === args.identityKey) - .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt)); + const identitySessions = await listIdentitySessions(args.identityKey); const canonicalExisting = args.reuseExisting === false ? null @@ -38222,6 +38340,12 @@ export function createAgentChatService(args: { refreshReconstructionContext(managed); await refreshHeadShaStartForManagedExecutionLane(managed); persistChatState(managed); + // Only a brand-new CTO thread is blank, so this is the one place the + // opening turn belongs. Fire-and-forget: session creation must not block on + // a model round-trip, and a failed intro is logged, not fatal. + if (args.identityKey === "cto") { + void seedCtoIntroTurn(managed.session.id).catch(() => {}); + } return managed.session; }; @@ -42627,6 +42751,7 @@ export function createAgentChatService(args: { getChatEventHistory, getChatEventHistoryPage, ensureIdentitySession, + getCtoAttention, approveToolUse, respondToInput, dismissPendingInputForSettlement, diff --git a/apps/desktop/src/main/services/cto/ctoPromptContent.ts b/apps/desktop/src/main/services/cto/ctoPromptContent.ts index f4ed1cc8fe..33ead43d8a 100644 --- a/apps/desktop/src/main/services/cto/ctoPromptContent.ts +++ b/apps/desktop/src/main/services/cto/ctoPromptContent.ts @@ -37,6 +37,26 @@ const previewDeps = { previewSessionToolNames: () => [], } as unknown as ToolPreviewDeps; +/** + * Onboarding step id that records the CTO's opening turn. Not a user-facing + * setup step — it lives in the same list so it is persisted and so + * `resetOnboarding` clears it alongside the rest. + */ +export const CTO_INTRO_ONBOARDING_STEP = "intro"; + +/** + * The first message in a brand-new CTO thread, sent as a real, visible user + * turn. It is deliberately not hidden: ADE has no hidden-turn mechanism, and a + * canned assistant message would be a fabricated transcript entry that then + * feeds back into the model's context on every later turn. A visible prompt is + * honest about what happened and costs nothing extra. + */ +export const CTO_INTRO_PROMPT = [ + "Introduce yourself: who you are, what you can do for me in this project, and how your memory works across model switches.", + "Then scan the project and tell me what you actually see — lanes, open PRs, anything blocked — and what you would look at first.", + "Keep it short.", +].join(" "); + function compactDescription(description: string): string { return description .replace(/\s+/g, " ") @@ -62,7 +82,9 @@ export function buildCtoCapabilityManifest(): string { "- UI navigation is suggestion-only. When an action should open in ADE, return an explicit navigation suggestion instead of silently switching tabs.", "- Treat ADE as your operating environment. Do not describe yourself as blocked on renderer button clicks when an internal tool can do the work.", "- When multiple tools exist for similar purposes, prefer the higher-level one (e.g., createPrFromLane over manual git commands).", - "- Always default laneId to the CTO's current lane if the user doesn't specify one.", + "- Never launch implementation work on your own lane. Your session is pinned to the project's primary lane, and agents working there would write straight to the primary worktree.", + "- When the user does not name a lane, let the work get a dedicated lane: call spawnChat without laneId (it creates one), or createLane first and pass that id. Only pass laneId when the user named an existing lane.", + "- Read-only inspection (status, listing chats, reading files, git status) may target any lane, including your own.", "- For model-specific requests, always resolve the user's model name to the full modelId before calling spawnChat.", ].join("\n"); } diff --git a/apps/desktop/src/main/services/cto/ctoState.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts index ef24a2a618..18cca0a62c 100644 --- a/apps/desktop/src/main/services/cto/ctoState.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { buildAdeGitignore } from "../../../shared/adeLayout"; import { openKvDb } from "../state/kvDb"; import { createCtoStateService } from "./ctoStateService"; +import { buildCtoCapabilityManifest } from "./ctoPromptContent"; function createLogger() { return { @@ -308,4 +309,26 @@ describe("ctoStateService", () => { fixture.db.close(); }); + + // The capability manifest is the CTO's live lane-routing lever: its operator + // tool bodies are not registered on a running session, so the prompt is what + // actually steers where CTO-launched work lands. It used to instruct + // "always default laneId to the CTO's current lane" — the CTO's lane is the + // project's primary lane, so every agent it launched ran against the primary + // worktree. + it("keeps CTO-launched work off the CTO's own lane", () => { + const manifest = buildCtoCapabilityManifest(); + + expect(manifest).not.toMatch(/default laneId to the CTO's current lane/i); + expect(manifest).toMatch(/never launch implementation work on your own lane/i); + expect(manifest).toMatch(/primary lane/i); + }); + + it("generates the manifest from the registered operator tool surface", () => { + const manifest = buildCtoCapabilityManifest(); + + expect(manifest).toContain("spawnChat"); + expect(manifest).toContain("createLane"); + expect(manifest).toContain("# Operating Rules"); + }); }); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index bddc9b2453..66baa1f5f3 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -533,6 +533,7 @@ import type { SyncPeerDeviceType, SyncRoleSnapshot, SyncTransferReadiness, + CtoAttentionState, CtoGetStateArgs, CtoEnsureSessionArgs, CtoUpdateIdentityArgs, @@ -10265,6 +10266,20 @@ export function registerIpc({ return ctx.ctoStateService.getSnapshot(arg.recentLimit ?? 20); }); + /** + * Read-only: is the CTO thread blocked on the user? The CTO chat is hidden + * from every session roster, so it never reaches the Work attention dot or + * the dock badge. This is a pure read — it must not call ensureIdentitySession, + * which would materialize a primary lane and a chat session as a side effect + * of rendering a badge. + */ + ipcMain.handle(IPC.ctoGetAttention, async (): Promise => { + const service = getCtx().agentChatService; + return service + ? await service.getCtoAttention() + : { awaitingInput: false, since: null }; + }); + ipcMain.handle(IPC.ctoEnsureSession, async (_event, arg: CtoEnsureSessionArgs = {}): Promise => { const ctx = getCtx(); requireAppContextServices(ctx, ["agentChatService"] as const); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 32775348fa..3b5a48248b 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -286,6 +286,7 @@ import type { CtoStartLinearOAuthResult, CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, + CtoAttentionState, CtoRunProjectScanResult, LinearConnectionStatus, CtoSetLinearTokenArgs, @@ -2601,6 +2602,7 @@ declare global { args: CtoGetLinearOAuthSessionArgs, ) => Promise; runProjectScan: () => Promise; + getAttention: () => Promise; }; updateCheckForUpdates: () => Promise; updateGetState: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index cc27e3d596..fe9006dd0d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -155,6 +155,7 @@ import type { CtoStartLinearOAuthResult, CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, + CtoAttentionState, CtoRunProjectScanResult, LinearConnectionStatus, CtoSetLinearOAuthClientArgs, @@ -9754,6 +9755,10 @@ contextBridge.exposeInMainWorld("ade", { callProjectRuntimeActionOr("cto_state", "runProjectScan", {}, () => ipcRenderer.invoke(IPC.ctoRunProjectScan), ), + getAttention: async (): Promise => + callProjectRuntimeActionOr("cto_state", "getAttention", {}, () => + ipcRenderer.invoke(IPC.ctoGetAttention), + ), }, updateCheckForUpdates: () => ipcRenderer.invoke(IPC.updateCheckForUpdates), updateGetState: (): Promise => diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index a85bc363b7..668aca1503 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -82,6 +82,7 @@ import { WebAnalyticsConsentBanner, } from "../analytics/ProductAnalyticsLifecycle"; import { useAppWideSessionAttention } from "../../hooks/useAppWideSessionAttention"; +import { useCtoAttention } from "../../hooks/useCtoAttention"; import { useAttentionSync } from "../attention/useAttentionSync"; type PrToast = { @@ -358,6 +359,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const isWorkAdjacentRoute = isWorkRoute || isLanesRoute; const isLanesRouteRef = useRef(isLanesRoute); useAppWideSessionAttention(); + useCtoAttention(); useAttentionSync(isAttentionRoute); useEffect(() => { diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index 7cc5a96db8..6d48a083b9 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -101,6 +101,10 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) const projectBinding = useAppStore((s) => s.projectBinding); const showWelcome = useAppStore((s) => s.showWelcome); const terminalAttention = useAppStore((s) => s.terminalAttention); + const ctoAttention = useAppStore((s) => s.ctoAttention); + const ctoWaitingLabel = ctoAttention.since + ? `The CTO is waiting on you — since ${new Date(ctoAttention.since).toLocaleTimeString()}` + : "The CTO is waiting on you"; const location = useLocation(); const { status: accountStatus } = useAccountStatus(); const activeProjectRoot = @@ -251,6 +255,15 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) )} /> ) : null} + {/* CTO attention dot. The CTO thread is hidden from every session + roster, so it cannot borrow the Work dot above — without this + a question from the CTO would surface nowhere. */} + {it.to === "/cto" && ctoAttention.awaitingInput ? ( + + ) : null} diff --git a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts index 44721f3771..f711d36370 100644 --- a/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts +++ b/apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts @@ -20,6 +20,11 @@ export function useAppWideSessionAttention(): void { const currentProjectRoot = useAppStore(selectActiveProjectRoot); const showWelcome = useAppStore((state) => state.showWelcome); const setTerminalAttention = useAppStore((state) => state.setTerminalAttention); + // The CTO thread is hidden from the session list this hook summarizes, so it + // must be added to the dock badge explicitly or a CTO question would never + // reach a minimized window. Kept in this hook so `setDockBadgeCount` keeps a + // single writer. + const ctoAwaitingInput = useAppStore((state) => state.ctoAttention.awaitingInput); const lastDockBadgeCountRef = useRef(null); const trackedProjectRoot = showWelcome ? null : currentProjectRoot; @@ -59,11 +64,12 @@ export function useAppWideSessionAttention(): void { if (cancelled) return; const attention = summarizeTerminalAttention(sessions); setTerminalAttention(attention); + const badgeCount = attention.needsAttentionCount + (ctoAwaitingInput ? 1 : 0); // Dock badge mirrors the loud tier only; push on change so a blocked // agent reaches the user even with the window minimized. - if (lastDockBadgeCountRef.current !== attention.needsAttentionCount) { - lastDockBadgeCountRef.current = attention.needsAttentionCount; - void window.ade?.app?.setDockBadgeCount?.(attention.needsAttentionCount)?.catch?.(() => {}); + if (lastDockBadgeCountRef.current !== badgeCount) { + lastDockBadgeCountRef.current = badgeCount; + void window.ade?.app?.setDockBadgeCount?.(badgeCount)?.catch?.(() => {}); } } catch { // best effort @@ -136,5 +142,5 @@ export function useAppWideSessionAttention(): void { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [trackedProjectRoot, setTerminalAttention]); + }, [trackedProjectRoot, setTerminalAttention, ctoAwaitingInput]); } diff --git a/apps/desktop/src/renderer/hooks/useCtoAttention.ts b/apps/desktop/src/renderer/hooks/useCtoAttention.ts new file mode 100644 index 0000000000..6a253382cd --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useCtoAttention.ts @@ -0,0 +1,111 @@ +import { useEffect } from "react"; +import type { CtoAttentionState } from "../../shared/types"; +import { shouldRefreshSessionListForChatEvent } from "../lib/chatSessionEvents"; +import { selectActiveProjectRoot, useAppStore } from "../state/appStore"; + +const IDLE: CtoAttentionState = { awaitingInput: false, since: null }; + +/** + * Keeps the CTO tab's "needs you" dot fresh. + * + * The CTO chat is deliberately excluded from every lane/session roster, which + * also excludes it from `terminalAttention` and the dock badge. Without this + * hook a hidden thread could ask a question and show nothing anywhere — the + * exact failure mode hiding the row introduces. It is a pure read: the probe + * never creates a CTO session or a primary lane. + */ +export function useCtoAttention(): void { + const currentProjectRoot = useAppStore(selectActiveProjectRoot); + const showWelcome = useAppStore((state) => state.showWelcome); + const setCtoAttention = useAppStore((state) => state.setCtoAttention); + const trackedProjectRoot = showWelcome ? null : currentProjectRoot; + + useEffect(() => { + if (!trackedProjectRoot) { + setCtoAttention(IDLE); + return; + } + + let cancelled = false; + let inFlight = false; + let queued = false; + let timer: number | null = null; + let dueAt = 0; + + // Clear immediately on project switch so the previous project's CTO state + // cannot linger on the tab while the first probe is in flight. + setCtoAttention(IDLE); + + const refresh = async () => { + if (cancelled) return; + if (inFlight) { + queued = true; + return; + } + inFlight = true; + try { + const next = await window.ade?.cto?.getAttention?.(); + if (cancelled || !next) return; + setCtoAttention(next); + } catch { + // Best effort: a failed probe leaves the last known state rather than + // falsely clearing a pending question. + } finally { + inFlight = false; + if (!cancelled && queued) { + queued = false; + schedule(250); + } + } + }; + + // An already-pending timer must not swallow a sooner request: window focus + // asks for an immediate refresh, and dropping it leaves the dot stale for + // the remainder of the debounce. + const schedule = (delayMs = 1_500) => { + if (cancelled) return; + const nextDueAt = Date.now() + delayMs; + if (timer != null) { + if (dueAt <= nextDueAt) return; + window.clearTimeout(timer); + } + dueAt = nextDueAt; + timer = window.setTimeout(() => { + timer = null; + dueAt = 0; + void refresh(); + }, delayMs); + }; + + schedule(0); + + // A CTO question arrives as a chat event on its own session, so we cannot + // filter by lane here. We can filter by *kind*: the probe runs a full + // identity-session scan in main, so arming it on every streaming delta + // would burn main-process CPU for a whole turn. This is the same predicate + // the Work-tab attention hook uses, and it admits exactly the event types + // that can change `awaitingInput`. + const unsubscribeChat = window.ade?.agentChat?.onEvent?.((event) => { + if (!shouldRefreshSessionListForChatEvent(event)) return; + schedule(); + }); + const interval = window.setInterval(() => { + if (document.visibilityState !== "visible") return; + schedule(); + }, 15_000); + const onFocus = () => schedule(0); + window.addEventListener("focus", onFocus); + + return () => { + cancelled = true; + try { + unsubscribeChat?.(); + } catch { + // ignore + } + if (timer != null) window.clearTimeout(timer); + window.clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; + }, [trackedProjectRoot, setCtoAttention]); +} diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 9f66317d4b..83e201feed 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -2,7 +2,7 @@ import React, { createContext, useContext, type ReactNode } from "react"; import { useStore } from "zustand"; import { createStore, type StoreApi } from "zustand/vanilla"; import type { StateCreator } from "zustand"; -import type { KeybindingsSnapshot, LaneDeleteProgress, LaneListSnapshot, LaneSummary, OpenProjectBinding, ProjectInfo, ProjectPathInspection, ProviderMode, RecentProjectSummary, TerminalSessionSummary } from "../../shared/types"; +import type { CtoAttentionState, KeybindingsSnapshot, LaneDeleteProgress, LaneListSnapshot, LaneSummary, OpenProjectBinding, ProjectInfo, ProjectPathInspection, ProviderMode, RecentProjectSummary, TerminalSessionSummary } from "../../shared/types"; import { recentProjectStateKey } from "../../shared/projectIdentity"; import { THIS_MACHINE_ID } from "../../shared/machineIdentity"; import { MODEL_REGISTRY, type ModelDescriptor } from "../../shared/modelRegistry"; @@ -231,6 +231,16 @@ const EMPTY_TERMINAL_ATTENTION: TerminalAttentionSnapshot = { byLaneId: {} }; +/** + * The CTO chat is hidden from every session roster, so it never contributes to + * `terminalAttention`. This is its own signal so a hidden thread cannot ask a + * question silently. + */ +const EMPTY_CTO_ATTENTION: CtoAttentionState = { + awaitingInput: false, + since: null +}; + const WORK_VIEW_STORAGE_KEY = "ade.workViewState.v1"; const TERMINAL_PREFERENCES_STORAGE_KEY = "ade.terminalPreferences.v1"; const USER_PREFERENCES_STORAGE_KEY = "ade.userPreferences.v1"; @@ -1087,6 +1097,7 @@ export type AppState = { laneInspectorTabs: Record; keybindings: KeybindingsSnapshot | null; terminalAttention: TerminalAttentionSnapshot; + ctoAttention: CtoAttentionState; smartTooltipsEnabled: boolean; onboardingEnabled: boolean; didYouKnowEnabled: boolean; @@ -1227,6 +1238,7 @@ export type AppState = { | ((prev: TerminalPreferences) => TerminalPreferences) ) => void; setTerminalAttention: (snapshot: TerminalAttentionSnapshot) => void; + setCtoAttention: (snapshot: CtoAttentionState) => void; setSmartTooltipsEnabled: (enabled: boolean) => void; setOnboardingEnabled: (enabled: boolean) => void; setDidYouKnowEnabled: (enabled: boolean) => void; @@ -1484,6 +1496,7 @@ const createAppState: StateCreator = (set, get) => { laneInspectorTabs: {}, keybindings: null, terminalAttention: EMPTY_TERMINAL_ATTENTION, + ctoAttention: EMPTY_CTO_ATTENTION, smartTooltipsEnabled: initialUserPreferences.smartTooltipsEnabled, onboardingEnabled: initialUserPreferences.onboardingEnabled, didYouKnowEnabled: initialUserPreferences.didYouKnowEnabled, @@ -1575,6 +1588,7 @@ const createAppState: StateCreator = (set, get) => { ? { laneInspectorTabs: {}, terminalAttention: EMPTY_TERMINAL_ATTENTION, + ctoAttention: EMPTY_CTO_ATTENTION, } : {}), // Lane data is replaced on a project change, and additionally restored @@ -1926,6 +1940,7 @@ const createAppState: StateCreator = (set, get) => { return { terminalPreferences: updated }; }), setTerminalAttention: (terminalAttention) => set({ terminalAttention }), + setCtoAttention: (ctoAttention) => set({ ctoAttention }), setSmartTooltipsEnabled: (enabled) => set((prev) => { persistUserPreferencesFrom({ ...prev, smartTooltipsEnabled: enabled }); @@ -2346,6 +2361,7 @@ const createAppState: StateCreator = (set, get) => { laneInspectorTabs: {}, keybindings: null, terminalAttention: EMPTY_TERMINAL_ATTENTION, + ctoAttention: EMPTY_CTO_ATTENTION, dismissedMissingAiBannerRoots: pickDismissMapForRoots(prev.dismissedMissingAiBannerRoots, [project.rootPath]), dismissedGithubBannerRoots: pickDismissMapForRoots(prev.dismissedGithubBannerRoots, [project.rootPath]), }; @@ -2444,6 +2460,7 @@ const createAppState: StateCreator = (set, get) => { laneInspectorTabs: {}, keybindings: null, terminalAttention: EMPTY_TERMINAL_ATTENTION, + ctoAttention: EMPTY_CTO_ATTENTION, } : {}), ...(outgoingProjectKey @@ -2491,6 +2508,7 @@ const createAppState: StateCreator = (set, get) => { laneInspectorTabs: {}, keybindings: null, terminalAttention: EMPTY_TERMINAL_ATTENTION, + ctoAttention: EMPTY_CTO_ATTENTION, }); invalidateAiDiscoveryCache(rootPath); invalidateProjectConfigCache(rootPath); @@ -2676,6 +2694,7 @@ const createAppState: StateCreator = (set, get) => { laneInspectorTabs: {}, keybindings: null, terminalAttention: EMPTY_TERMINAL_ATTENTION, + ctoAttention: EMPTY_CTO_ATTENTION, laneCacheByProject, }; }); @@ -2739,6 +2758,7 @@ const createAppState: StateCreator = (set, get) => { laneInspectorTabs: {}, keybindings: null, terminalAttention: EMPTY_TERMINAL_ATTENTION, + ctoAttention: EMPTY_CTO_ATTENTION, openProjectTabRoots: [], openRemoteProjectTabs: [], // No active project: drop every dismiss entry so reopening the same project later starts with a clean slate. diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index e62332523d..aa3cb5153c 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -791,6 +791,7 @@ export const IPC = { usageSaveBudgetConfig: "ade.usage.saveBudgetConfig", usageEvent: "ade.usage.event", ctoGetState: "ade.cto.getState", + ctoGetAttention: "ade.cto.getAttention", ctoEnsureSession: "ade.cto.ensureSession", ctoListSessionLogs: "ade.cto.listSessionLogs", ctoUpdateIdentity: "ade.cto.updateIdentity", diff --git a/apps/desktop/src/shared/types/cto.ts b/apps/desktop/src/shared/types/cto.ts index eb06b23096..194671a18a 100644 --- a/apps/desktop/src/shared/types/cto.ts +++ b/apps/desktop/src/shared/types/cto.ts @@ -290,3 +290,17 @@ export type CtoSearchMemoryResult = { query: string; rows: CtoMemorySearchRow[]; }; + +/* ── Attention ── */ + +/** + * Whether the CTO thread is blocked on the user. The CTO chat is deliberately + * hidden from every lane/session roster, so it cannot borrow the Work tab's + * attention dot — this is the one signal that keeps a hidden thread from going + * silent when it asks a question. + */ +export type CtoAttentionState = { + awaitingInput: boolean; + /** When the thread started waiting; null when it is not waiting. Tooltip copy. */ + since: string | null; +}; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 261123a513..7ee56e9358 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1118,6 +1118,19 @@ struct CtoOnboardingState: Codable, Hashable { var isComplete: Bool { completedAt != nil || completedSteps.contains("identity") } + + /// Steps to send when marking setup complete from this device. + /// + /// `cto.updateIdentity` replaces `onboardingState` wholesale, and the desktop + /// keeps non-user markers in this same list (e.g. `"intro"`, recording that + /// the CTO's opening turn was already sent). Sending a bare `["identity"]` + /// would erase those and make the host redo work it had already done, so the + /// required step is unioned into whatever the host already recorded. + static func stepsCompletingSetup(existing: [String]?) -> [String] { + var steps = existing ?? [] + if !steps.contains("identity") { steps.append("identity") } + return steps + } } /// Mirrors desktop `CtoIdentity`. The server has no top-level `id`; we diff --git a/apps/ios/ADE/Views/Cto/CtoSetup.swift b/apps/ios/ADE/Views/Cto/CtoSetup.swift index 62218abb46..07c9e663fb 100644 --- a/apps/ios/ADE/Views/Cto/CtoSetup.swift +++ b/apps/ios/ADE/Views/Cto/CtoSetup.swift @@ -311,8 +311,12 @@ struct CtoOnboardingScreen: View { // Mark onboarding complete. Desktop's only required step is "identity"; // stamp completedAt too so older merge paths that don't re-derive it still // read as complete. + // + // Union rather than replace — see `stepsCompletingSetup`. patch.onboardingState = CtoOnboardingState( - completedSteps: ["identity"], + completedSteps: CtoOnboardingState.stepsCompletingSetup( + existing: snapshot?.identity.onboardingState?.completedSteps + ), dismissedAt: nil, completedAt: ISO8601DateFormatter().string(from: Date()) ) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index eaabfa8f38..e15066c79d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -16810,6 +16810,23 @@ final class ADETests: XCTestCase { XCTAssertTrue(viaTimestamp.isComplete) } + func testCtoSetupCompletionPreservesHostOnboardingMarkers() { + // The host records non-user steps here (e.g. "intro", meaning the CTO's + // opening turn was already sent) and updateIdentity replaces the whole + // object, so completing setup from iOS must not drop them. + XCTAssertEqual( + CtoOnboardingState.stepsCompletingSetup(existing: ["intro"]), + ["intro", "identity"] + ) + XCTAssertEqual(CtoOnboardingState.stepsCompletingSetup(existing: nil), ["identity"]) + XCTAssertEqual(CtoOnboardingState.stepsCompletingSetup(existing: []), ["identity"]) + // Idempotent: re-saving setup must not duplicate the required step. + XCTAssertEqual( + CtoOnboardingState.stepsCompletingSetup(existing: ["identity", "intro"]), + ["identity", "intro"] + ) + } + func testCtoOnboardingDismissedOnDesktopDoesNotBlockIosTab() { func identity(_ state: CtoOnboardingState?) -> CtoIdentity { CtoIdentity( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index af624a4056..2cd1ee8403 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -668,7 +668,10 @@ ade.github.* # PR list, review, merge, checks. Also exposes ade.prs.* # stacked PR queue, integration, rebase/issue # resolver sessions, and merge readiness ade.conflicts.* # risk matrix, simulation, proposals -ade.cto.* # identity, agent roster, Linear +ade.cto.* # identity, agent roster, Linear, and the read-only + # `ctoGetAttention` probe (the CTO thread is hidden + # from every session roster, so it needs its own + # "needs you" signal) ade.sessions.* # terminal session CRUD ade.files.* # runtime-routed file workspace/tree/read/write/watch/search actions, # including paginated children, Git decorations, range reads, @@ -919,7 +922,7 @@ Enforced rules (from the stability overhaul): 2. New integrations are dormant-until-configured. 3. Feature pages stage data: cheapest (list/summary/topology) first, heavy (dashboard/settings/model metadata/overlays) on delay. 4. Never mount expensive trees eagerly — settings dialogs, advanced launcher sections unmount when closed. -5. Renderer polling is route-scoped except application-wide session attention. `useAppWideSessionAttention` stays mounted in `AppShell` across Work, Files, PRs, and other project routes; it refreshes on PTY/chat/session events, focus, and a visible-window 15-second recovery interval so a `Needs you` transition can update the global highlight and Dock badge off the Work route. Project-switch/close cleanup generation-guards stale async results. Lane panels still poll only while live sessions exist. The plain PR list does not fire a GitHub refresh on mount, renders active-repository PR snapshots only, skips conflict analysis, and defers rebase-needs / auto-rebase polling until the user opens a workflow tab or selects a PR. Selected PR detail reads apply progressively so slow comments or action-run hydration do not block status/checks/files from painting. Workflow PR views batch merge contexts and conflict analysis against metadata-only lane rows instead of running per-PR git/status work. The Lanes page reuses the `LaneSummary.autoRebaseStatus` snapshot already in the lane list instead of probing per-lane on `LaneGitActionsPane` mount; a fallback probe runs only when the snapshot is missing and after a visibility-gated 3.5 s delay. Run's `LaneRuntimeBar` keeps health/process refreshes separate from preview routing / port / OAuth refreshes so process events do not reread routing state. The Work top-bar sync chip refreshes on focus and on `sync-status` events instead of a 5 s interval. The chat composer's Cursor model inventory is fetched lazily — `ProviderModelSelector` calls `onOpen` on first open of the model catalog, and `AgentChatPane.refreshCursorModelInventory` is the only entry point that hits `cursor` with `activateRuntime: true`. +5. Renderer polling is route-scoped except application-wide session attention. `useAppWideSessionAttention` stays mounted in `AppShell` across Work, Files, PRs, and other project routes; it refreshes on PTY/chat/session events, focus, and a visible-window 15-second recovery interval so a `Needs you` transition can update the global highlight and Dock badge off the Work route. `useCtoAttention` sits beside it on the same cadence for the roster-hidden CTO thread, and debounces its probe behind `shouldRefreshSessionListForChatEvent` so a streaming turn does not re-run a full identity-session scan in main per delta. Project-switch/close cleanup generation-guards stale async results. Lane panels still poll only while live sessions exist. The plain PR list does not fire a GitHub refresh on mount, renders active-repository PR snapshots only, skips conflict analysis, and defers rebase-needs / auto-rebase polling until the user opens a workflow tab or selects a PR. Selected PR detail reads apply progressively so slow comments or action-run hydration do not block status/checks/files from painting. Workflow PR views batch merge contexts and conflict analysis against metadata-only lane rows instead of running per-PR git/status work. The Lanes page reuses the `LaneSummary.autoRebaseStatus` snapshot already in the lane list instead of probing per-lane on `LaneGitActionsPane` mount; a fallback probe runs only when the snapshot is missing and after a visibility-gated 3.5 s delay. Run's `LaneRuntimeBar` keeps health/process refreshes separate from preview routing / port / OAuth refreshes so process events do not reread routing state. The Work top-bar sync chip refreshes on focus and on `sync-status` events instead of a 5 s interval. The chat composer's Cursor model inventory is fetched lazily — `ProviderModelSelector` calls `onOpen` on first open of the model catalog, and `AgentChatPane.refreshCursorModelInventory` is the only entry point that hits `cursor` with `activateRuntime: true`. 6. Shared caches for high-frequency calls (`sessionListCache`, GitHub fingerprint-based snapshots, and the renderer's project-scoped `aiDiscoveryCache`). ModelPicker provider-auth reads join the cache's single in-flight `ade.ai.getStatus` request and react to cache update/invalidation events; picker instances do not poll, and call sites that already supply auth status skip the full-status read. 7. Memoize expensive renderer computations (`useMemo`, `React.memo`); isolate frequently-refreshing subtrees (e.g., budget footers). 8. `Promise.allSettled` over `Promise.all` for parallel startup — one failing service must not block others. @@ -1030,6 +1033,16 @@ that count before forwarding it to Electron's `app.setBadgeCount`. Project switch/close cancels the old refresh and clears its count. Quiet ready, idle, stale, ended, and settled rows never contribute to the Dock badge. +The CTO thread is the one exception to "canonical projected session rows are the +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 +[features/cto/README.md](./features/cto/README.md#hidden-from-rosters-but-never-silent). + ### 8.3 ADE CLI auth + API-key storage - ADE CLI session identity is resolved from env vars and the `initialize` handshake. diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index a84ed8b789..8f2f55a787 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -478,6 +478,20 @@ CTO sessions (`identityKey: "cto"`) are routed differently: 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. + `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 + [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 + on creation, once per project, and is recorded in CTO onboarding + state. +8. Because the thread is hidden from every session roster, its + "needs you" state is read through the dedicated read-only + `getCtoAttention` probe rather than the shared attention summary. `AgentChatIdentityKey` is now just `"cto"` — the `"cto"` thread is the only identity session. The former `"agent:"` worker sessions were diff --git a/docs/features/chat/tool-system.md b/docs/features/chat/tool-system.md index 78496a9002..ee56a612a5 100644 --- a/docs/features/chat/tool-system.md +++ b/docs/features/chat/tool-system.md @@ -123,7 +123,7 @@ uses to act on ADE itself: | Tool family | Purpose | |---|---| -| `spawnChat` | Spawn a new chat session in a specified lane with an explicit model, reasoning effort, and initial prompt. | +| `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. | | `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. | diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index 67930b8a02..dcb34ff4c9 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. +- `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). - `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. @@ -37,7 +37,15 @@ 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, the memory flush hooks, and the reconstruction-context injection (all detailed below). +- `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. + +### Attention surfaces (renderer) + +- `apps/desktop/src/renderer/hooks/useCtoAttention.ts` — the probe loop behind the CTO tab dot. Mounted once in `AppShell.tsx`. +- `apps/desktop/src/renderer/state/appStore.ts` — `ctoAttention` + `setCtoAttention`, reset to idle on every project switch/close alongside `terminalAttention`. +- `apps/desktop/src/renderer/components/app/TabNav.tsx` — renders the warning dot on the `/cto` tab with a "waiting since" tooltip. +- `apps/desktop/src/renderer/hooks/useAppWideSessionAttention.ts` — folds `ctoAttention.awaitingInput` into the dock badge count while remaining the only writer of `setDockBadgeCount`. ### iOS companion (`apps/ios/ADE/Views/Cto/`) @@ -103,6 +111,30 @@ The guarantee is that a deterministic flush always runs before anything can be l `AgentChatIdentityKey` is just `"cto"`. `ensureIdentitySession` reuses the newest CTO session regardless of which lane it was last active on: if nothing lives on the canonical lane but a CTO session exists elsewhere, it reuses that session and rebinds it to the canonical lane instead of forking a parallel thread. There is only ever one CTO thread per project. +### Hidden from rosters, but never silent + +The CTO thread is pinned to the project's **primary lane** (it needs a lane for its cwd), but it is filtered out of every session roster so it never reads as a chat you started: `agentChatService.listSessions` drops identity sessions unless `includeIdentity` is set, and `chatSessionProjection.projectChatSummariesOntoSessions` plus `laneListSnapshotService` drop the backing terminal row before the Work tab, Lanes tab, workspace graph, and TopBar ever see it. `sessions:get` still resolves the id, so deeplinks and `CtoPage` keep working. Universal search deliberately *does* index the thread — it is your own conversation, and it should be findable in ⌘K. + +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. +- 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`. + +### The opening turn + +A brand-new CTO thread used to open on a blank screen. `ensureIdentitySession` now seeds one real, **visible** first user turn when it *creates* the session (`seedCtoIntroTurn`), asking the CTO to introduce itself and give a read on the project. Because `ensureSession` is gated behind onboarding in `CtoPage`, session creation is exactly the blank-thread moment, and seeding there covers desktop, iOS, and the CLI in one place. + +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. + +### 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 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. + ### Session lifecycle tools Session lifecycle is not desktop-only and settle is not the only quiet tier. A @@ -145,7 +177,7 @@ The CTO tab is a single persistent thread plus a settings sheet — there is no Registered in `apps/desktop/src/main/services/ipc/registerIpc.ts`, named in `apps/desktop/src/shared/ipc.ts`, reached from the renderer via `window.ade.cto.*`: -- Thread + identity: `ctoEnsureSession`, `ctoGetState`, `ctoUpdateIdentity`, `ctoListSessionLogs`, `ctoPreviewSystemPrompt`, `ctoRunProjectScan`. +- Thread + identity: `ctoEnsureSession`, `ctoGetState`, `ctoGetAttention`, `ctoUpdateIdentity`, `ctoListSessionLogs`, `ctoPreviewSystemPrompt`, `ctoRunProjectScan`. - Onboarding: `ctoGetOnboardingState`, `ctoCompleteOnboardingStep`, `ctoDismissOnboarding`, `ctoResetOnboarding`. - Memory: `ctoGetMemory`, `ctoUpdateMemory`, `ctoSearchMemory`. - Linear read + credentials/OAuth: `ctoGetLinearConnectionStatus`, `ctoGetLinearProjects`, `ctoGetLinearQuickView`, `ctoGetLinearIssuePickerData`, `ctoSearchLinearIssues`, `ctoGetLinearIssueComments`, `ctoSetLinearToken`, `ctoClearLinearToken`, `ctoStartLinearOAuth`, `ctoGetLinearOAuthSession`, `ctoSetLinearOAuthClient`, `ctoClearLinearOAuthClient`.