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`.