diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 53ff566c6..486b907f4 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1560,7 +1560,7 @@ describe("adeRpcServer", () => { args: { outcome: "Shipped" }, assert: () => expect(runtime.sessionService.settleSession).toHaveBeenCalledWith( "chat-1", - { outcome: "Shipped" }, + { outcome: "Shipped", source: "agent_explicit" }, ), }, { diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 8f6803c3a..f59102e50 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -688,9 +688,28 @@ export async function createAdeRuntime(args: { laneServiceRef = laneService; await laneService.ensurePrimaryLane(); + // Late-bound because the publisher is constructed after the session/PTY + // services. Session changes still use it once publishing is attached. + let pushPublisherForPtySignals: PushPublisherService | null = null; + let ptyServiceForSessionChanges: ReturnType | null = null; const sessionService = createSessionService({ db }); sessionService.onChanged((event) => { pushEvent("runtime", { type: "terminal_session_changed", event }); + const session = sessionService.get(event.sessionId); + const runtimeState = session + ? ptyServiceForSessionChanges?.getRuntimeState(event.sessionId, session.status) + ?? session.runtimeState + : null; + if ( + session + && (session.status !== "running" || runtimeState === "idle") + && ( + session.settleOverride === "settled" + || (session.settleOverride !== "active" && Boolean(session.settledAt)) + ) + ) { + pushPublisherForPtySignals?.handleSessionSettled(projectId, event.sessionId); + } }); const processRegistry = createProcessRegistryService({ db, @@ -900,10 +919,8 @@ export async function createAdeRuntime(args: { // pattern as desktop main. Without this bridge, paired phones only ever // receive terminal snapshots, never live terminal_data push. let syncServiceForPtyEvents: ReturnType | null = null; - // Same late-binding for the push publisher: it feeds tracked CLI runtime - // states (running / waiting-input from OSC 133 markers) into the phone's - // Live Activity, and it's constructed after ptyService. - let pushPublisherForPtySignals: PushPublisherService | null = null; + // The late-bound push publisher feeds tracked CLI runtime states into the + // phone's Live Activity. const ptyService = createPtyService({ projectRoot, transcriptsDir: paths.transcriptsDir, @@ -931,6 +948,9 @@ export async function createAdeRuntime(args: { runtimeState: signal.runtimeState, }); }, + onSessionUserInput: ({ sessionId }) => { + pushPublisherForPtySignals?.handleSessionAttentionResolved(projectId, sessionId); + }, diskPressureMonitor, onSessionEnded: (event) => { void sessionDeltaService.computeSessionDelta(event.sessionId).catch((error) => { @@ -945,6 +965,7 @@ export async function createAdeRuntime(args: { loadPty: ptyBackend ?? (() => nodePty), disposePtyBackend: ptyBackend?.dispose }); + ptyServiceForSessionChanges = ptyService; const testService = createTestService({ db, @@ -1509,6 +1530,10 @@ export async function createAdeRuntime(args: { title: session.title ?? null, toolType: session.toolType ?? null, chatSessionId: session.chatSessionId ?? null, + status: session.status, + runtimeState: session.runtimeState ?? null, + settledAt: session.settledAt ?? null, + settleOverride: session.settleOverride ?? null, }; } catch { return null; diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 9f8f3fe4f..3d870dabf 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -285,7 +285,15 @@ describe("createPushPublisherService flush", () => { flushDebounceMs: 2_000, promptFlushMs: 150, }); - const cliSessions = new Map(); + const cliSessions = new Map(); const detach = publisher.attachSources("scope-1", { agentChatService: agentChatService as never, projectName: "ADE", @@ -1843,7 +1851,7 @@ describe("createPushPublisherService flush", () => { run({ sessionId: "s-1", kind: "chat", phase: "waiting_for_input" }), ])).toBe(1); const waitingRun = second.liveActivity[0].contentState.runs.find((r: { id: string }) => r.id === "cli-1"); - expect(waitingRun.phase).toBe("waiting_for_input"); + expect(waitingRun.phase).toBe("stale"); publisher.dispose(); }); @@ -1875,6 +1883,27 @@ describe("createPushPublisherService flush", () => { detail: "Which account should the e2e test use?", }); + publisher.handleCliRuntimeSignal("scope-1", { + laneId: "auth-lane", + sessionId: "cli-ask-1", + runtimeState: "idle", + }); + await vi.advanceTimersByTimeAsync(200); + const heartbeatPayload = publish.mock.calls.at(-1)?.[0]; + expect(heartbeatPayload.liveActivity[0].contentState.runs[0]).toMatchObject({ + id: "cli-ask-1", + phase: "waiting_for_input", + }); + + publisher.handleSessionAttentionResolved("scope-1", "cli-ask-1"); + expect(publisher._debug.getPendingAlerts()).toEqual([]); + await vi.advanceTimersByTimeAsync(200); + const resolvedPayload = publish.mock.calls.at(-1)?.[0]; + expect(resolvedPayload.liveActivity[0].contentState.runs[0]).toMatchObject({ + id: "cli-ask-1", + phase: "running", + }); + publisher.dispose(); }); @@ -1916,6 +1945,45 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("terminates a waiting CLI run when dismiss-and-settle resolves attention", async () => { + const { publisher, cliSessions } = makeHarness(); + await publisher.start(); + + publisher.handleSessionAttentionRequested("scope-1", { + sessionId: "cli-settle-1", + kind: "cli", + title: "Fix auth race", + message: "Choose an account", + laneId: "auth-lane", + }); + publisher.handleSessionSettled("scope-1", "cli-settle-1"); + + expect(publisher._debug.getPendingAlerts()).toEqual([]); + expect(publisher._debug.runs.has("cli-settle-1")).toBe(false); + + cliSessions.set("cli-settle-1", { + title: "Fix auth race", + toolType: "codex", + status: "running", + settledAt: "2026-07-29T22:00:00.000Z", + }); + publisher.handleCliRuntimeSignal("scope-1", { + laneId: "auth-lane", + sessionId: "cli-settle-1", + runtimeState: "running", + }); + expect(publisher._debug.runs.get("cli-settle-1")?.phase).toBe("running"); + + publisher.handleCliRuntimeSignal("scope-1", { + laneId: "auth-lane", + sessionId: "cli-settle-1", + runtimeState: "idle", + }); + expect(publisher._debug.runs.has("cli-settle-1")).toBe(false); + + publisher.dispose(); + }); + it("drops chat-owned shells and unknown sessions from CLI run tracking", async () => { const { publisher, publish, emit, cliSessions } = makeHarness(); cliSessions.set("shell-1", { title: "attached shell", toolType: "shell", chatSessionId: "s-1" }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index ec30d9900..6b4b73582 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -196,6 +196,10 @@ export type PushPublisherSources = { title: string | null; toolType?: string | null; chatSessionId?: string | null; + status?: string | null; + runtimeState?: string | null; + settledAt?: string | null; + settleOverride?: "settled" | "active" | null; } | null; }; @@ -932,6 +936,19 @@ export function createPushPublisherService(deps: PushPublisherDeps) { runs.delete(run.sessionId); continue; } + const atRest = record.status !== "running" || record.runtimeState === "idle"; + if ( + atRest + && ( + record.settleOverride === "settled" + || (record.settleOverride !== "active" && record.settledAt) + ) + ) { + run.phase = "completed"; + recentRuns.set(run.sessionId, { ...run }); + runs.delete(run.sessionId); + continue; + } run.title = record.title?.trim() || run.title || null; run.agent = providerDisplayName(record.toolType) ?? run.agent ?? "CLI"; run.metaResolved = true; @@ -1548,22 +1565,49 @@ export function createPushPublisherService(deps: PushPublisherDeps) { }; /** - * OSC 133-derived terminal state for tracked CLI sessions. Feeds the Live - * Activity only — no alert pushes: a CLI agent returns to its prompt - * (waiting-input) after EVERY turn, so alerting on it would ping the user - * once per turn. Failure alerts stay with onPtyExit's non-zero-exit path. + * Terminal runtime state for tracked CLI sessions. Prompt/marker inference + * must never raise attention: only an explicit `ade chat ask` or a + * provider-structured pending input may publish `waiting_for_input`. */ const onCliRuntimeSignal = (scopeKey: string, signal: PushCliRuntimeSignal): void => { if (disposed || !signal.sessionId) return; const existing = runs.get(signal.sessionId); + const session = scopes.get(scopeKey)?.resolveCliSession?.(signal.sessionId) ?? null; + const atRest = session?.status !== "running" || signal.runtimeState === "idle"; + if ( + atRest + && ( + session?.settleOverride === "settled" + || (session?.settleOverride !== "active" && Boolean(session?.settledAt)) + ) + ) { + if (existing) { + existing.phase = "completed"; + existing.itemId = null; + markRunUpdated(existing); + recentRuns.set(signal.sessionId, { ...existing }); + runs.delete(signal.sessionId); + pendingAlerts = pendingAlerts.filter( + (alert) => + alert.dedupeKey !== `alert:${signal.sessionId}:approval` + && alert.dedupeKey !== `alert:${signal.sessionId}:question`, + ); + clearAlertDedupe(`alert:${signal.sessionId}:approval`); + clearAlertDedupe(`alert:${signal.sessionId}:question`); + scheduleFlush(true); + } + return; + } // Exit/kill phases are owned by onPtyExit (which knows the exit code). if (signal.runtimeState === "exited" || signal.runtimeState === "killed") return; + // Explicit/provider-structured attention owns this phase until the + // lifecycle event that resolves it. PTY heartbeats are observational and + // must not erase a real request. + if (existing?.phase === "waiting_for_input" || existing?.phase === "waiting_for_approval") return; // `idle` = no output for 12s with no OSC prompt marker — we can't prove // the CLI is working OR at a prompt, so publish it as `stale` (dimmed, // not counted active) instead of overstating it as a live running row. - const phase: AgentRunPhase = signal.runtimeState === "waiting-input" - ? "waiting_for_input" - : signal.runtimeState === "idle" + const phase: AgentRunPhase = signal.runtimeState === "waiting-input" || signal.runtimeState === "idle" ? "stale" : "running"; // Signals re-fire on a ~10s heartbeat; only a phase change is worth a @@ -1886,6 +1930,43 @@ export function createPushPublisherService(deps: PushPublisherDeps) { scheduleFlush(true); }, + handleSessionAttentionResolved(scopeKey: string | null, sessionId: string): void { + if (disposed || !sessionId) return; + const run = runs.get(sessionId); + if (!run || (scopeKey != null && run.scopeKey !== scopeKey)) return; + if (run.phase !== "waiting_for_input" && run.phase !== "waiting_for_approval") return; + run.phase = "running"; + run.itemId = null; + markRunUpdated(run); + pendingAlerts = pendingAlerts.filter( + (alert) => + alert.dedupeKey !== `alert:${sessionId}:approval` + && alert.dedupeKey !== `alert:${sessionId}:question`, + ); + clearAlertDedupe(`alert:${sessionId}:approval`); + clearAlertDedupe(`alert:${sessionId}:question`); + scheduleFlush(true); + }, + + handleSessionSettled(scopeKey: string | null, sessionId: string): void { + if (disposed || !sessionId) return; + const run = runs.get(sessionId); + if (!run || (scopeKey != null && run.scopeKey !== scopeKey)) return; + run.phase = "completed"; + run.itemId = null; + markRunUpdated(run); + recentRuns.set(sessionId, { ...run }); + runs.delete(sessionId); + pendingAlerts = pendingAlerts.filter( + (alert) => + alert.dedupeKey !== `alert:${sessionId}:approval` + && alert.dedupeKey !== `alert:${sessionId}:question`, + ); + clearAlertDedupe(`alert:${sessionId}:approval`); + clearAlertDedupe(`alert:${sessionId}:question`); + scheduleFlush(true); + }, + /** Force a flush soon (e.g. right after a device registers). */ poke(): void { scheduleFlush(true); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 0e612a1a8..1e2776483 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -60,6 +60,7 @@ function createService(options?: { ensureResumeTargets: vi.fn().mockResolvedValue(undefined), enrichSessions: vi.fn((sessions: unknown[]) => sessions), getRuntimeState: vi.fn(() => "idle"), + setSessionRuntimeState: vi.fn(), listTerminals: vi.fn().mockReturnValue([]), activeForChat: vi.fn().mockReturnValue(null), ...options?.ptyService, @@ -1767,8 +1768,9 @@ describe("createSyncRemoteCommandService", () => { const enrichedSession = { ...session, runtimeState: "running" }; const getSessionSummary = vi.fn().mockResolvedValue({ sessionId: "session-1", - status: "idle", - idleSinceAt: "2026-01-01T00:00:00.000Z", + status: "active", + awaitingInput: true, + pendingInputItemId: "provider-question-1", orchestrationRunId: "run-1", orchestrationRole: "worker", orchestrationTag: "impl", @@ -1792,12 +1794,31 @@ describe("createSyncRemoteCommandService", () => { expect(getSessionSummary).toHaveBeenCalledWith("session-1"); expect(result).toEqual(expect.objectContaining({ id: "session-1", - runtimeState: "idle", - chatIdleSinceAt: "2026-01-01T00:00:00.000Z", + runtimeState: "waiting-input", + chatIdleSinceAt: null, + pendingInputItemId: "provider-question-1", + attentionSource: "provider_structured", orchestrationRunId: "run-1", orchestrationRole: "worker", orchestrationTag: "impl", })); + + getSessionSummary.mockResolvedValueOnce({ + sessionId: "session-1", + status: "active", + awaitingInput: false, + }); + ptyService.enrichSessions.mockReturnValueOnce([{ + ...enrichedSession, + pendingInputItemId: "provider-question-1", + attentionSource: "provider_structured", + }]); + const resumed = await service.execute(makePayload("work.getSession", { sessionId: "session-1" })); + expect(resumed).toEqual(expect.objectContaining({ + runtimeState: "running", + pendingInputItemId: null, + attentionSource: null, + })); }); it("delegates PR merge contexts and queue state to the injected services", async () => { @@ -2215,7 +2236,10 @@ describe("createSyncRemoteCommandService", () => { }); describe("session lifecycle remote commands", () => { - function createLifecycleService() { + function createLifecycleService(options?: { + pushPublisherService?: Record; + session?: Record; + }) { const sessionService = { settleSession: vi.fn(() => true), unsettleSession: vi.fn(() => true), @@ -2227,9 +2251,12 @@ describe("session lifecycle remote commands", () => { wakeSessions: vi.fn(() => ["session-1"]), setSettleOverride: vi.fn(() => true), clearWokeMarker: vi.fn(() => true), - get: vi.fn(() => ({ id: "session-1", toolType: "codex-chat" })), + get: vi.fn(() => options?.session ?? ({ id: "session-1", toolType: "codex-chat" })), }; - const { service } = createService({ sessionService }); + const { service } = createService({ + sessionService, + ...(options?.pushPublisherService ? { pushPublisherService: options.pushPublisherService } : {}), + }); return { service, sessionService }; } @@ -2246,6 +2273,38 @@ describe("session lifecycle remote commands", () => { expect(sessionService.unsettleSession).toHaveBeenCalledWith("session-1"); }); + it("resolves push attention when dismissing an explicit CLI ask", async () => { + const handleSessionSettled = vi.fn(); + const { service, sessionService } = createLifecycleService({ + pushPublisherService: { handleSessionSettled }, + session: { + id: "session-1", + toolType: "codex", + attentionRequestedAt: "2026-07-29T21:00:00.000Z", + }, + }); + + await expect(service.execute(makePayload("session.settleSession", { + sessionId: "session-1", + dismissPendingInput: true, + }))).resolves.toEqual({ ok: true, sessionId: "session-1" }); + + expect(handleSessionSettled).not.toHaveBeenCalled(); + expect(sessionService.settleSession).toHaveBeenCalledWith("session-1", {}); + }); + + it("leaves settlement push projection to the central session listener", async () => { + const handleSessionSettled = vi.fn(); + const { service } = createLifecycleService({ + pushPublisherService: { handleSessionSettled }, + }); + + await service.execute(makePayload("session.settleSession", { sessionId: "session-1" })); + await service.execute(makePayload("session.settleSessions", { sessionIds: ["session-1"] })); + + expect(handleSessionSettled).not.toHaveBeenCalled(); + }); + it("normalizes the snooze deadline and rejects unparseable ones", async () => { const { service, sessionService } = createLifecycleService(); const futureIso = new Date(Date.now() + 60 * 60_000).toISOString(); @@ -2629,25 +2688,71 @@ describe("lanes.unarchive", () => { }); describe("lanes.refreshSnapshots conditional responses", () => { - function createLaneListService() { + function createLaneListService(options?: { + sessions?: Record[]; + chats?: Record[]; + }) { const lanes = [{ id: "lane-1", name: "Lane one", status: { dirty: false, ahead: 0, behind: 0 } }]; const laneService = { refreshSnapshots: vi.fn().mockResolvedValue({ refreshedCount: 1, lanes }), listStateSnapshots: vi.fn().mockReturnValue([]), }; - const sessionService = { list: vi.fn().mockReturnValue([]) }; + const sessionService = { list: vi.fn().mockReturnValue(options?.sessions ?? []) }; const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }; const service = createSyncRemoteCommandService({ laneService, prService: {}, ptyService: {}, sessionService, + ...(options?.chats + ? { agentChatService: { listSessions: vi.fn().mockResolvedValue(options.chats) } } + : {}), fileService: {}, logger, } as any); return { service, laneService }; } + it("prioritizes provider-blocked chat attention over another running session", async () => { + const { service } = createLaneListService({ + sessions: [ + { + id: "cli-1", + laneId: "lane-1", + status: "running", + runtimeState: "running", + toolType: "codex", + lastOutputPreview: "Working", + }, + { + id: "chat-1", + laneId: "lane-1", + status: "running", + runtimeState: "idle", + toolType: "codex-chat", + lastOutputPreview: "Question restored", + }, + ], + chats: [{ + sessionId: "chat-1", + laneId: "lane-1", + status: "active", + awaitingInput: true, + pendingInputItemId: null, + }], + }); + + const result = await service.execute(makePayload("lanes.refreshSnapshots")) as { + snapshots: Array<{ runtime: { bucket: string; runningCount: number; awaitingInputCount: number } }>; + }; + + expect(result.snapshots[0]?.runtime).toMatchObject({ + bucket: "awaiting-input", + runningCount: 1, + awaitingInputCount: 1, + }); + }); + it("returns the full payload with a signature, then notModified for a matching ifNoneMatch", async () => { const { service } = createLaneListService(); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 2fa15c70f..2fe35ee2e 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1663,13 +1663,26 @@ function projectChatOntoSession( runtimeState: "waiting-input" as const, chatIdleSinceAt: null, pendingInputItemId: chat.pendingInputItemId ?? session.pendingInputItemId ?? null, + attentionSource: "provider_structured" as const, }; } if (chat.status === "active") { - return { ...base, runtimeState: "running" as const, chatIdleSinceAt: null }; + return { + ...base, + runtimeState: "running" as const, + chatIdleSinceAt: null, + pendingInputItemId: null, + attentionSource: session.attentionSource === "provider_structured" ? null : session.attentionSource, + }; } if (chat.status === "idle" || chat.status === "ended") { - return { ...base, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; + return { + ...base, + runtimeState: "idle" as const, + chatIdleSinceAt: chat.idleSinceAt ?? null, + pendingInputItemId: null, + attentionSource: session.attentionSource === "provider_structured" ? null : session.attentionSource, + }; } return base; } @@ -2225,17 +2238,7 @@ async function listRemoteWorkSessions( if (!isChatToolType(session.toolType) || session.status !== "running") return session; const chat = chatSummaryBySessionId.get(session.id); if (!chat) return session; - if (chat.awaitingInput) { - return { - ...session, - runtimeState: "waiting-input" as const, - chatIdleSinceAt: null, - pendingInputItemId: chat.pendingInputItemId ?? null, - }; - } - if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; - if (chat.status === "idle") return { ...session, runtimeState: "idle" as const, chatIdleSinceAt: chat.idleSinceAt ?? null }; - return session; + return projectChatOntoSession(session, chat); }); } @@ -3494,11 +3497,12 @@ async function resolveChatCreateArgs( function sessionStatusBucket(argsIn: { status: string; - lastOutputPreview: string | null | undefined; runtimeState?: string | null; settledAt?: string | null; settleOverride?: "settled" | "active" | null; attentionRequestedAt?: string | null; + pendingInputItemId?: string | null; + attentionSource?: "agent_explicit" | "provider_structured" | "user" | null; lastTurnFailedAt?: string | null; }): "running" | "awaiting-input" | "ended" { // Mirrors the settled-tier precedence in shared/sessionCanonicalState.ts: @@ -3507,21 +3511,17 @@ function sessionStatusBucket(argsIn: { // turn is not running. The tri-state override is consulted at that same // declared-settle tier: "active" is an explicit keep-active pin that // suppresses settle, "settled" behaves like a declared settle. - if (argsIn.attentionRequestedAt) return "awaiting-input"; + if ( + argsIn.attentionRequestedAt + || argsIn.pendingInputItemId + || argsIn.attentionSource === "provider_structured" + ) return "awaiting-input"; const effectiveSettled = argsIn.settleOverride === "active" ? false : argsIn.settleOverride === "settled" || Boolean(argsIn.settledAt); if (effectiveSettled && (argsIn.status !== "running" || argsIn.runtimeState === "idle")) return "ended"; if (argsIn.lastTurnFailedAt) return "ended"; if (argsIn.status === "running") { - if (argsIn.runtimeState === "waiting-input") return "awaiting-input"; - const preview = argsIn.lastOutputPreview ?? ""; - if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { - return "awaiting-input"; - } - if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { - return "awaiting-input"; - } return "running"; } return "ended"; @@ -3555,10 +3555,10 @@ function summarizeLaneRuntime( else if (bucket === "awaiting-input") awaitingInputCount += 1; else endedCount += 1; } - const bucket = runningCount > 0 - ? "running" - : awaitingInputCount > 0 - ? "awaiting-input" + const bucket = awaitingInputCount > 0 + ? "awaiting-input" + : runningCount > 0 + ? "running" : endedCount > 0 ? "ended" : "none"; @@ -3576,8 +3576,10 @@ async function buildLaneListSnapshots( lanes: Awaited["list"]>>, options: ListLanesArgs = {}, ): Promise { - const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + const [rawSessions, chatSessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ Promise.resolve(args.sessionService.list({ limit: 500 })), + args.agentChatService?.listSessions(undefined, { includeAutomation: true }).catch(() => []) + ?? Promise.resolve([]), options.includeRebaseSuggestions === false ? Promise.resolve([]) : Promise.resolve(args.rebaseSuggestionService?.listSuggestions({ lanes }) ?? []), @@ -3589,6 +3591,11 @@ async function buildLaneListSnapshots( ? Promise.resolve(null) : args.conflictService?.getBatchAssessment({ lanes }).catch(() => null) ?? Promise.resolve(null), ]); + const chatBySessionId = new Map(chatSessions.map((chat) => [chat.sessionId, chat] as const)); + const sessions = rawSessions.map((session) => { + const chat = chatBySessionId.get(session.id); + return chat ? projectChatOntoSession(session, chat) : session; + }); const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); @@ -3646,7 +3653,7 @@ async function buildLaneDetailPayload(args: SyncRemoteCommandServiceArgs, laneId ] = await Promise.all([ args.laneService.getChildren(laneId), Promise.resolve(args.sessionService.list({ laneId, limit: 200 })), - args.agentChatService?.listSessions(laneId, { includeAutomation: true }) ?? Promise.resolve([]), + args.agentChatService?.listSessions(laneId, { includeAutomation: true }).catch(() => []) ?? Promise.resolve([]), Promise.resolve(args.rebaseSuggestionService?.listSuggestions({ lanes: suggestionLanes }) ?? []), Promise.resolve(args.autoRebaseService?.listStatuses({ lanes: [lane] }) ?? []), Promise.resolve(args.laneService.getStateSnapshot(laneId)), @@ -3662,7 +3669,13 @@ async function buildLaneDetailPayload(args: SyncRemoteCommandServiceArgs, laneId return { lane, - runtime: summarizeLaneRuntime(laneId, sessions), + runtime: summarizeLaneRuntime( + laneId, + sessions.map((session) => { + const chat = chatSessions.find((candidate) => candidate.sessionId === session.id); + return chat ? projectChatOntoSession(session, chat) : session; + }), + ), stackChain, children, stateSnapshot: stateSnapshot as LaneStateSnapshotSummary | null, diff --git a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx index 71cee4669..2b049c54b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx @@ -313,15 +313,16 @@ describe("lifecycle markers", () => { // Regression: an "Until I'm asked" snooze (~100 years) marked a blocked row // `z wakes when asked` forever. Every early-wake trigger was chat-only, and a - // tracked CLI row's needs-input state is derived with no event to hook — so a + // explicit and structured needs-input states raise a hand — so a // needs-you row must never READ as snoozed either. it("does NOT mark a snoozed row as snoozed while it is asking for you", () => { const snooze = { snoozedUntil: new Date(NOW + 100 * 365 * 86_400_000).toISOString(), snoozedAt: new Date(NOW).toISOString(), }; - // Every deterministic hand-raise a text row can see. - expect(sessionLifecycleMarker({ ...snooze, runtimeState: "waiting-input" }, { nowMs: NOW })).toBeNull(); + // Runtime/prompt inference is non-interrupting; only explicit or + // provider-structured requests raise the hand. + expect(sessionLifecycleMarker({ ...snooze, runtimeState: "waiting-input" }, { nowMs: NOW })?.kind).toBe("snoozed"); expect(sessionLifecycleMarker({ ...snooze, awaitingInput: true }, { nowMs: NOW })).toBeNull(); expect(sessionLifecycleMarker({ ...snooze, pendingInputItemId: "item-1" }, { nowMs: NOW })).toBeNull(); expect(sessionLifecycleMarker( diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index d6342b8ba..22edd5487 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -313,11 +313,7 @@ export async function wakeSession( }); } -/** - * Set the tri-state settle override, consulted at the declared-settle tier - * BEFORE the derived exit-0 rule. "active" is the keep-active pin (the only way - * to hold a clean-exit row out of the quiet tier); null clears the pin. - */ +/** Set or clear an explicit settle override. */ export async function setSessionSettleOverride( connection: AdeCodeConnection, sessionId: string, diff --git a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts index 2ed959f29..9d58e6387 100644 --- a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts +++ b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts @@ -203,17 +203,13 @@ export type SessionLifecycleSnapshot = Partial & { /** * The deterministic hand-raise a text row can see, mirroring rule 1 of - * `canonicalSessionState`: a pending input item, a "waiting-input" runtime, an - * `ade chat ask` escalation, or the runtime's own awaiting-input flag. This is - * all the TUI needs to decide filing — the preview heuristic only ever upgrades - * running → needs_you, and a running row is not the case at risk here. + * `canonicalSessionState`: a provider-structured pending input item or an + * explicit `ade chat ask` escalation. Runtime/prompt inference is deliberately + * excluded because it is not an auditable request for the user. */ function snapshotRaisesHand(session: SessionLifecycleSnapshot): boolean { - if (session.awaitingInput === true) return true; - if (typeof session.runtimeState === "string" && session.runtimeState.trim().toLowerCase() === "waiting-input") { - return true; - } if (typeof session.pendingInputItemId === "string" && session.pendingInputItemId.trim().length > 0) return true; + if (session.awaitingInput === true) return true; return typeof session.attentionRequestedAt === "string" && session.attentionRequestedAt.trim().length > 0; } diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 9504932ee..c887753a1 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -1711,7 +1711,7 @@ describe("runtime session actions", () => { dismissPendingInput: true, })).resolves.toEqual({ ok: true, sessionId: "chat-1" }); expect(dismissPendingInputForSettlement).toHaveBeenCalledWith({ sessionId: "chat-1" }); - expect(settleSession).toHaveBeenCalledWith("chat-1", {}); + expect(settleSession).toHaveBeenCalledWith("chat-1", { source: "user" }); expect(dismissPendingInputForSettlement.mock.invocationCallOrder[0]).toBeLessThan( settleSession.mock.invocationCallOrder[0]!, ); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index f86372663..c30c9cd67 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1951,7 +1951,7 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { const blockers = runtime.agentChatService ? await runtime.agentChatService.getSettlementBlockers(sessionId) : [ - ...(session.attentionRequestedAt || session.runtimeState === "waiting-input" + ...(session.attentionRequestedAt || session.pendingInputItemId ? [{ code: "pending_input", message: "Resolve the pending input before settling." } as const] : []), ...(session.lastTurnFailedAt @@ -1967,7 +1967,7 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { } if (!await settleTerminalSession({ sessionId, - opts: { outcome }, + opts: { outcome, source: "agent_explicit" }, sessionService, agentChatService: runtime.agentChatService, ptyService: runtime.ptyService, @@ -1988,6 +1988,7 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { opts: { ...(outcome ? { outcome } : {}), ...(dismissPendingInput ? { dismissPendingInput: true } : {}), + source: "user", }, sessionService, agentChatService: runtime.agentChatService, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index eb6f4e829..2c2204e07 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -409,15 +409,18 @@ describe("createCtoOperatorTools", () => { sessionId: "chat-1", outcome: "CI green", })).resolves.toMatchObject({ success: true }); - expect(sessionService.settleSession).toHaveBeenCalledWith("chat-1", { outcome: "CI green" }); + expect(sessionService.settleSession).toHaveBeenCalledWith("chat-1", { + outcome: "CI green", + source: "operator", + }); await (tools.unsettleSession as any).execute({ sessionId: "chat-1" }); expect(sessionService.unsettleSession).toHaveBeenCalledWith("chat-1"); await (tools.setSessionSettleOverride as any).execute({ sessionId: "chat-1", override: "active" }); - expect(sessionService.setSettleOverride).toHaveBeenCalledWith("chat-1", "active"); + expect(sessionService.setSettleOverride).toHaveBeenCalledWith("chat-1", "active", "operator"); await (tools.setSessionSettleOverride as any).execute({ sessionId: "chat-1", override: "clear" }); - expect(sessionService.setSettleOverride).toHaveBeenLastCalledWith("chat-1", null); + expect(sessionService.setSettleOverride).toHaveBeenLastCalledWith("chat-1", null, "operator"); }); it("snoozes by duration or explicit deadline and rejects neither", async () => { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 694875446..7dc93faf2 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -556,7 +556,10 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { try { - const ok = deps.sessionService.settleSession(sessionId, outcome ? { outcome } : {}); + const ok = deps.sessionService.settleSession(sessionId, { + ...(outcome ? { outcome } : {}), + source: "operator", + }); if (!ok) return { success: false, error: `Session not found: ${sessionId}` }; return { success: true, sessionId, ...readSessionLifecycle(deps, sessionId) }; } catch (error) { @@ -586,7 +589,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { try { const normalized = override === "clear" ? null : override; - const ok = deps.sessionService.setSettleOverride(sessionId, normalized); + const ok = deps.sessionService.setSettleOverride(sessionId, normalized, "operator"); if (!ok) return { success: false, error: `Session not found: ${sessionId}` }; return { success: true, sessionId, ...readSessionLifecycle(deps, sessionId) }; } catch (error) { diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts index f7a4e8db6..444e7b1e6 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts @@ -19,7 +19,7 @@ function makeHarness(session: Record) { } describe("laneListSnapshotService", () => { - it("buckets idle AI CLI sessions as awaiting input", async () => { + it("does not infer awaiting input from an idle AI CLI", async () => { const services = makeHarness({ laneId: "lane-1", status: "running", @@ -39,9 +39,9 @@ describe("laneListSnapshotService", () => { ); expect(snapshot?.runtime).toMatchObject({ - bucket: "awaiting-input", - runningCount: 0, - awaitingInputCount: 1, + bucket: "running", + runningCount: 1, + awaitingInputCount: 0, pendingInputCount: 0, endedCount: 0, sessionCount: 1, @@ -86,7 +86,7 @@ describe("laneListSnapshotService", () => { }); }); - it("does not count stale chat awaiting state without a pending item id", async () => { + it("counts provider-structured awaiting state without an item id", async () => { const services = { ...makeHarness({ id: "chat-1", @@ -151,9 +151,9 @@ describe("laneListSnapshotService", () => { ); expect(snapshot?.runtime).toMatchObject({ - bucket: "awaiting-input", - runningCount: 0, - awaitingInputCount: 1, + bucket: "running", + runningCount: 1, + awaitingInputCount: 0, }); }); diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts index 95b504d1b..dcaf9ab3c 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -101,33 +101,16 @@ export type LaneListSnapshotOptions = { export const OPTIONAL_LANE_ENRICHMENT_BUDGET_MS = 250; -const IDLE_ATTENTION_TOOL_TYPES = new Set([ - "claude", - "codex", - "cursor-cli", - "droid", - "opencode", - "claude-orchestrated", - "codex-orchestrated", - "opencode-orchestrated", - "aider", - "continue", -]); - -function idleRuntimeNeedsAttention(toolType: string | null | undefined): boolean { - if (isChatToolType(toolType)) return true; - if (!toolType) return false; - return IDLE_ATTENTION_TOOL_TYPES.has(toolType.trim().toLowerCase()); -} - function sessionStatusBucket(args: { status: string; - lastOutputPreview: string | null | undefined; runtimeState?: string | null; toolType?: string | null; settledAt?: string | null; settleOverride?: "settled" | "active" | null; attentionRequestedAt?: string | null; + pendingInputItemId?: string | null; + attentionSource?: "agent_explicit" | "provider_structured" | "user" | null; + pendingInputWaiting?: boolean; lastTurnFailedAt?: string | null; }): "running" | "awaiting-input" | "ended" { // `ade chat ask` escalation outranks everything; a declared settle maps to @@ -136,25 +119,18 @@ function sessionStatusBucket(args: { // The tri-state override is consulted at the same declared-settle tier: // "active" is an explicit keep-active pin, "settled" acts like a declared // settle. Mirrors canonicalSessionState in shared/sessionCanonicalState.ts. - if (args.attentionRequestedAt) return "awaiting-input"; + if ( + args.attentionRequestedAt + || args.pendingInputItemId + || args.attentionSource === "provider_structured" + || args.pendingInputWaiting === true + ) return "awaiting-input"; const effectiveSettled = args.settleOverride === "active" ? false : args.settleOverride === "settled" || Boolean(args.settledAt); if (effectiveSettled && (args.status !== "running" || args.runtimeState === "idle")) return "ended"; if (args.lastTurnFailedAt) return "ended"; - if (args.status === "running") { - if (args.runtimeState === "waiting-input") return "awaiting-input"; - if (args.runtimeState === "idle" && idleRuntimeNeedsAttention(args.toolType)) return "awaiting-input"; - const preview = args.lastOutputPreview ?? ""; - if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { - return "awaiting-input"; - } - if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { - return "awaiting-input"; - } - return "running"; - } - if (isChatToolType(args.toolType)) return "awaiting-input"; + if (args.status === "running") return "running"; return "ended"; } @@ -167,6 +143,7 @@ function summarizeLaneRuntime( runtimeState?: string | null; toolType?: string | null; pendingInputItemId?: string | null; + attentionSource?: "agent_explicit" | "provider_structured" | "user" | null; pendingInputWaiting?: boolean; }>, ): LaneRuntimeSummary { diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index 7b969360c..f40d01f03 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -754,6 +754,7 @@ describe("prMergeAutoSettlementService", () => { ["chat-ready"], "PR #101 merged", "2026-03-24T12:01:05.000Z", + "pr_merge", ); expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "pr-sessions-auto-settled", @@ -840,6 +841,7 @@ describe("prMergeAutoSettlementService", () => { ["chat-ready"], "PR #202 merged", "2026-03-24T12:03:05.000Z", + "pr_merge", ); }); diff --git a/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts b/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts index 115f18b91..869b8f5cc 100644 --- a/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts +++ b/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts @@ -84,6 +84,7 @@ export function createPrMergeAutoSettlementService(args: { [session.id], `PR #${pr.githubPrNumber} merged`, polledAt, + "pr_merge", )); } } diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index f46354b8f..96fa6ef80 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -1480,6 +1480,7 @@ export function createPtyService({ broadcastExit, onSessionEnded, onSessionRuntimeSignal, + onSessionUserInput, diskPressureMonitor, loadPty, disposePtyBackend @@ -1511,6 +1512,7 @@ export function createPtyService({ lastOutputPreview: string | null; at: string; }) => void; + onSessionUserInput?: (args: { laneId: string; sessionId: string }) => void; diskPressureMonitor?: DiskPressureMonitor | null; loadPty: () => typeof ptyNs; disposePtyBackend?: () => void; @@ -4102,6 +4104,7 @@ export function createPtyService({ if (entry.tracked && isTrackedAgentCliToolType(entry.toolTypeHint)) { clearTrackedCliTurnStartMarkers(entry.sessionId); entry.attentionRequested = false; + onSessionUserInput?.({ laneId: entry.laneId, sessionId: entry.sessionId }); return; } if (entry.attentionRequested) { diff --git a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts index 65c002beb..6e0d01e49 100644 --- a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts +++ b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts @@ -92,6 +92,7 @@ export function projectChatOntoSession( runtimeState: "waiting-input", chatIdleSinceAt: null, pendingInputItemId: chat.pendingInputItemId ?? session.pendingInputItemId ?? null, + attentionSource: "provider_structured", }; } if (chat.status === "active") { @@ -100,6 +101,7 @@ export function projectChatOntoSession( runtimeState: "running", chatIdleSinceAt: null, pendingInputItemId: null, + attentionSource: session.attentionSource === "provider_structured" ? null : session.attentionSource, }; } if (chat.status === "idle" || chat.status === "ended") { @@ -108,6 +110,7 @@ export function projectChatOntoSession( runtimeState: "idle", chatIdleSinceAt: chat.idleSinceAt ?? null, pendingInputItemId: null, + attentionSource: session.attentionSource === "provider_structured" ? null : session.attentionSource, }; } return fallbackUnprojectedChatSession(base); diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index f0a838be7..ddb3dbd58 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -1272,6 +1272,10 @@ describe("sessionService resume metadata", () => { }); service.requestAttention("session-settle", "Need a decision"); + expect(service.get("session-settle")).toEqual(expect.objectContaining({ + attentionMessage: "Need a decision", + attentionSource: "agent_explicit", + })); service.settleSession("session-settle", { outcome: " Shipped the fix ", settledAt: "2026-03-17T01:00:00.000Z", @@ -1284,12 +1288,15 @@ describe("sessionService resume metadata", () => { expect(service.get("session-settle")).toEqual(expect.objectContaining({ settledAt: "2026-03-17T01:00:00.000Z", statusNote: "Shipped the fix", + settleSource: "user", attentionRequestedAt: null, attentionMessage: null, + attentionSource: null, })); service.unsettleSession("session-settle"); expect(service.get("session-settle")?.settledAt).toBeNull(); + expect(service.get("session-settle")?.settleSource).toBeNull(); }); it("bulk settles only rows that were not already settled", async () => { @@ -1326,6 +1333,7 @@ describe("sessionService resume metadata", () => { ["session-settled", "session-other"], "PR #841 merged", "2026-03-17T03:00:00.000Z", + "pr_merge", )).toEqual(["session-other"]); expect(service.get("session-settled")).toEqual(expect.objectContaining({ settledAt: "2026-03-17T01:00:00.000Z", @@ -1333,6 +1341,7 @@ describe("sessionService resume metadata", () => { })); expect(service.get("session-other")).toEqual(expect.objectContaining({ settledAt: "2026-03-17T03:00:00.000Z", + settleSource: "pr_merge", statusNote: "PR #841 merged", })); }); @@ -1627,7 +1636,7 @@ describe("sessionService snooze overlay", () => { expect(service.get("session-cli")?.wokeAt).toBeTruthy(); }); - it("does NOT wake a snoozed session on a clean exit 0 (that is the settled path)", async () => { + it("does NOT wake a snoozed session on a clean exit 0", async () => { const { service } = await makeService("ade-session-service-snooze-exit-zero-"); service.create({ sessionId: "session-cli-clean", @@ -1789,6 +1798,35 @@ describe("sessionService settle override", () => { expect(service.get("session-override")?.settleOverride).toBe("active"); }); + it("preserves the declaration source while an active override temporarily hides settle", async () => { + const { service } = await makeService("ade-session-service-override-source-"); + + service.settleSession("session-override", { + settledAt: "2026-03-17T03:00:00.000Z", + source: "agent_explicit", + }); + service.setSettleOverride("session-override", "active"); + expect(service.get("session-override")?.settleSource).toBe("agent_explicit"); + + service.setSettleOverride("session-override", null); + expect(service.get("session-override")).toEqual(expect.objectContaining({ + settledAt: "2026-03-17T03:00:00.000Z", + settleOverride: null, + settleSource: "agent_explicit", + })); + }); + + it("records the caller provenance for a settled override", async () => { + const { service } = await makeService("ade-session-service-override-provenance-"); + + service.setSettleOverride("session-override", "settled", "operator"); + + expect(service.get("session-override")).toEqual(expect.objectContaining({ + settleOverride: "settled", + settleSource: "operator", + })); + }); + it("supports the bulk override variant", async () => { const { service } = await makeService("ade-session-service-override-bulk-"); service.create({ @@ -1805,6 +1843,9 @@ describe("sessionService settle override", () => { expect(service.setSettleOverrides(["session-override", "session-override-2", "missing"], "active")) .toEqual(["session-override", "session-override-2"]); expect(service.get("session-override-2")?.settleOverride).toBe("active"); + service.settleSession("session-override", { source: "pr_merge" }); + service.setSettleOverrides(["session-override"], "active"); + expect(service.get("session-override")?.settleSource).toBe("pr_merge"); service.setSettleOverrides(["session-override", "session-override-2"], null); expect(service.get("session-override")?.settleOverride).toBeNull(); expect(service.setSettleOverrides([], "active")).toEqual([]); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 6c0ea74de..c26c149ff 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import type { AdeDb } from "../state/kvDb"; import type { ClaudeSessionPointer, + SessionAttentionSource, + SessionSettleSource, SessionSettleOverride, SessionWakeReason, TerminalSessionDetail, @@ -54,8 +56,10 @@ type SessionRow = { statusNote: string | null; attentionRequestedAt: string | null; attentionMessage: string | null; + attentionSource: string | null; lastTurnFailedAt: string | null; settleOverride: string | null; + settleSource: string | null; snoozedUntil: string | null; snoozedAt: string | null; wokeAt: string | null; @@ -106,8 +110,10 @@ const SESSION_COLUMNS = ` s.status_note as statusNote, s.attention_requested_at as attentionRequestedAt, s.attention_message as attentionMessage, + s.attention_source as attentionSource, s.last_turn_failed_at as lastTurnFailedAt, s.settle_override as settleOverride, + s.settle_source as settleSource, s.snoozed_until as snoozedUntil, s.snoozed_at as snoozedAt, s.woke_at as wokeAt, @@ -139,6 +145,18 @@ function isResumeProvider(value: unknown): value is TerminalResumeProvider { return value === "claude" || value === "codex" || value === "cursor" || value === "droid" || value === "opencode"; } +function normalizeAttentionSource(value: unknown): SessionAttentionSource | null { + return value === "agent_explicit" || value === "provider_structured" || value === "user" + ? value + : null; +} + +function normalizeSettleSource(value: unknown): SessionSettleSource | null { + return value === "agent_explicit" || value === "user" || value === "pr_merge" || value === "operator" + ? value + : null; +} + function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return null; const record = raw as Record; @@ -530,8 +548,10 @@ export function createSessionService({ db }: { db: AdeDb }) { statusNote: normalizeOptionalText(row.statusNote, 200), attentionRequestedAt: normalizeIsoTimestamp(row.attentionRequestedAt), attentionMessage: normalizeOptionalText(row.attentionMessage, 500), + attentionSource: normalizeAttentionSource(row.attentionSource), lastTurnFailedAt: normalizeIsoTimestamp(row.lastTurnFailedAt), settleOverride: normalizeSettleOverride(row.settleOverride), + settleSource: normalizeSettleSource(row.settleSource), snoozedUntil: normalizeIsoTimestamp(row.snoozedUntil), snoozedAt: normalizeIsoTimestamp(row.snoozedAt), wokeAt: normalizeIsoTimestamp(row.wokeAt), @@ -646,7 +666,7 @@ export function createSessionService({ db }: { db: AdeDb }) { const settleMany = ( sessionIds: string[], - options: { outcome?: string; settledAt?: string } = {}, + options: { outcome?: string; settledAt?: string; source?: SessionSettleSource } = {}, ): string[] => { const ids = normalizeSessionIds(sessionIds); if (!ids.length) return []; @@ -667,14 +687,17 @@ export function createSessionService({ db }: { db: AdeDb }) { update terminal_sessions set settled_at = coalesce(settled_at, ?), settle_override = null, + settle_source = ?, ${hasOutcome ? "status_note = ?," : ""} attention_requested_at = null, - attention_message = null + attention_message = null, + attention_source = null where (settled_at is null or settle_override is not null) and id in (${updatePlaceholders}) `, [ normalizeIsoTimestamp(options.settledAt) ?? new Date().toISOString(), + options.source ?? "user", ...(hasOutcome ? [normalizeOptionalText(options.outcome, 200)] : []), ...newlySettled, ], @@ -1264,7 +1287,7 @@ export function createSessionService({ db }: { db: AdeDb }) { setLastOutputPreview(sessionId: string, preview: string, opts?: { clearSettled?: boolean }): void { db.run( opts?.clearSettled - ? "update terminal_sessions set last_output_preview = ?, last_output_at = ?, settled_at = null, settle_override = null where id = ?" + ? "update terminal_sessions set last_output_preview = ?, last_output_at = ?, settled_at = null, settle_override = null, settle_source = null where id = ?" : "update terminal_sessions set last_output_preview = ?, last_output_at = ? where id = ?", [preview, new Date().toISOString(), sessionId] ); @@ -1287,7 +1310,7 @@ export function createSessionService({ db }: { db: AdeDb }) { db.run( opts?.clearSettled === false ? "update terminal_sessions set last_output_at = ? where id = ?" - : "update terminal_sessions set last_output_at = ?, settled_at = null, settle_override = null where id = ?", + : "update terminal_sessions set last_output_at = ?, settled_at = null, settle_override = null, settle_source = null where id = ?", [at, sessionId] ); }, @@ -1347,8 +1370,8 @@ export function createSessionService({ db }: { db: AdeDb }) { // persisted "woke · errored" marker instead of staying hidden until its // (possibly ~100-year "until I'm asked") deadline. Reason "error" keeps // the newer-than-`snoozed_at` guard, so snoozing on top of an already - // dead session stays snoozed. A clean exit 0 does NOT wake — that is the - // settled path. + // dead session stays snoozed. A clean exit 0 does not wake because it is + // neither a failure nor an explicit request for attention. if (isFailedSessionEnd(exitCode, status)) { const woke = wakeSnoozedRow(sessionId, "error", { errorAt: endedAt }); if (woke) emitChanged({ sessionId, reason: "meta-updated" }); @@ -1383,7 +1406,7 @@ export function createSessionService({ db }: { db: AdeDb }) { settleSession( sessionId: string, - opts: { outcome?: string | null; settledAt?: string } = {}, + opts: { outcome?: string | null; settledAt?: string; source?: SessionSettleSource } = {}, ): boolean { const settledAt = normalizeIsoTimestamp(opts.settledAt) ?? new Date().toISOString(); const outcome = normalizeOptionalText(opts.outcome, 200); @@ -1396,12 +1419,14 @@ export function createSessionService({ db }: { db: AdeDb }) { update terminal_sessions set settled_at = coalesce(settled_at, ?), settle_override = null, + settle_source = ?, status_note = ?, attention_requested_at = null, - attention_message = null + attention_message = null, + attention_source = null where id = ? `, - [settledAt, outcome, id], + [settledAt, opts.source ?? "user", outcome, id], ); } else { db.run( @@ -1409,30 +1434,27 @@ export function createSessionService({ db }: { db: AdeDb }) { update terminal_sessions set settled_at = coalesce(settled_at, ?), settle_override = null, + settle_source = ?, attention_requested_at = null, - attention_message = null + attention_message = null, + attention_source = null where id = ? `, - [settledAt, id], + [settledAt, opts.source ?? "user", id], ); } }); }, - /** - * Clears a declared settle plus any `'settled'` override. An `'active'` - * pin survives, because un-settling must not undo an explicit keep-active - * decision. Rows that derive settle from a clean exit need - * `setSettleOverride(id, "active")`, not unsettle — there is no - * `settled_at` for unsettle to clear on those. - */ + /** Clears a declared settle plus any `'settled'` override. */ unsettleSession(sessionId: string): boolean { return mutateSessionMeta(sessionId, (id) => { db.run( ` update terminal_sessions set settled_at = null, - settle_override = case when settle_override = 'settled' then null else settle_override end + settle_override = case when settle_override = 'settled' then null else settle_override end, + settle_source = null where id = ? `, [id], @@ -1440,16 +1462,28 @@ export function createSessionService({ db }: { db: AdeDb }) { }); }, - /** - * Tri-state settle override: `"settled"` behaves like a declared settle, - * `"active"` is the explicit keep-active pin that beats the derived exit-0 - * auto-settle, `null` hands the row back to the derived rules. Real - * activity clears it at the same write sites that clear `settled_at`. - */ - setSettleOverride(sessionId: string, override: SessionSettleOverride | null): boolean { + /** Explicit settle override, cleared with `settled_at` on real activity. */ + setSettleOverride( + sessionId: string, + override: SessionSettleOverride | null, + source: SessionSettleSource = "user", + ): boolean { const normalized = override == null ? null : normalizeSettleOverride(override); + const normalizedSource = normalizeSettleSource(source) ?? "user"; return mutateSessionMeta(sessionId, (id) => { - db.run("update terminal_sessions set settle_override = ? where id = ?", [normalized, id]); + db.run( + ` + update terminal_sessions + set settle_override = ?, + settle_source = case + when ? = 'settled' then ? + when settled_at is null then null + else settle_source + end + where id = ? + `, + [normalized, normalized, normalizedSource, id], + ); }); }, @@ -1465,8 +1499,17 @@ export function createSessionService({ db }: { db: AdeDb }) { if (!present.length) return []; const updatePlaceholders = present.map(() => "?").join(", "); db.run( - `update terminal_sessions set settle_override = ? where id in (${updatePlaceholders})`, - [normalized, ...present], + ` + update terminal_sessions + set settle_override = ?, + settle_source = case + when ? = 'settled' then 'user' + when settled_at is null then null + else settle_source + end + where id in (${updatePlaceholders}) + `, + [normalized, normalized, ...present], ); for (const id of present) { emitChanged({ sessionId: id, reason: "meta-updated" }); @@ -1482,8 +1525,9 @@ export function createSessionService({ db }: { db: AdeDb }) { sessionIds: string[], outcome: string, settledAt: string = new Date().toISOString(), + source: SessionSettleSource = "user", ): string[] { - return settleMany(sessionIds, { outcome, settledAt }); + return settleMany(sessionIds, { outcome, settledAt, source }); }, unsettleSessions(sessionIds: string[]): void { @@ -1494,7 +1538,8 @@ export function createSessionService({ db }: { db: AdeDb }) { ` update terminal_sessions set settled_at = null, - settle_override = case when settle_override = 'settled' then null else settle_override end + settle_override = case when settle_override = 'settled' then null else settle_override end, + settle_source = null where id in (${placeholders}) `, ids, @@ -1638,18 +1683,24 @@ export function createSessionService({ db }: { db: AdeDb }) { * it un-settles (including any override) and it wakes a snoozed row early, * before its timer. */ - requestAttention(sessionId: string, message: string | null): boolean { + requestAttention( + sessionId: string, + message: string | null, + source: SessionAttentionSource = "agent_explicit", + ): boolean { return mutateSessionMeta(sessionId, (id) => { db.run( ` update terminal_sessions set attention_requested_at = ?, attention_message = ?, + attention_source = ?, settled_at = null, - settle_override = null + settle_override = null, + settle_source = null where id = ? `, - [new Date().toISOString(), normalizeOptionalText(message, 500), id], + [new Date().toISOString(), normalizeOptionalText(message, 500), source, id], ); wakeSnoozedRow(id, "needs_you"); }); @@ -1658,7 +1709,7 @@ export function createSessionService({ db }: { db: AdeDb }) { clearAttentionRequest(sessionId: string): boolean { return mutateSessionMeta(sessionId, (id) => { db.run( - "update terminal_sessions set attention_requested_at = null, attention_message = null where id = ?", + "update terminal_sessions set attention_requested_at = null, attention_message = null, attention_source = null where id = ?", [id], ); }); @@ -1672,7 +1723,7 @@ export function createSessionService({ db }: { db: AdeDb }) { // settled/failed mutually exclusive at write time, so every surface's // precedence order agrees by construction. db.run( - "update terminal_sessions set last_turn_failed_at = ?, settled_at = null, settle_override = null where id = ?", + "update terminal_sessions set last_turn_failed_at = ?, settled_at = null, settle_override = null, settle_source = null where id = ?", [failedAt, id], ); // Early wake, but ONLY for an error newer than the snooze. Snoozing on @@ -1701,8 +1752,10 @@ export function createSessionService({ db }: { db: AdeDb }) { set last_turn_failed_at = null, settled_at = null, settle_override = null, + settle_source = null, attention_requested_at = null, - attention_message = null + attention_message = null, + attention_source = null where id = ? `, [id], diff --git a/apps/desktop/src/main/services/sessions/settleTerminalSession.ts b/apps/desktop/src/main/services/sessions/settleTerminalSession.ts index 21227ea9a..6b49ed36e 100644 --- a/apps/desktop/src/main/services/sessions/settleTerminalSession.ts +++ b/apps/desktop/src/main/services/sessions/settleTerminalSession.ts @@ -1,11 +1,13 @@ import type { createAgentChatService } from "../chat/agentChatService"; import type { createPtyService } from "../pty/ptyService"; import type { createSessionService } from "./sessionService"; +import type { SessionSettleSource } from "../../../shared/types"; import { isChatToolType } from "./chatSessionProjection"; export type SettleTerminalSessionOptions = { outcome?: string; dismissPendingInput?: boolean; + source?: SessionSettleSource; }; export async function settleTerminalSession(args: { @@ -37,6 +39,9 @@ export async function settleTerminalSession(args: { return args.sessionService.settleSession( args.sessionId, - args.opts?.outcome ? { outcome: args.opts.outcome } : {}, + { + ...(args.opts?.outcome ? { outcome: args.opts.outcome } : {}), + ...(args.opts?.source ? { source: args.opts.source } : {}), + }, ); } diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts index 0e0d885f6..63e51daee 100644 --- a/apps/desktop/src/main/services/state/kvDb.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.test.ts @@ -287,7 +287,15 @@ describe("terminal_sessions snooze + settle-override schema", () => { const columns = db.all<{ name: string; type: string; notnull: number }>( "pragma table_info('terminal_sessions')", ); - for (const name of ["settle_override", "snoozed_until", "snoozed_at", "woke_at", "woke_reason"]) { + for (const name of [ + "attention_source", + "settle_override", + "settle_source", + "snoozed_until", + "snoozed_at", + "woke_at", + "woke_reason", + ]) { const column = columns.find((entry) => entry.name === name); expect(column, `${name} column missing`).toBeTruthy(); expect(column?.type.toLowerCase()).toBe("text"); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 0b0fee350..a85a8939e 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2036,8 +2036,10 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { status_note text, attention_requested_at text, attention_message text, + attention_source text, last_turn_failed_at text, settle_override text, + settle_source text, snoozed_until text, snoozed_at text, woke_at text, @@ -2063,9 +2065,9 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { safeAddColumn(db, "alter table terminal_sessions add column status_note text"); safeAddColumn(db, "alter table terminal_sessions add column attention_requested_at text"); safeAddColumn(db, "alter table terminal_sessions add column attention_message text"); + safeAddColumn(db, "alter table terminal_sessions add column attention_source text"); safeAddColumn(db, "alter table terminal_sessions add column last_turn_failed_at text"); - // Tri-state settle override ('settled' | 'active' | null) consulted before - // the derived exit-0 auto-settle, plus the snooze visibility overlay + // Explicit settle override ('settled' | 'active' | null), plus the snooze visibility overlay // (snoozed_until / snoozed_at) and its "woke" marker. All nullable with NO // unique index: `terminal_sessions` replicates to iOS through cr-sqlite and // `crsql_as_crr` rejects any non-PK unique index. The same columns exist in @@ -2073,6 +2075,7 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { // ensureColumn migrations — a missing iOS half does not fail here, it // surfaces as changeset-apply errors on the phone. safeAddColumn(db, "alter table terminal_sessions add column settle_override text"); + safeAddColumn(db, "alter table terminal_sessions add column settle_source text"); safeAddColumn(db, "alter table terminal_sessions add column snoozed_until text"); safeAddColumn(db, "alter table terminal_sessions add column snoozed_at text"); safeAddColumn(db, "alter table terminal_sessions add column woke_at text"); diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index 434c9eb38..d8e9e8fa1 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -66,9 +66,9 @@ describe("terminalSessionSignals", () => { expect(normalizeResumeCommand("claude -r abc123", "claude")).toBe("claude --resume abc123"); }); - it("maps OSC 133 prompt markers to waiting-input", () => { + it("does not infer waiting-input from OSC 133 prompt markers", () => { const marker = "\u001b]133;A\u0007"; - expect(runtimeStateFromOsc133Chunk(marker, "running")).toBe("waiting-input"); + expect(runtimeStateFromOsc133Chunk(marker, "running")).toBe("running"); }); it("maps OSC 133 command markers to running", () => { diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index 8f49de5e7..05619ba20 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -498,10 +498,6 @@ export function runtimeStateFromOsc133Chunk( if (!chunk) return next; for (const match of chunk.matchAll(OSC_133_REGEX)) { const marker = (match[1] ?? "").toUpperCase(); - if (marker === "A" || marker === "D") { - next = "waiting-input"; - continue; - } if (marker === "B" || marker === "C") { next = "running"; } diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts index 91455798c..db2031b84 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts @@ -98,13 +98,29 @@ describe("buildLaneAgents", () => { chat({ sessionId: "ended", status: "ended" }), chat({ sessionId: "working", status: "active" }), ], - [cli({ id: "waiting", runtimeState: "waiting-input" })], + [cli({ id: "waiting", runtimeState: "waiting-input", attentionRequestedAt: "2026-01-01T00:00:01.000Z" })], ); expect(agents.map((a) => a.sessionId)).toEqual(["working", "waiting", "ended"]); expect(agents.find((a) => a.sessionId === "ended")?.activity).toBe("ended"); expect(agents.find((a) => a.sessionId === "waiting")?.activity).toBe("awaiting-input"); }); + it("does not infer awaiting input from a CLI runtime marker alone", () => { + const agents = buildLaneAgents([], [cli({ id: "waiting", runtimeState: "waiting-input" })]); + expect(agents[0]?.activity).toBe("idle"); + expect(agents[0]?.lastHint).toBeNull(); + }); + + it("marks provider-structured CLI attention as awaiting input", () => { + const agents = buildLaneAgents([], [cli({ + id: "provider-waiting", + runtimeState: "waiting-input", + attentionSource: "provider_structured", + })]); + expect(agents[0]?.activity).toBe("awaiting-input"); + expect(agents[0]?.lastHint).toBe("Awaiting your input"); + }); + it("marks awaiting-input chats with a hint", () => { const agents = buildLaneAgents([chat({ sessionId: "c", awaitingInput: true })], []); expect(agents[0]?.activity).toBe("awaiting-input"); diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.ts index 8cd054f4f..8bc2c65b6 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -38,9 +38,14 @@ function chatActivity(summary: AgentChatSessionSummary): LaneAgentActivity { } function cliActivity(summary: TerminalSessionSummary): LaneAgentActivity { + if ( + summary.pendingInputItemId + || summary.attentionRequestedAt + || summary.attentionSource === "provider_structured" + ) return "awaiting-input"; switch (summary.runtimeState) { case "running": return "working"; - case "waiting-input": return "awaiting-input"; + case "waiting-input": return "idle"; case "exited": case "killed": return "ended"; default: return "idle"; @@ -86,7 +91,10 @@ function cliAgentFrom(summary: TerminalSessionSummary): LaneAgent { modelId: null, providerLabel: cliProviderLabel(summary.toolType), activity: cliActivity(summary), - lastHint: summary.runtimeState === "waiting-input" + lastHint: + summary.pendingInputItemId + || summary.attentionRequestedAt + || summary.attentionSource === "provider_structured" ? "Awaiting your input" : summary.summary?.trim() || summary.lastOutputPreview?.trim() || null, lastActivityAt: summary.endedAt ?? summary.startedAt, diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index 3ce8c106b..ad406a466 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -103,7 +103,7 @@ describe("SessionCard orchestration identity", () => { expect(screen.getByLabelText("Needs you")).toBeTruthy(); }); - it("uses the same Needs you copy for CLI input prompts", () => { + it("does not infer Needs you from a CLI runtime marker", () => { render( { />, ); - expect(screen.getByLabelText("Needs you")).toBeTruthy(); + expect(screen.queryByLabelText("Needs you")).toBeNull(); }); it("renders a Claude session tag beside the title", () => { diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx index 5d496ac4e..be61c5f7d 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx @@ -112,16 +112,13 @@ describe("SessionContextMenu settle safety", () => { expect(onSettle).toHaveBeenCalledWith(session, null); }); - it("allows stale provider input without a live response handle to be dismissed", () => { - const session = makeSession({ + it("does not infer dismissible input from a provider runtime marker", () => { + renderMenu(makeSession({ toolType: "codex-chat", runtimeState: "waiting-input", pendingInputItemId: null, - }); - const { onSettle } = renderMenu(session); - - fireEvent.click(screen.getByRole("button", { name: "Dismiss & settle" })); - expect(onSettle).toHaveBeenCalledWith(session, null); + })); + expect(screen.queryByRole("button", { name: "Dismiss & settle" })).toBeNull(); }); it("passes the owning machine binding directly to deferred lifecycle actions", () => { @@ -158,7 +155,7 @@ describe("SessionContextMenu settle safety", () => { expect(screen.getByRole("button", { name: "Dismiss & settle" })).toBeTruthy(); }); - it("requires resolving a native CLI prompt that ADE cannot dismiss truthfully", () => { + it("does not infer a native CLI prompt from the runtime marker", () => { const onSettle = vi.fn(); renderMenu(makeSession({ toolType: "codex", @@ -167,13 +164,12 @@ describe("SessionContextMenu settle safety", () => { attentionRequestedAt: null, }), vi.fn(), onSettle); - const button = screen.getByRole("button", { name: "Resolve input to settle" }); - expect((button as HTMLButtonElement).disabled).toBe(true); + expect(screen.queryByRole("button", { name: "Resolve input to settle" })).toBeNull(); expect(onSettle).not.toHaveBeenCalled(); }); }); -describe("SessionContextMenu snooze and derived-settle lifecycle", () => { +describe("SessionContextMenu snooze and explicit-settle lifecycle", () => { let sessionsApi: Record>; beforeEach(() => { @@ -191,34 +187,6 @@ describe("SessionContextMenu snooze and derived-settle lifecycle", () => { vi.clearAllMocks(); }); - /** exit-0 PTY with no `settledAt`: canonically settled, but nothing declared it. */ - function derivedSettledSession(): TerminalSessionSummary { - return makeSession({ - toolType: "shell", - status: "completed", - runtimeState: "exited", - endedAt: "2026-07-10T12:30:00.000Z", - exitCode: 0, - settledAt: null, - }); - } - - it("gives a DERIVED settled row an Unsettle action backed by the keep-active override", async () => { - // Regression: this row previously fell out of every branch of the settle - // chain and rendered no lifecycle action at all. - renderMenu(derivedSettledSession()); - - fireEvent.click(screen.getByRole("button", { name: "Unsettle" })); - - await waitFor(() => { - expect(sessionsApi.setSettleOverride).toHaveBeenCalledWith("chat-1", "active"); - }); - // There is no `settledAt` column to clear, so the declared path stays unused. - expect(sessionsApi.unsettle).not.toHaveBeenCalled(); - // "Keep active" would be the identical call here, so it is not duplicated. - expect(screen.queryByRole("button", { name: "Keep active" })).toBeNull(); - }); - it("keeps the declared-settle path for settledAt rows and adds a keep-active pin", async () => { const session = makeSession({ toolType: "shell", diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index 1ba6ab49f..79eff1d0c 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -142,12 +142,6 @@ export function SessionContextMenu({ const isSnoozed = isSessionSnoozed(session); const snoozeWake = isSnoozed ? snoozeWakeLabel(session.snoozedUntil) : null; const isSettled = canonicalPhase === "settled"; - /** - * A DERIVED settle — a clean exit-0 (or a `settleOverride: "settled"`) with no - * `settledAt` — used to fall out of every branch here and end up with no - * lifecycle action at all. The keep-active override is the unsettle for those - * rows: it outranks the derived rule so the row leaves the quiet tier. - */ const isDeclaredSettled = Boolean(session.settledAt); const chooseSnooze = (key: SnoozeDurationKey) => { void snoozeSessionForDuration(session, key, Date.now(), binding); diff --git a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx index d5d326403..9cdadf16a 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx @@ -48,6 +48,15 @@ function normalizeLoose(s: string): string { return s.replace(/\s+/g, " ").trim(); } +function lifecycleSourceLabel(source: TerminalSessionSummary["attentionSource"] | TerminalSessionSummary["settleSource"]): string { + if (source === "agent_explicit") return "Agent declaration"; + if (source === "provider_structured") return "Provider request"; + if (source === "pr_merge") return "Lane PR merge"; + if (source === "operator") return "ADE operator"; + if (source === "user") return "User action"; + return "—"; +} + function sectionShell({ title, icon: Icon, children }: { title: string; icon: typeof Info; children: ReactNode }) { return (
@@ -210,6 +219,14 @@ export function SessionInfoPopover({ ["Process", runtimeStateLabel(session.runtimeState)], session.toolType ? ["Tool", formatToolTypeLabel(session.toolType)] : null, session.exitCode != null ? ["Exit code", String(session.exitCode)] : null, + session.attentionRequestedAt + || session.pendingInputItemId + || session.attentionSource === "provider_structured" + ? ["Attention source", lifecycleSourceLabel(session.attentionSource)] + : null, + session.settledAt || session.settleOverride === "settled" + ? ["Settlement source", lifecycleSourceLabel(session.settleSource)] + : null, !session.tracked ? ["Worktree", "Not linked to this lane’s worktree"] : null, isChat && session.archivedAt ? ["Archived", formatWhen(session.archivedAt)] : null, ["Started", formatWhen(session.startedAt)], diff --git a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts index ee3115ee6..7fccea917 100644 --- a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts +++ b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts @@ -79,9 +79,8 @@ export async function wakeSessionNow( } /** - * Pin a session's lifecycle. `"active"` is the keep-active pin that also - * unsettles a DERIVED settle (clean exit 0 with no `settledAt`), which is the - * only lifecycle action such a row has. + * Pin a session's lifecycle. `"active"` is the keep-active pin that suppresses + * a declared settle until real activity clears the override. */ export async function setSessionSettleOverride( session: Pick, @@ -98,20 +97,14 @@ export async function setSessionSettleOverride( } /** - * Lift a settle, whichever kind it is. A DECLARED settle (`settledAt` set) - * clears the column; a DERIVED settle (clean exit 0, no `settledAt`) has no - * column to clear, so the keep-active pin is its only unsettle. Both the Work - * row menu and the chat header chip route through here, so the branch can never - * drift apart — and a failed write is reported instead of swallowed. + * Lift a declared settle. Both the Work row menu and the chat header chip route + * through here, so the branch can never drift apart — and a failed write is + * reported instead of swallowed. */ export async function unsettleSession( session: Pick, pin?: OpenProjectBinding | null, ): Promise { - if (!session.settledAt) { - await setSessionSettleOverride(session, "active", pin); - return; - } try { await (pin ? window.ade.sessions.unsettle(session.id, pin) diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 7d96938d0..d69727b43 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -1593,9 +1593,8 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(result.current.filtered.map((session) => session.id)).toEqual(["session-running", "session-ended"]); }); expect(result.current.runningFiltered.map((s) => s.id)).toEqual(["session-running"]); - // exit-0 shells auto-settle (clean exit IS the done declaration). - expect(result.current.endedFiltered.map((s) => s.id)).toEqual([]); - expect(result.current.settledFiltered.map((s) => s.id)).toEqual(["session-ended"]); + expect(result.current.endedFiltered.map((s) => s.id)).toEqual(["session-ended"]); + expect(result.current.settledFiltered.map((s) => s.id)).toEqual([]); }); it("partitions snoozed rows out of the flat sidebar buckets and back once the snooze lapses", async () => { @@ -1644,11 +1643,7 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(result.current.settledFiltered.map((s) => s.id)).toEqual([]); }); - // Regression: "Until I'm asked" snooze hid a needs-you row forever. All three - // early-wake triggers were chat-only, and a tracked CLI row's needs-input - // state is DERIVED (no event exists to hook), so a snoozed CLI session that - // hit a permission prompt could never come back. Snooze must yield to a - // raised hand at filing time. + // Regression: "Until I'm asked" snooze hid an explicitly raised hand. it("does NOT file a snoozed needs-you row as snoozed in the flat sidebar buckets", async () => { const nowMs = Date.now(); const iso = (offsetMs: number) => new Date(nowMs + offsetMs).toISOString(); @@ -1656,6 +1651,7 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { const snoozedCliNeedsYou = makeSession("session-cli-needs-you", "lane-1", { toolType: "claude" as const, runtimeState: "waiting-input" as const, + attentionRequestedAt: iso(-1_000), snoozedUntil: iso(100 * 365 * 24 * 3600_000), snoozedAt: iso(-60_000), }); @@ -2370,10 +2366,9 @@ describe("useWorkSessions — grouping defaults and derived tab order", () => { organization: "all-lanes-by-status", collapsedGroupIds: ["status:running"], }); - // The exit-0 session lands in the settled tier, not ended. - expect(byStatus.groups.map((group) => group.id)).toEqual(["status:running", "status:awaiting-input", "status:settled"]); + expect(byStatus.groups.map((group) => group.id)).toEqual(["status:running", "status:ended"]); expect(byStatus.groups[0]!.collapsed).toBe(true); - expect(byStatus.sessionIds).toEqual(["session-a2", "session-c1"]); + expect(byStatus.sessionIds).toEqual(["session-c1"]); const byTime = buildWorkTabGroupModel({ sessions, @@ -2402,6 +2397,7 @@ describe("useWorkSessions — grouping defaults and derived tab order", () => { runtimeState: "exited" as const, exitCode: 0, endedAt: iso(-60_000), + settledAt: iso(-30_000), }), ]; const lanes = [ @@ -2428,10 +2424,7 @@ describe("useWorkSessions — grouping defaults and derived tab order", () => { expect(model.groups[2]!.sessionIds).toEqual(["session-settled"]); }); - // Regression: "Until I'm asked" snooze hid a needs-you row forever — the - // grouped status path lifted snoozed rows out of Your-move with no filing - // exception, and no early-wake event exists for a tracked CLI row (its - // needs-input state is derived). + // Regression: "Until I'm asked" snooze hid an explicitly raised hand. it("does NOT file a snoozed needs-you row into the Snoozed group", () => { const nowMs = Date.parse("2026-04-01T12:00:00.000Z"); const iso = (offsetMs: number) => new Date(nowMs + offsetMs).toISOString(); @@ -2441,6 +2434,7 @@ describe("useWorkSessions — grouping defaults and derived tab order", () => { makeSession("session-cli-needs-you", "lane-a", { toolType: "claude" as const, runtimeState: "waiting-input" as const, + attentionRequestedAt: iso(-1_000), snoozedUntil: iso(100 * 365 * 24 * 3600_000), snoozedAt: iso(-60_000), }), diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index c52251538..ae9fc37fa 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -1568,9 +1568,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) for (const session of chipFiltered) { // Snooze is a visibility overlay: it pulls the row OUT of whatever bucket // it would otherwise sit in, including Running — but it YIELDS to a raised - // hand. A needs_you row is filed normally even while snoozed, which is - // what keeps "Until I'm asked" honest for tracked CLI rows (their - // needs-input state is derived, so no early-wake event can fire). + // hand. A needs_you row is filed normally even while snoozed, which + // keeps "Until I'm asked" honest. const phase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; const filingBucket = sessionFilingBucket(session, nowMs); if (filingBucket === "snoozed") { diff --git a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx index 349c63495..155824490 100644 --- a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx +++ b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx @@ -88,7 +88,7 @@ describe("SessionLifecycleChips", () => { await waitFor(() => expect(sessionsApi.wakeSession).toHaveBeenCalledWith("session-1", "manual")); }); - it("shows a settled chip and unsettles a DERIVED settle through the keep-active override", async () => { + it("does not render a settled chip for a clean process exit", () => { seedSessions([makeSession({ toolType: "shell", status: "completed", @@ -99,11 +99,9 @@ describe("SessionLifecycleChips", () => { })]); render(); - fireEvent.click(screen.getByTestId("chat-session-settled-chip")); - fireEvent.click(screen.getByRole("menuitem", { name: "Unsettle" })); - - await waitFor(() => expect(sessionsApi.setSettleOverride).toHaveBeenCalledWith("session-1", "active")); + expect(screen.queryByTestId("chat-session-settled-chip")).toBeNull(); expect(sessionsApi.unsettle).not.toHaveBeenCalled(); + expect(sessionsApi.setSettleOverride).not.toHaveBeenCalled(); }); it("clears a declared settle through the settle column", async () => { diff --git a/apps/desktop/src/renderer/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index 4ee9005c7..4c15502c4 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.test.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.test.ts @@ -3,7 +3,6 @@ import { runningSessionNeedsAttention, sanitizeTerminalInlineText, sessionNeedsChatTabHighlight, - sessionNeedsUserInput, sessionStatusBucket, sessionStatusDot, } from "./terminalAttention"; @@ -19,14 +18,14 @@ describe("terminalAttention", () => { ).toBe("running"); }); - it("still detects explicit confirmation prompts", () => { + it("keeps prompt-text detection separate from lifecycle attention", () => { expect(runningSessionNeedsAttention("Confirm continue? (y/n)")).toBe(true); expect( sessionStatusBucket({ status: "running", lastOutputPreview: "Confirm continue? (y/n)", }), - ).toBe("awaiting-input"); + ).toBe("running"); }); it("removes cursor save and restore escapes from inline previews", () => { @@ -93,35 +92,20 @@ describe("terminalAttention", () => { })).toBe(false); }); - it("highlights agent chats blocked on approval or questions", () => { + it("highlights only structured or explicitly declared chat requests", () => { expect(sessionNeedsChatTabHighlight({ runtimeState: "waiting-input", toolType: "cursor", - })).toBe(true); + })).toBe(false); expect(sessionNeedsChatTabHighlight({ runtimeState: "idle", toolType: "codex-chat", pendingInputItemId: "approval-1", })).toBe(true); - }); - }); - - describe("sessionNeedsUserInput", () => { - it("keeps idle agent chats out of CLI-style prompt detection", () => { - expect(sessionNeedsUserInput({ - status: "running", - lastOutputPreview: "Completed response", - runtimeState: "idle", + expect(sessionNeedsChatTabHighlight({ + runtimeState: "waiting-input", toolType: "claude-chat", - })).toBe(false); - }); - - it("still detects CLI confirmation prompts for CLI headers", () => { - expect(sessionNeedsUserInput({ - status: "running", - lastOutputPreview: "Confirm continue? (y/n)", - runtimeState: "running", - toolType: "claude", + attentionSource: "provider_structured", })).toBe(true); }); }); @@ -148,14 +132,14 @@ describe("terminalAttention", () => { expect(dot.label).toBe("Running"); }); - it("returns a solid (non-spinning) amber dot for a running needs-attention session", () => { + it("keeps a prompt-looking running session non-interrupting", () => { const dot = sessionStatusDot({ status: "running", lastOutputPreview: "Confirm continue? (y/n)", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("amber"); - expect(dot.label).toBe("Needs you"); + expect(dot.cls).toContain("emerald"); + expect(dot.label).toBe("Running"); }); it("returns a solid amber dot for an idle chat session", () => { diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 9d2db712e..8f16dd655 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -106,6 +106,7 @@ type SessionCanonicalUiInput = { runtimeState?: TerminalRuntimeState; toolType?: TerminalToolType | null; pendingInputItemId?: string | null; + attentionSource?: TerminalSessionSummary["attentionSource"]; lastActivityAt?: string | null; exitCode?: number | null; settledAt?: string | null; @@ -127,6 +128,7 @@ export function canonicalInputFromSummary(session: TerminalSessionSummary): Sess runtimeState: session.runtimeState, toolType: session.toolType, pendingInputItemId: session.pendingInputItemId, + attentionSource: session.attentionSource, lastActivityAt: session.lastActivityAt, exitCode: session.exitCode, settledAt: session.settledAt, @@ -142,6 +144,7 @@ export function sessionCanonicalUiState(session: SessionCanonicalUiInput): Canon runtimeState: session.runtimeState ?? null, toolType: session.toolType ?? null, pendingInputItemId: session.pendingInputItemId ?? null, + attentionSource: session.attentionSource ?? null, lastOutputPreview: session.lastOutputPreview, lastActivityAt: session.lastActivityAt ?? null, exitCode: session.exitCode ?? null, @@ -150,7 +153,6 @@ export function sessionCanonicalUiState(session: SessionCanonicalUiInput): Canon attentionRequestedAt: session.attentionRequestedAt ?? null, lastTurnFailedAt: session.lastTurnFailedAt ?? null, nowMs: session.nowMs, - previewSuggestsNeedsInput: runningSessionNeedsAttention, isChatTool: isChatToolType, }); } @@ -171,32 +173,17 @@ export function sessionInlineStatusLabel(session: SessionCanonicalUiInput): stri return null; } -export function sessionNeedsUserInput(args: { - status: TerminalSessionStatus; - lastOutputPreview: string | null; - runtimeState?: TerminalRuntimeState; - toolType?: TerminalToolType | null; - pendingInputItemId?: string | null; - attentionRequestedAt?: string | null; -}): boolean { - if (args.runtimeState === "waiting-input") return true; - if (args.pendingInputItemId) return true; - if (args.attentionRequestedAt) return true; - if (isChatToolType(args.toolType)) return false; - if (args.status !== "running") return false; - return runningSessionNeedsAttention(args.lastOutputPreview); -} - /** Yellow Work tab border — agent chats blocked on approval/question/`ade chat ask` only. */ export function sessionNeedsChatTabHighlight(args: { runtimeState?: TerminalRuntimeState; toolType?: TerminalToolType | null; pendingInputItemId?: string | null; + attentionSource?: TerminalSessionSummary["attentionSource"]; attentionRequestedAt?: string | null; }): boolean { if (!isChatToolType(args.toolType)) return false; - if (args.runtimeState === "waiting-input") return true; if (args.pendingInputItemId) return true; + if (args.attentionSource === "provider_structured") return true; if (args.attentionRequestedAt) return true; return false; } diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index b19754b9f..0d977142e 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -15,13 +15,13 @@ export const adeBundledAgentSkills = [ ] as const; export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ - "Session status protocol (keeps the Work sidebar truthful):", + "ADE control protocol for truthful Work status:", '- Working: `ade chat note "running e2e shard 2/4"`; keep it current.', '- Blocked on user input without a structured ask: `ade chat ask ""`.', - "- Settlement means the entire session is terminal, not just the current turn. Otherwise leave it in Your move.", - "- Settle only when ALL are true: the task is delivered and verified; no work, check, approval, question, subagent, job, schedule, goal, plan item, or follow-up remains; the final response is closed; the next reply would be new scope.", + "- Settlement means the entire session is terminal, not merely the current turn or CLI process.", + "- Settle only when ALL are true: delivered and verified; no work, check, approval, question, subagent, job, schedule, goal, plan item, or follow-up remains; the next reply would be new scope.", "- Do NOT settle during discussion, planning, review, monitoring, or waiting. “let’s discuss”, “for now”, “don’t change yet”, open questions, and feedback invitations are non-terminal.", - '- Only then run `ade chat settle --outcome ""`. The runtime rejects settlement while structured work remains. New user activity un-settles.', + '- Only then run `ade chat settle --outcome ""`. The runtime rejects settlement while structured work remains.', "- If uncertain, do not settle; update `ade chat note` and leave it in Your move.", ].join("\n"); diff --git a/apps/desktop/src/shared/sessionCanonicalState.test.ts b/apps/desktop/src/shared/sessionCanonicalState.test.ts index dcf782a57..710f06faa 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.test.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.test.ts @@ -12,8 +12,6 @@ import { } from "./sessionCanonicalState"; const NOW = Date.parse("2026-07-06T12:00:00.000Z"); -const promptLikePreview = (preview: string | null | undefined) => - Boolean(preview && /\(y\/n\)/i.test(preview)); const chatTools = new Set(["claude-chat", "cursor"]); const isChatTool = (toolType: string | null | undefined) => Boolean(toolType && chatTools.has(toolType)); @@ -23,7 +21,6 @@ function state(overrides: Partial) { runtimeState: "running", toolType: "claude", nowMs: NOW, - previewSuggestsNeedsInput: promptLikePreview, isChatTool, ...overrides, }); @@ -32,12 +29,12 @@ function state(overrides: Partial) { describe("canonicalSessionState precedence", () => { const silentSince = new Date(NOW - SESSION_STALE_AFTER_MS - 1_000).toISOString(); - // Table: deterministic signals must outrank everything below them, and the - // preview heuristic must never outvote a deterministic runtime state. + // Table: explicit and structured signals must outrank everything below them. const cases: Array<[string, Partial, string, string | null]> = [ ["pendingInputItemId wins over stale silence", { pendingInputItemId: "i-1", lastActivityAt: silentSince }, "needs_you", "Needs you"], - ["waiting-input wins over stale silence", { runtimeState: "waiting-input", lastActivityAt: silentSince }, "needs_you", "Needs you"], - ["waiting-input wins even when preview looks calm", { runtimeState: "waiting-input", lastOutputPreview: "compiling..." }, "needs_you", "Needs you"], + ["provider provenance restores structured input without an item id", { attentionSource: "provider_structured" }, "needs_you", "Needs you"], + ["runtime waiting-input does not outvote stale silence", { runtimeState: "waiting-input", lastActivityAt: silentSince }, "stale", "Stale"], + ["runtime waiting-input alone stays non-interrupting", { runtimeState: "waiting-input", lastOutputPreview: "compiling..." }, "running", null], ["pendingInputItemId wins on an ended session", { pendingInputItemId: "i-1", status: "detached", exitCode: 1 }, "needs_you", "Needs you"], ["disposed session is stopped, not failed", { status: "disposed", runtimeState: "killed", exitCode: null }, "stopped", null], ["user-stop signal is stopped, not failed", { status: "disposed", runtimeState: "killed", exitCode: 130 }, "stopped", null], @@ -46,12 +43,12 @@ describe("canonicalSessionState precedence", () => { ["killed runtime is failed", { status: "detached", runtimeState: "killed", exitCode: null }, "failed", "Failed"], ["running + silent past threshold is stale", { lastActivityAt: silentSince }, "stale", "Stale"], ["stale wins over a prompt-looking preview", { lastActivityAt: silentSince, lastOutputPreview: "continue? (y/n)" }, "stale", "Stale"], - ["heuristic upgrades plain running LAST", { lastOutputPreview: "continue? (y/n)" }, "needs_you", "Needs you"], + ["prompt-looking output alone stays running", { lastOutputPreview: "continue? (y/n)" }, "running", null], ["plain running stays running (no badge)", { lastOutputPreview: "compiling..." }, "running", null], ["idle chat is ready (no badge)", { runtimeState: "idle", toolType: "claude-chat" }, "ready", null], ["idle CLI is idle (no badge)", { runtimeState: "idle" }, "idle", null], ["heuristic does NOT fire on idle sessions", { runtimeState: "idle", lastOutputPreview: "continue? (y/n)" }, "idle", null], - ["clean exit auto-settles (no badge)", { status: "detached", exitCode: 0 }, "settled", null], + ["clean exit stays ended until explicitly settled", { status: "detached", exitCode: 0 }, "ended", null], ["unknown exit stays ended (no badge)", { status: "detached", exitCode: null, runtimeState: "exited" }, "ended", null], ["detached chat is ended, not perpetually ready", { status: "detached", toolType: "claude-chat", exitCode: null }, "ended", null], ["declared settle wins over failure", { status: "detached", exitCode: 2, settledAt: "2026-07-06T11:00:00.000Z" }, "settled", null], @@ -89,17 +86,14 @@ describe("stale boundary", () => { }); describe("settle override tri-state", () => { - // The bug this exists to fix: exit 0 auto-settles WITHOUT stamping - // settled_at, so the row had no lifecycle action at all and was pinned to - // the quiet tier forever. const cleanExit: Partial = { status: "detached", exitCode: 0, runtimeState: "exited" }; - it("null override leaves the derived exit-0 auto-settle intact", () => { - expect(state({ ...cleanExit }).phase).toBe("settled"); - expect(state({ ...cleanExit, settleOverride: null }).phase).toBe("settled"); + it("null override leaves a clean exit ended", () => { + expect(state({ ...cleanExit }).phase).toBe("ended"); + expect(state({ ...cleanExit, settleOverride: null }).phase).toBe("ended"); }); - it("'active' override beats the derived exit-0 rule", () => { + it("'active' override leaves an undeclared clean exit ended", () => { const result = state({ ...cleanExit, settleOverride: "active" }); expect(result.phase).toBe("ended"); expect(result.badge).toBeNull(); @@ -160,10 +154,7 @@ describe("snooze is a visibility overlay, not a phase", () => { }); // Regression: an "Until I'm asked" snooze (~100 years) hid a needs-you row -// forever. Every early-wake trigger (`ade chat ask`, chat turn failure, chat -// turn complete) was chat-only, and a tracked CLI row's needs-input state is -// DERIVED (runtime "waiting-input" / preview heuristic) with no event to hook — -// so the filing rule, not an event, is what has to bring the row back. +// forever. The filing rule must yield to explicit or structured attention. describe("snooze filing yields to a raised hand (isSessionFiledAsSnoozed)", () => { const snoozedUntil = new Date(NOW + 60_000).toISOString(); const snoozedAt = new Date(NOW - 60_000).toISOString(); @@ -183,7 +174,7 @@ describe("snooze filing yields to a raised hand (isSessionFiledAsSnoozed)", () = expect(isSessionSnoozed(snoozed, NOW)).toBe(true); expect(canonicalSessionState({ status: "running", - runtimeState: "waiting-input", + pendingInputItemId: "question-1", nowMs: NOW, }).phase).toBe("needs_you"); }); diff --git a/apps/desktop/src/shared/sessionCanonicalState.ts b/apps/desktop/src/shared/sessionCanonicalState.ts index bdf72f720..17b4b73ce 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.ts @@ -1,4 +1,5 @@ import type { + SessionAttentionSource, SessionSettleOverride, SessionWakeReason, TerminalRuntimeState, @@ -64,6 +65,7 @@ export type CanonicalSessionInputs = { runtimeState?: TerminalRuntimeState | null; toolType?: TerminalToolType | null; pendingInputItemId?: string | null; + attentionSource?: SessionAttentionSource | null; lastOutputPreview?: string | null; /** ISO timestamp of most recent output/activity (drives stale). */ lastActivityAt?: string | null; @@ -74,17 +76,10 @@ export type CanonicalSessionInputs = { * PTY output), so no timestamp comparison happens here. */ settledAt?: string | null; - /** - * Tri-state settle override, consulted BEFORE the derived exit-0 rule. - * "settled" behaves like a declared settle; "active" is an explicit - * keep-active pin that suppresses settle (derived AND declared) so a clean - * PTY exit is not permanently pinned to the quiet tier. Cleared on real - * activity at the same write sites that clear `settledAt`. - */ + /** Explicit lifecycle override. Cleared with `settledAt` on real activity. */ settleOverride?: SessionSettleOverride | null; /** - * Escalated ask from `ade chat ask` (chat sessions; CLI sessions ride - * runtimeState "waiting-input" instead). Cleared by the next user message. + * Escalated ask from `ade chat ask`. Cleared by the next user message. */ attentionRequestedAt?: string | null; /** @@ -93,12 +88,6 @@ export type CanonicalSessionInputs = { */ lastTurnFailedAt?: string | null; nowMs?: number; - /** - * The preview-text heuristic (regex over terminal output) supplied by the - * caller so this module stays dependency-free. It is consulted LAST and can - * only upgrade running → needs_you — deterministic signals always win. - */ - previewSuggestsNeedsInput?: (preview: string | null | undefined) => boolean; /** Chat sessions idle between turns are "ready", not running/ended. */ isChatTool?: (toolType: TerminalToolType | null | undefined) => boolean; }; @@ -112,21 +101,17 @@ function isSilentPast(lastActivityAt: string | null | undefined, nowMs: number, /** * Canonical precedence (highest first): - * 1. deterministic needs-input — pendingInputItemId, runtimeState - * "waiting-input", or an `ade chat ask` escalation (never outvoted by - * anything below), + * 1. explicit/structured needs-input — pendingInputItemId or an + * `ade chat ask` escalation (never outvoted by anything below), * 2. settled — explicitly declared (agent/user) or forced by a "settled" * override; presence wins over failure because a declared quiet is a * human/agent judgment call. An "active" override suppresses this tier * entirely. Cleared at the write site on any new activity, * 3. stopped — user/system-disposed PTY, * 4. failed — non-zero exit / killed / chat turn death, - * 5. clean exit — a PTY that exited 0 IS the process declaring it's done; - * auto-settles without any declaration, UNLESS an "active" override pins - * it (rule 2's override check runs first), - * 6. stale — status running but silent ≥ SESSION_STALE_AFTER_MS, - * 7. running (incl. the preview heuristic's needs_you upgrade, LAST), - * 8. resting states — ready (idle chat, quiet "your move"), idle, ended. + * 5. stale — status running but silent ≥ SESSION_STALE_AFTER_MS, + * 6. running, + * 7. resting states — ready (idle chat, quiet "your move"), idle, ended. */ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSessionState { const nowMs = args.nowMs ?? Date.now(); @@ -134,7 +119,11 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe // 1. Deterministic attention beats everything — including the failure and // stale checks below (an agent explicitly asking is actionable regardless). - if (args.pendingInputItemId || args.runtimeState === "waiting-input" || args.attentionRequestedAt) { + if ( + args.pendingInputItemId + || args.attentionRequestedAt + || args.attentionSource === "provider_structured" + ) { return { phase: "needs_you", badge: BADGE_BY_KIND.needs_you }; } @@ -145,10 +134,6 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe // goes idle again (the settledAt column survives background wakes; only user // activity clears it). // - // The tri-state override is consulted here, i.e. BEFORE the derived exit-0 - // rule below. "active" is an explicit keep-active pin: it beats derived - // settle (a clean exit) and a stale declared settle alike, so the row keeps a - // real lifecycle action instead of being stuck in the quiet tier forever. const pinnedActive = args.settleOverride === "active"; const atRest = args.status !== "running" || args.runtimeState === "idle"; if (!pinnedActive && atRest && (args.settleOverride === "settled" || args.settledAt)) { @@ -190,12 +175,9 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe } return { phase: "ready", badge: null }; } - // 5. Clean exit auto-settle: exit 0 is the one deterministic "done" - // declaration a process can make. Unknown exits stay "ended" (red). - // An "active" override vetoes it — that is the whole point of the pin. - if (args.exitCode === 0 && !pinnedActive) { - return { phase: "settled", badge: null }; - } + // A clean process exit only says the CLI ended. Settlement is a lifecycle + // declaration made by the agent/user (or the lane PR-merge policy), never + // inferred from process mechanics. return { phase: "ended", badge: null }; } @@ -217,11 +199,6 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe return chat ? { phase: "ready", badge: null } : { phase: "idle", badge: null }; } - // 7. Preview heuristic LAST: it may only upgrade running → needs_you. - if (args.previewSuggestsNeedsInput?.(args.lastOutputPreview)) { - return { phase: "needs_you", badge: BADGE_BY_KIND.needs_you }; - } - return { phase: "running", badge: null }; } @@ -233,7 +210,7 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe * awaiting-input — "your move": loud needs_you rows and quiet resting chats * and idle CLIs share the section; the badge alone is loud, * ended — died (failed / stopped / unknown exit), - * settled — declared or clean-exit done; quiet tier at the bottom. + * settled — explicitly declared done; quiet tier at the bottom. */ export type CanonicalStatusBucket = "running" | "awaiting-input" | "ended" | "settled"; @@ -289,12 +266,9 @@ export function isSessionSnoozed(session: SessionSnoozeState, nowMs: number = Da * * Snooze is a visibility overlay, and an overlay must yield to a session that * is actually blocked on the user — otherwise the "Until I'm asked" window - * (~100 years) can bury a row whose hand IS raised. Only three events ever - * wrote an early wake (`ade chat ask`, chat turn failure, chat turn complete), - * all chat-only, so a tracked CLI session that hits a permission prompt has no - * event to fire: for it, `needs_you` is DERIVED (runtimeState "waiting-input" - * or the output-preview heuristic) and nothing persists a flag. Deriving the - * filing rule from the phase covers chat and CLI identically with no event. + * (~100 years) can bury a row whose hand IS raised. Explicit and structured + * requests project to `needs_you`, so deriving the filing rule from the phase + * covers chat and CLI identically. * * Deliberately separate from `isSessionSnoozed`, which stays the raw two-column * read: chips, menus, and wake labels legitimately want "is this row snoozed?" diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 224317bde..bb0fc0fb2 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -69,17 +69,7 @@ export function isTrackedAgentCliToolType( export type TerminalRuntimeState = "running" | "waiting-input" | "idle" | "exited" | "killed"; -/** - * Tri-state settle override (terminal_sessions.settle_override). It is - * consulted by `canonicalSessionState()` BEFORE the derived "exit 0 means - * done" rule: - * null — no override; the derived rules decide, - * "settled" — behaves exactly like a declared settle, - * "active" — explicit keep-active pin that beats derived settle, so a - * clean PTY exit stops being permanently pinned to the quiet - * tier with no lifecycle action available. - * Cleared on real activity at the same write sites that clear `settled_at`. - */ +/** Explicit settle pin. Cleared on activity with `settled_at`. */ export type SessionSettleOverride = "settled" | "active"; /** @@ -215,14 +205,17 @@ export type TerminalSessionSummary = { statusNote?: string | null; attentionRequestedAt?: string | null; attentionMessage?: string | null; + /** Auditable owner of the current explicit attention declaration. */ + attentionSource?: SessionAttentionSource | null; lastTurnFailedAt?: string | null; /** * Tri-state settle override (terminal_sessions.settle_override). Optional for * migration tolerance; null/undefined both mean "no override". Unlike - * `settledAt` this is a *lifecycle* control that outranks the derived - * exit-0 auto-settle in `canonicalSessionState()`. + * `settledAt` this is an explicit lifecycle control. */ settleOverride?: SessionSettleOverride | null; + /** Auditable owner/policy that declared the current settled state. */ + settleSource?: SessionSettleSource | null; /** * Snooze is a synced VISIBILITY OVERLAY, never a lifecycle phase — it does * not touch `canonicalSessionState()`, only where the UI files the row. @@ -271,6 +264,9 @@ export type TerminalSessionSummary = { spawnKind?: AgentChatSpawnKind; }; +export type SessionAttentionSource = "agent_explicit" | "provider_structured" | "user"; +export type SessionSettleSource = "agent_explicit" | "user" | "pr_merge" | "operator"; + export type TerminalSessionDetail = TerminalSessionSummary & { // Reserved for future expansion (goal/tool templates, derived deltas, etc.) }; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 78d25ddf2..c8a2fee6b 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -651,7 +651,7 @@ export type SyncRosterChat = { /** * Additive settled-lifecycle projection. Optional so current phones remain * compatible with older hosts and current hosts remain compatible with older - * phones. `exitCode` is needed for canonical clean-exit auto-settle. + * phones. `exitCode` is needed to distinguish clean ends from failures. */ settledAt?: string | null; statusNote?: string | null; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 69210194d..be0afdb6f 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3648,11 +3648,13 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { var statusNote: String? = nil var attentionRequestedAt: String? = nil var attentionMessage: String? = nil + var attentionSource: String? = nil var lastTurnFailedAt: String? = nil /// Tri-state settle override (`"settled"` / `"active"` / nil), consulted at - /// the declared-settle tier BEFORE the derived exit-0 rule. Mirrors the + /// the declared-settle tier. Mirrors the /// desktop `SessionSettleOverride`. var settleOverride: String? = nil + var settleSource: String? = nil /// Snooze visibility overlay. `snoozedUntil` is the derived-expiry deadline /// (no scheduler exists — every surface compares it to now); `snoozedAt` is /// the load-bearing baseline for the early-wake error comparison. @@ -3701,8 +3703,10 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { && lhs.statusNote == rhs.statusNote && lhs.attentionRequestedAt == rhs.attentionRequestedAt && lhs.attentionMessage == rhs.attentionMessage + && lhs.attentionSource == rhs.attentionSource && lhs.lastTurnFailedAt == rhs.lastTurnFailedAt && lhs.settleOverride == rhs.settleOverride + && lhs.settleSource == rhs.settleSource && lhs.snoozedUntil == rhs.snoozedUntil && lhs.snoozedAt == rhs.snoozedAt && lhs.wokeAt == rhs.wokeAt @@ -3750,8 +3754,10 @@ extension TerminalSessionSummary { case statusNote case attentionRequestedAt case attentionMessage + case attentionSource case lastTurnFailedAt case settleOverride + case settleSource case snoozedUntil case snoozedAt case wokeAt @@ -3793,8 +3799,10 @@ extension TerminalSessionSummary { statusNote = try container.decodeIfPresent(String.self, forKey: .statusNote) attentionRequestedAt = try container.decodeIfPresent(String.self, forKey: .attentionRequestedAt) attentionMessage = try container.decodeIfPresent(String.self, forKey: .attentionMessage) + attentionSource = try container.decodeIfPresent(String.self, forKey: .attentionSource) lastTurnFailedAt = try container.decodeIfPresent(String.self, forKey: .lastTurnFailedAt) settleOverride = try container.decodeIfPresent(String.self, forKey: .settleOverride) + settleSource = try container.decodeIfPresent(String.self, forKey: .settleSource) snoozedUntil = try container.decodeIfPresent(String.self, forKey: .snoozedUntil) snoozedAt = try container.decodeIfPresent(String.self, forKey: .snoozedAt) wokeAt = try container.decodeIfPresent(String.self, forKey: .wokeAt) diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 9c4d486ec..7d4c5905b 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -205,7 +205,9 @@ create table if not exists terminal_sessions ( resume_command text, resume_metadata_json text, archived_at text, + attention_source text, settle_override text, + settle_source text, snoozed_until text, snoozed_at text, woke_at text, @@ -231,6 +233,10 @@ alter table terminal_sessions add column manually_named integer not null default alter table terminal_sessions add column archived_at text; +alter table terminal_sessions add column attention_source text; + +alter table terminal_sessions add column settle_source text; + alter table terminal_sessions add column chat_session_id text; create index if not exists idx_terminal_sessions_chat_session_id on terminal_sessions(chat_session_id); diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 0c43278d2..36f7a61a5 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -102,8 +102,10 @@ final class DatabaseService { let statusNote: String? let attentionRequestedAt: String? let attentionMessage: String? + let attentionSource: String? let lastTurnFailedAt: String? let settleOverride: String? + let settleSource: String? let snoozedUntil: String? let snoozedAt: String? let wokeAt: String? @@ -1122,9 +1124,9 @@ final class DatabaseService { id, lane_id, lane_name, pty_id, tracked, goal, tool_type, pinned, title, started_at, ended_at, exit_code, transcript_path, head_sha_start, head_sha_end, status, last_output_preview, last_output_at, summary, runtime_state, resume_command, resume_metadata_json, manually_named, chat_idle_since_at, chat_session_id, - pending_input_item_id, archived_at, settled_at, status_note, attention_requested_at, attention_message, last_turn_failed_at, - settle_override, snoozed_until, snoozed_at, woke_at, woke_reason - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + pending_input_item_id, archived_at, settled_at, status_note, attention_requested_at, attention_message, attention_source, last_turn_failed_at, + settle_override, settle_source, snoozed_until, snoozed_at, woke_at, woke_reason + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(id) do update set lane_id = excluded.lane_id, lane_name = excluded.lane_name, @@ -1156,8 +1158,10 @@ final class DatabaseService { status_note = excluded.status_note, attention_requested_at = excluded.attention_requested_at, attention_message = excluded.attention_message, + attention_source = excluded.attention_source, last_turn_failed_at = excluded.last_turn_failed_at, settle_override = excluded.settle_override, + settle_source = excluded.settle_source, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, woke_at = excluded.woke_at, @@ -1266,36 +1270,46 @@ final class DatabaseService { } else { sqlite3_bind_null(statement, 31) } - if let lastTurnFailedAt = session.lastTurnFailedAt { - try bindText(lastTurnFailedAt, to: statement, index: 32) + if let attentionSource = session.attentionSource { + try bindText(attentionSource, to: statement, index: 32) } else { sqlite3_bind_null(statement, 32) } - if let settleOverride = session.settleOverride { - try bindText(settleOverride, to: statement, index: 33) + if let lastTurnFailedAt = session.lastTurnFailedAt { + try bindText(lastTurnFailedAt, to: statement, index: 33) } else { sqlite3_bind_null(statement, 33) } - if let snoozedUntil = session.snoozedUntil { - try bindText(snoozedUntil, to: statement, index: 34) + if let settleOverride = session.settleOverride { + try bindText(settleOverride, to: statement, index: 34) } else { sqlite3_bind_null(statement, 34) } - if let snoozedAt = session.snoozedAt { - try bindText(snoozedAt, to: statement, index: 35) + if let settleSource = session.settleSource { + try bindText(settleSource, to: statement, index: 35) } else { sqlite3_bind_null(statement, 35) } - if let wokeAt = session.wokeAt { - try bindText(wokeAt, to: statement, index: 36) + if let snoozedUntil = session.snoozedUntil { + try bindText(snoozedUntil, to: statement, index: 36) } else { sqlite3_bind_null(statement, 36) } - if let wokeReason = session.wokeReason { - try bindText(wokeReason, to: statement, index: 37) + if let snoozedAt = session.snoozedAt { + try bindText(snoozedAt, to: statement, index: 37) } else { sqlite3_bind_null(statement, 37) } + if let wokeAt = session.wokeAt { + try bindText(wokeAt, to: statement, index: 38) + } else { + sqlite3_bind_null(statement, 38) + } + if let wokeReason = session.wokeReason { + try bindText(wokeReason, to: statement, index: 39) + } else { + sqlite3_bind_null(statement, 39) + } } } @@ -1887,8 +1901,8 @@ final class DatabaseService { s.title, s.status, s.started_at, s.ended_at, s.exit_code, s.transcript_path, s.head_sha_start, s.head_sha_end, s.last_output_preview, s.summary, s.runtime_state, s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at, - s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.last_turn_failed_at, - s.settle_override, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason + s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.attention_source, s.last_turn_failed_at, + s.settle_override, s.settle_source, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason from terminal_sessions s left join lanes l on l.id = s.lane_id where l.project_id = ? @@ -1912,8 +1926,8 @@ final class DatabaseService { s.title, s.status, s.started_at, s.ended_at, s.exit_code, s.transcript_path, s.head_sha_start, s.head_sha_end, s.last_output_preview, s.summary, s.runtime_state, s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at, - s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.last_turn_failed_at, - s.settle_override, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason + s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.attention_source, s.last_turn_failed_at, + s.settle_override, s.settle_source, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason from terminal_sessions s left join lanes l on l.id = s.lane_id where s.id = ? and (l.project_id = ? or l.id is null) @@ -1959,12 +1973,14 @@ final class DatabaseService { statusNote: stringValue(statement, index: 27), attentionRequestedAt: stringValue(statement, index: 28), attentionMessage: stringValue(statement, index: 29), - lastTurnFailedAt: stringValue(statement, index: 30), - settleOverride: stringValue(statement, index: 31), - snoozedUntil: stringValue(statement, index: 32), - snoozedAt: stringValue(statement, index: 33), - wokeAt: stringValue(statement, index: 34), - wokeReason: stringValue(statement, index: 35) + attentionSource: stringValue(statement, index: 30), + lastTurnFailedAt: stringValue(statement, index: 31), + settleOverride: stringValue(statement, index: 32), + settleSource: stringValue(statement, index: 33), + snoozedUntil: stringValue(statement, index: 34), + snoozedAt: stringValue(statement, index: 35), + wokeAt: stringValue(statement, index: 36), + wokeReason: stringValue(statement, index: 37) ) } @@ -1988,8 +2004,10 @@ final class DatabaseService { statusNote: row.statusNote, attentionRequestedAt: row.attentionRequestedAt, attentionMessage: row.attentionMessage, + attentionSource: row.attentionSource, lastTurnFailedAt: row.lastTurnFailedAt, settleOverride: row.settleOverride, + settleSource: row.settleSource, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, wokeAt: row.wokeAt, @@ -2090,6 +2108,7 @@ final class DatabaseService { sessionId: String, settledAt: String?? = nil, settleOverride: String?? = nil, + settleSource: String?? = nil, snoozedUntil: String?? = nil, snoozedAt: String?? = nil, wokeAt: String?? = nil, @@ -2100,6 +2119,7 @@ final class DatabaseService { sessionId: sessionId, settledAt: settledAt, settleOverride: settleOverride, + settleSource: settleSource, snoozedUntil: snoozedUntil, snoozedAt: snoozedAt, wokeAt: wokeAt, @@ -2112,6 +2132,7 @@ final class DatabaseService { sessionId: String, settledAt: String?? = nil, settleOverride: String?? = nil, + settleSource: String?? = nil, snoozedUntil: String?? = nil, snoozedAt: String?? = nil, wokeAt: String?? = nil, @@ -2132,6 +2153,7 @@ final class DatabaseService { assign("settled_at", settledAt) assign("settle_override", settleOverride) + assign("settle_source", settleSource) assign("snoozed_until", snoozedUntil) assign("snoozed_at", snoozedAt) assign("woke_at", wokeAt) @@ -2917,6 +2939,11 @@ final class DatabaseService { columnName: "attention_message", definition: "text" ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "attention_source", + definition: "text" + ) try ensureColumn( tableName: "terminal_sessions", columnName: "last_turn_failed_at", @@ -2932,6 +2959,11 @@ final class DatabaseService { columnName: "settle_override", definition: "text" ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "settle_source", + definition: "text" + ) try ensureColumn( tableName: "terminal_sessions", columnName: "snoozed_until", diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 2836005f5..2b2e4564a 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -8529,6 +8529,7 @@ final class SyncService: ObservableObject { resultShape: SessionLifecycleResultShape? = .envelope, settledAt: String?? = nil, settleOverride: String?? = nil, + settleSource: String?? = nil, snoozedUntil: String?? = nil, snoozedAt: String?? = nil, wokeAt: String?? = nil, @@ -8545,6 +8546,7 @@ final class SyncService: ObservableObject { sessionId: trimmed, settledAt: settledAt, settleOverride: settleOverride, + settleSource: settleSource, snoozedUntil: snoozedUntil, snoozedAt: snoozedAt, wokeAt: wokeAt, @@ -8567,6 +8569,7 @@ final class SyncService: ObservableObject { sessionId: trimmed, settledAt: restored(settledAt, previous.settledAt), settleOverride: restored(settleOverride, previous.settleOverride), + settleSource: restored(settleSource, previous.settleSource), snoozedUntil: restored(snoozedUntil, previous.snoozedUntil), snoozedAt: restored(snoozedAt, previous.snoozedAt), wokeAt: restored(wokeAt, previous.wokeAt), @@ -8609,7 +8612,8 @@ final class SyncService: ObservableObject { // the machine settled nothing. Mirrors the desktop `settleMany`. resultShape: .changedIdList, settledAt: .some(iso8601WithFractionalSecondsFormatter.string(from: Date())), - settleOverride: .some(nil) + settleOverride: .some(nil), + settleSource: .some("user") ) } @@ -8635,12 +8639,13 @@ final class SyncService: ObservableObject { // the desktop `unsettleMany`, which passes no `applied` predicate. resultShape: nil, settledAt: .some(nil), - settleOverride: nil + settleOverride: nil, + settleSource: .some(nil) ) } /// Set (or clear, with `nil`) the tri-state settle override. `"active"` is the - /// "keep active" pin that suppresses settle including the exit-0 auto-settle. + /// "keep active" pin that suppresses an explicit settle. func setSessionSettleOverride(sessionId: String, override: SessionSettleOverride?) async throws { try await sendSessionLifecycleCommand( sessionId: sessionId, diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index e27836a66..71bff01ef 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -616,7 +616,7 @@ struct WorkSessionListRow: View { } /// "Keep active" only means something once a row would otherwise read settled - /// — either declared or auto-settled by a clean exit. + /// — explicitly declared by an agent, user, operator, or merge policy. private var canKeepActive: Bool { lifecycleAvailable && session.resolvedSettleOverride != .active diff --git a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift index 5b9d15f0a..7c0eff655 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift @@ -80,12 +80,11 @@ func isWorkChatToolType(_ toolType: String?) -> Bool { /// The tri-state settle override persisted on `terminal_sessions.settle_override`. /// Mirrors the desktop `SessionSettleOverride`. Consulted at the declared-settle -/// tier, i.e. BEFORE the derived exit-0 rule. +/// tier. enum SessionSettleOverride: String, Equatable { /// Behaves exactly like a declared settle, without a `settled_at` stamp. case settled - /// Explicit keep-active pin: suppresses settle, derived AND declared, so a - /// clean PTY exit is not permanently stuck in the quiet tier. + /// Explicit keep-active pin: suppresses a declared settle. case active /// Tolerant parse of the persisted column — unknown/blank values mean "no @@ -98,17 +97,15 @@ enum SessionSettleOverride: String, Equatable { } /// Canonical precedence (highest first), identical to the desktop module: -/// 1. deterministic needs-input — pending item, "waiting-input" runtime, or -/// an explicit attention request (never outvoted by anything below), +/// 1. explicit/structured needs-input — pending item or an explicit +/// attention request (never outvoted by anything below), /// 2. settled — explicitly declared, or forced by a "settled" override; an /// "active" override suppresses this tier entirely, -/// 3. ended branch — stopped, failed, chat ready/ended, clean-exit settled -/// (which an "active" override also vetoes), +/// 3. ended branch — stopped, failed, chat ready/ended, /// 4. running-chat turn failure, /// 5. stale — status running but silent ≥ `sessionStaleAfterSeconds`, /// 6. idle — ready(chat)/idle, -/// 7. preview heuristic's needs_you upgrade, consulted LAST, -/// 8. running. +/// 7. running. /// /// Snooze is deliberately absent: it is a visibility overlay and never changes /// the phase. See `isSessionSnoozed(_:now:)`. @@ -117,6 +114,7 @@ func workCanonicalSessionState( runtimeState: String? = nil, toolType: String? = nil, pendingInputItemId: String? = nil, + providerStructuredInput: Bool = false, lastOutputPreview: String? = nil, lastActivityAt: String? = nil, exitCode: Int? = nil, @@ -125,7 +123,6 @@ func workCanonicalSessionState( attentionRequestedAt: String? = nil, lastTurnFailedAt: String? = nil, now: Date = Date(), - previewSuggestsNeedsInput: (String?) -> Bool = workSessionPreviewSuggestsNeedsInput, isChatTool: (String?) -> Bool = isWorkChatToolType ) -> CanonicalSessionState { let chat = isChatTool(toolType) @@ -140,7 +137,7 @@ func workCanonicalSessionState( // 1. Deterministic attention beats everything — including the failure and // stale checks below (an agent explicitly asking is actionable regardless). // An escalated ask outranks BOTH override values. - if !pending.isEmpty || runtimeLower == "waiting-input" || !attentionRequested.isEmpty { + if !pending.isEmpty || providerStructuredInput || !attentionRequested.isEmpty { return CanonicalSessionState(phase: .needsYou, badge: badgeByKind[.needsYou]) } @@ -149,10 +146,7 @@ func workCanonicalSessionState( // the turn streams, then re-settles at idle (settledAt survives background // wakes; only user activity clears it). // - // The tri-state override is consulted HERE, i.e. before the derived exit-0 - // rule below. "active" is an explicit keep-active pin: it beats derived - // settle (a clean exit) and a stale declared settle alike, so the row keeps a - // real lifecycle action instead of being stuck in the quiet tier forever. + // An "active" override suppresses a stale declared settle. let pinnedActive = override == .active let atRest = statusLower != "running" || runtimeLower == "idle" if !pinnedActive && atRest && (override == .settled || !settled.isEmpty) { @@ -192,11 +186,7 @@ func workCanonicalSessionState( return CanonicalSessionState(phase: .ready, badge: nil) } - // A non-chat clean exit is the process declaring the work done. An "active" - // override vetoes it — that is the whole point of the pin. - if exitCode == 0 && !pinnedActive { - return CanonicalSessionState(phase: .settled, badge: nil) - } + // Process completion is not a lifecycle declaration. return CanonicalSessionState(phase: .ended, badge: nil) } @@ -219,69 +209,9 @@ func workCanonicalSessionState( : CanonicalSessionState(phase: .idle, badge: nil) } - // 7. Preview heuristic LAST: it may only upgrade running → needs_you. - if previewSuggestsNeedsInput(lastOutputPreview) { - return CanonicalSessionState(phase: .needsYou, badge: badgeByKind[.needsYou]) - } - return CanonicalSessionState(phase: .running, badge: nil) } -// MARK: - Preview-text heuristic (mirrors desktop `runningSessionNeedsAttention`) - -private let workNeedsInputPatterns: [NSRegularExpression] = { - let specs: [(String, Bool)] = [ - ("\\b(?:waiting|awaiting)\\b.{0,28}\\b(?:input|confirmation|response|prompt)\\b", true), - ("\\b(?:press|hit)\\b.{0,14}\\b(?:enter|return|any key)\\b", true), - ("\\b(?:select|choose|pick)\\b.{0,28}\\b(?:option|number|profile|item)\\b", true), - ("\\b(?:confirm|continue|proceed|retry)\\b.{0,24}\\?", true), - ("\\((?:y/n|yes/no)\\)", true), - ("\\[(?:y/n|yes/no)\\]", true), - ("\\b(?:enter|type)\\b.{0,24}:\\s*$", true), - // Claude Code tool-approval / plan-mode prompts: "(Y)es / (N)o". - ("\\([Yy]\\)\\w*\\s*.{0,12}\\([Nn]\\)\\w*", false), - ("\\ballow\\b.{0,40}\\?\\s", true), - ] - return specs.compactMap { pattern, caseInsensitive in - let options: NSRegularExpression.Options = caseInsensitive ? [.caseInsensitive] : [] - return try? NSRegularExpression(pattern: pattern, options: options) - } -}() - -// ESC-introduced control sequences (OSC/CSI) plus a two-char escape sweep so a -// raw terminal-tail preview reads as plain text before pattern matching. -private let workAnsiEscapeRegexes: [NSRegularExpression] = { - // ICU escape syntax (\uHHHH) — not Swift/JS \u{..}. Matches ESC-introduced OSC/CSI sequences plus a two-char escape sweep. - [ - "\\u001B\\][^\\u0007]*(?:\\u0007|\\u001B\\\\)", - "\\u001B\\[[0-?]*[ -/]*[@-~]", - "\\u001B(?:[@-Z\\\\-_]|[0-9=>])", - ].compactMap { try? NSRegularExpression(pattern: $0) } -}() - -/// The preview-text needs-input heuristic, ported from the desktop -/// `runningSessionNeedsAttention`. Consulted LAST by `workCanonicalSessionState` -/// and can only upgrade a plain running session to needs_you. -func workSessionPreviewSuggestsNeedsInput(_ preview: String?) -> Bool { - guard var text = preview, !text.isEmpty else { return false } - let fullRange = { NSRange(text.startIndex.. CanonicalSessionState { - let statusLower = session.status.lowercased() - let runtimeLower = session.runtimeState.lowercased() - let isAwaiting = summary?.awaitingInput == true - || runtimeLower == "waiting-input" - || statusLower == "awaiting-input" - || statusLower == "awaiting_input" - let effectiveRuntime = isAwaiting ? "waiting-input" : session.runtimeState return workCanonicalSessionState( status: session.status, - runtimeState: effectiveRuntime, + runtimeState: session.runtimeState, toolType: session.toolType, - pendingInputItemId: session.pendingInputItemId, + pendingInputItemId: summary?.pendingInputItemId ?? session.pendingInputItemId, + providerStructuredInput: summary?.awaitingInput == true || session.attentionSource == "provider_structured", lastOutputPreview: session.lastOutputPreview, lastActivityAt: workSessionStaleActivityTimestamp(session: session, summary: summary), exitCode: session.exitCode, @@ -442,10 +366,8 @@ func isSessionSnoozed(_ session: SessionSnoozeState, now: Date = Date()) -> Bool /// Mirrors the desktop `isSessionFiledAsSnoozed`. Snooze is a visibility overlay /// and an overlay must YIELD to a session actually blocked on the user, or the /// "Until I'm asked" window (~100 years) buries the very row whose hand is -/// raised. Only chat paths ever wrote an early wake; a tracked CLI row's -/// needs-input state is derived (runtime "waiting-input" / preview heuristic) -/// with no event to hook, so deriving the filing rule from the phase is what -/// covers chat and CLI identically. +/// raised. Explicit and structured requests project to `needsYou`, so deriving +/// the filing rule from the phase covers chat and CLI identically. /// /// Deliberately separate from `isSessionSnoozed`, which stays the raw /// two-column read that chips, menus, and wake labels want, and the canonical @@ -608,9 +530,7 @@ extension TerminalSessionSummary { /// Whether a list may file this row into its quiet Snoozed tail. A row whose /// canonical phase is `needsYou` is filed normally even while snoozed: the - /// overlay yields to a raised hand, which is the only thing that makes - /// "Until I'm asked" honest for tracked CLI rows (no early-wake event exists - /// for them — their needs-input state is derived). + /// overlay yields to a raised hand, which keeps "Until I'm asked" honest. func isFiledAsSnoozed(summary: AgentChatSessionSummary?, now: Date = Date()) -> Bool { guard isSnoozed(now: now) else { return false } let phase = workCanonicalSessionState(session: self, summary: summary, now: now).phase diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 92dba660f..874e5ffc2 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -41,24 +41,6 @@ final class WorkSessionCanonicalStateTests: XCTestCase { lastActivityAt: silentFor(sessionStaleAfterSeconds + 60), exitCode: nil ), - Case( - name: "waiting-input beats stale", - status: "running", - runtimeState: "waiting-input", - toolType: "codex", - pendingInputItemId: nil, - lastActivityAt: silentFor(sessionStaleAfterSeconds + 60), - exitCode: nil - ), - Case( - name: "waiting-input beats calm idle chat preview", - status: "running", - runtimeState: "waiting-input", - toolType: "codex-chat", - pendingInputItemId: nil, - lastActivityAt: iso(now), - exitCode: nil - ), Case( name: "pendingInput beats a non-zero exit (deterministic ask still actionable)", status: "ended", @@ -86,6 +68,29 @@ final class WorkSessionCanonicalStateTests: XCTestCase { } } + func testRuntimeWaitingInputAloneDoesNotRaiseAttention() { + XCTAssertEqual( + workCanonicalSessionState( + status: "running", + runtimeState: "waiting-input", + toolType: "codex", + lastActivityAt: silentFor(sessionStaleAfterSeconds + 60), + now: now + ).phase, + .stale + ) + XCTAssertEqual( + workCanonicalSessionState( + status: "running", + runtimeState: "waiting-input", + toolType: "codex-chat", + lastActivityAt: iso(now), + now: now + ).phase, + .running + ) + } + func testStoppedDisposedSessionsAreNotFailed() { let disposed = workCanonicalSessionState(status: "disposed", runtimeState: "killed", toolType: "codex", exitCode: nil, now: now) XCTAssertEqual(disposed.phase, .stopped) @@ -106,9 +111,9 @@ final class WorkSessionCanonicalStateTests: XCTestCase { let killed = workCanonicalSessionState(status: "ended", runtimeState: "killed", toolType: "codex", exitCode: nil, now: now) XCTAssertEqual(killed.phase, .failed) - // Clean exit (0) → settled: the process itself declared completion. + // Clean exit (0) → ended: process completion is not a lifecycle declaration. let clean = workCanonicalSessionState(status: "ended", runtimeState: "exited", toolType: "codex", exitCode: 0, now: now) - XCTAssertEqual(clean.phase, .settled) + XCTAssertEqual(clean.phase, .ended) XCTAssertNil(clean.badge) // A non-zero exit while still running is ignored (failure is an ended-only @@ -157,9 +162,9 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertEqual(state.phase, .stale) } - // MARK: - Preview heuristic (running → needs_you, LAST) + // MARK: - Prompt previews are observational only - func testPreviewHeuristicUpgradesRunningToNeedsYou() { + func testPromptPreviewLeavesRunningCalm() { let prompts = ["Continue? (y/n)", "Press enter to proceed", "Allow this action? (Y)es / (N)o"] for prompt in prompts { let state = workCanonicalSessionState( @@ -170,7 +175,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { lastActivityAt: iso(now), now: now ) - XCTAssertEqual(state.phase, .needsYou, "prompt: \(prompt)") + XCTAssertEqual(state.phase, .running, "prompt: \(prompt)") } } @@ -185,7 +190,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { exitCode: 0, now: now ) - XCTAssertEqual(state.phase, .settled) + XCTAssertEqual(state.phase, .ended) XCTAssertNil(state.badge) } @@ -246,6 +251,28 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertEqual(badge?.kind, .needsYou) } + func testRowWrapperMapsChatSummaryPendingItemToNeedsYou() { + let session = makeSession(status: "running", runtimeState: "running", toolType: "codex-chat") + let summary = makeChatSummary( + status: "active", + awaitingInput: false, + pendingInputItemId: "provider-question-1" + ) + XCTAssertEqual( + workCanonicalSessionState(session: session, summary: summary, now: now).phase, + .needsYou + ) + } + + func testRowWrapperMapsHydratedProviderSourceToNeedsYou() { + var session = makeSession(status: "running", runtimeState: "waiting-input", toolType: "codex-chat") + session.attentionSource = "provider_structured" + XCTAssertEqual( + workCanonicalSessionState(session: session, summary: nil, now: now).phase, + .needsYou + ) + } + func testCapsuleBadgeSurfacesFailedExit() { let session = makeSession(status: "ended", runtimeState: "exited", toolType: "codex", exitCode: 130) let badge = workSessionCapsuleBadge(session: session, summary: nil, now: now) @@ -527,7 +554,11 @@ final class WorkSessionCanonicalStateTests: XCTestCase { ) } - private func makeChatSummary(status: String, awaitingInput: Bool?) -> AgentChatSessionSummary { + private func makeChatSummary( + status: String, + awaitingInput: Bool?, + pendingInputItemId: String? = nil + ) -> AgentChatSessionSummary { AgentChatSessionSummary( sessionId: "chat-1", laneId: "lane-1", @@ -568,7 +599,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { lastOutputPreview: nil, summary: nil, awaitingInput: awaitingInput, - pendingInputItemId: nil, + pendingInputItemId: pendingInputItemId, threadId: nil, requestedCwd: nil ) @@ -661,23 +692,21 @@ final class WorkSessionCanonicalStateTests: XCTestCase { // MARK: - Settle override tri-state (desktop sessionCanonicalState.ts parity) // - // Mirrors `describe("settle override tri-state")`. The bug the override - // exists to fix: exit 0 auto-settles WITHOUT stamping settled_at, so the row - // had no lifecycle action at all and was pinned to the quiet tier forever. + // Mirrors `describe("settle override tri-state")`. - func testNullOverrideLeavesDerivedExitZeroAutoSettleIntact() { + func testNullOverrideLeavesCleanExitEnded() { XCTAssertEqual( - cleanExitState(settleOverride: nil).phase, .settled, - "no override: a clean exit still auto-settles" + cleanExitState(settleOverride: nil).phase, .ended, + "no override: a clean exit remains ended" ) - XCTAssertEqual(cleanExitState(settleOverride: "").phase, .settled, "blank override reads as none") + XCTAssertEqual(cleanExitState(settleOverride: "").phase, .ended, "blank override reads as none") XCTAssertEqual( - cleanExitState(settleOverride: "nonsense").phase, .settled, + cleanExitState(settleOverride: "nonsense").phase, .ended, "unknown override values must not invent a state" ) } - func testActiveOverrideBeatsDerivedExitZeroRule() { + func testActiveOverrideKeepsCleanExitEnded() { let result = cleanExitState(settleOverride: "active") XCTAssertEqual(result.phase, .ended) XCTAssertNil(result.badge) @@ -798,9 +827,8 @@ final class WorkSessionCanonicalStateTests: XCTestCase { // // Regression: an "Until I'm asked" snooze (~100 years) hid a needs-you row // forever. Every early-wake trigger was chat-only, and a tracked CLI row's - // needs-input state is DERIVED (runtime "waiting-input" / preview heuristic) - // with no event to hook — so the FILING rule, not an event, is what has to - // bring the row back. + // needs-input state is explicit or provider-structured, so the filing rule + // must bring a genuinely blocked row back. /// Snoozed "until I'm asked": the deadline that used to bury a blocked row. private var indefiniteSnooze: SessionSnoozeState { @@ -813,14 +841,14 @@ final class WorkSessionCanonicalStateTests: XCTestCase { func testSnoozedNeedsYouRowIsNotFiledAsSnoozed() { XCTAssertFalse(isSessionFiledAsSnoozed(indefiniteSnooze, phase: .needsYou, now: now)) - // A tracked CLI row blocked at a permission prompt: the phase is derived - // from the runtime, and no early-wake event exists for it at all. - let blocked = snoozedSession( + // A tracked CLI row with an explicit pending item is actionable. + var blocked = snoozedSession( untilOffset: TimeInterval(workSnoozeIndefiniteDays) * 86_400, atOffset: -60, status: "running", runtimeState: "waiting-input" ) + blocked.pendingInputItemId = "item-1" XCTAssertEqual( workCanonicalSessionState(session: blocked, summary: nil, now: now).phase, .needsYou @@ -869,6 +897,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { runtimeState: "waiting-input" ) blocked.id = "s-blocked" + blocked.pendingInputItemId = "item-1" var calm = snoozedSession(untilOffset: 3_600, atOffset: -60) calm.id = "s-calm" diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index c1b9aeafc..0041a1b0f 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -76,7 +76,9 @@ into the Work list: - `ade chat unsettle` returns the row to the active lifecycle. `buildAdeCliGuidance` exposes these commands in the injected agent prompt so the -state change is an explicit agent decision, not a transcript-text heuristic. +state change is an explicit agent decision, not a transcript-text or terminal +marker heuristic. Settlement means the original objective is fully delivered +and verified; a completed turn or clean CLI exit alone remains active/ended. SDK-backed Claude, Codex, Cursor, Droid, and OpenCode chats receive `ADE_CHAT_SESSION_ID` plus `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for an orchestration lead), and their persistent guidance names the concrete diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index a2b4f022e..6f3650403 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1596,7 +1596,7 @@ iOS mirrors `apps/desktop/src/shared/sessionCanonicalState.ts` in `WorkSessionCanonicalState.swift`. The precedence and visual vocabulary match desktop: deterministic approval/question/`ade chat ask` is `Needs you`; an explicit settle applies only while the session is at rest; failed and stopped -remain distinct; a non-chat exit code 0 is settled; and a running row with a +remain distinct; a clean process exit is merely ended; and a running row with a real activity timestamp at least three hours old is `Stale`, not settled. The replicated `terminal_sessions` lifecycle columns flow through @@ -1616,11 +1616,9 @@ The phone carries the full lifecycle, not a read-only view of it. Two mechanisms sit on top of the derivation and both are mirrored here. `settle_override` is a tri-state (`null | "settled" | "active"`) consulted at -the **declared**-settle tier — before the derived "exit code 0 means done" -rule. `"settled"` behaves like a declared settle; `"active"` is an explicit -keep-active pin and is the only way to un-settle a clean-exit row, because such -a row derives its settle and has no `settled_at` for unsettle to clear; `null` -returns the row to the derived rules. The override is cleared on real activity +the declared-settle tier. `"settled"` behaves like a declared settle; +`"active"` is an explicit keep-active pin; `null` returns the row to the +persisted lifecycle state. The override is cleared on real activity at the same write sites that clear `settled_at`. Snooze is a synced **visibility overlay**, never a lifecycle phase — the Swift @@ -1634,7 +1632,7 @@ the row. Waking stamps `woke_at` / `woke_reason` (`timer | needs_you | error | turn_complete | manual`) so the row can explain its return until it is visited, at which point `clearWokeMarker` drops it. Early wake also fires for a session that ends in failure (non-zero exit, or a -`"failed"` end with no exit code); a clean exit 0 is the settled path and never +`"failed"` end with no exit code); a clean exit 0 is not a failure and never wakes. Filing yields to a raised hand: `isSessionFiledAsSnoozed(session, phase)` returns false for a `needs_you` phase, so a snoozed row blocked on the user stays in its normal section rather than disappearing into the snoozed tail. diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index a27051f35..38f82cb91 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -206,14 +206,13 @@ Shared types and IPC: surface and the `terminal` ADE action domain. - `apps/desktop/src/shared/sessionCanonicalState.ts` — canonical phase and status-bucket derivation shared by desktop and mirrored on iOS. Precedence is - deterministic attention → declared settle at rest → stopped/failure/clean - exit → stale/running/resting. It is the source of the one-word row capsule, + explicit/structured attention → declared settle at rest → stopped/failure/ + clean exit → stale/running/resting. It is the source of the one-word row capsule, Work grouping, and the loud-vs-quiet attention split. The **settle override** (`terminal_sessions.settle_override`, `null | "settled" | "active"`) is consulted at the declared-settle tier, i.e. - *before* the derived exit-0 rule: `"settled"` behaves like a declared settle, - and `"active"` is an explicit keep-active pin so a clean PTY exit is no - longer auto-settled with no `settled_at` and therefore no lifecycle action. + `"settled"` behaves like a declared settle, and `"active"` is an explicit + keep-active pin that suppresses a declared settle. It is cleared on real activity at the same write sites that clear `settled_at` (PTY output, `touchSessionActivity`, turn start, attention request, turn failure). @@ -231,9 +230,8 @@ Shared types and IPC: `woke_reason` so the UI can show a "woke" marker until the row is visited. A session that **ends in failure** (non-zero exit, or a `"failed"` end that never got an exit code) is an early-wake trigger too, at the `end` / - `reconcileStaleRunningSessions` write sites — a clean exit 0 is the settled - path and never wakes. Because CLI "needs input" is purely *derived* (there is - no event to hook), filing also yields to it: `isSessionFiledAsSnoozed` + `reconcileStaleRunningSessions` write sites. Filing yields to an explicit or + provider-structured hand raise: `isSessionFiledAsSnoozed` (`shared/sessionCanonicalState.ts`, mirrored in `WorkSessionCanonicalState.swift`) returns false for a `needs_you` phase, so a snoozed row that is blocked on the user stays in its normal section on every @@ -960,10 +958,9 @@ Renderer surfaces: a pointer edge. The menu carries one exhaustive lifecycle block holding every action that changes where the sidebar files the row: **Snooze…** expands into the four durations (or **Wake now**, with the wake label, when the row is - already snoozed), and settle actions branch on whether the settle was - *declared* or *derived*. A declared settle has a `settled_at` for Unsettle to - clear; a derived settle — a clean exit 0, or `settleOverride: "settled"` — - has nothing to clear, so the keep-active pin is its only unsettle. A row + already snoozed), and settle actions operate only on explicit declarations. + A declared settle has a `settled_at` for Unsettle to clear, while + `settleOverride` can explicitly pin either state. A row reaching the end of that block with nothing rendered would be a row the user cannot un-hide, which is why the block is kept exhaustive. - `apps/desktop/src/renderer/lib/sessionListCache.ts` — shared renderer @@ -1222,8 +1219,8 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. 6. **Classify and settle** — `canonicalSessionState()` projects persisted facts in a fixed order. Deterministic pending input or `ade chat ask` is - loud `needs_you`; an explicit `settledAt` wins at rest; disposed and failed - sessions remain distinct; a non-chat exit code 0 auto-classifies as settled; + loud `needs_you`; an explicit `settledAt` wins at rest; disposed, failed, + and cleanly ended sessions remain distinct; exit code 0 does not settle; a still-running session with no activity for three hours is `stale` but is not settled. Chat idle between turns is the quiet `ready` phase. Explicit Settle/Unsettle is available from the session context menu and multi-select @@ -1240,17 +1237,21 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. because the process is active again. Scheduled/background wakes are different: an explicitly settled chat shows running while the unattended turn streams, retains `settledAt`, and returns to Settled when it rests. - `ade chat ask` clears settle, persists the blocking question, marks a live - tracked CLI as waiting-input, and publishes a time-sensitive push; the next - user turn clears it. `ade chat note ""` clears only the status line. + `ade chat ask` clears settle, persists the blocking question and its + `agent_explicit` provenance, marks a live tracked CLI as waiting-input, and + publishes a time-sensitive push; the next user turn clears it. Provider + structured input carries its own pending item id. OSC markers and + prompt-looking output never create `Needs you`. `ade chat note ""` clears + only the status line. Beyond the binary settle there is a tri-state **settle override** (`terminal_sessions.settle_override`). `"settled"` behaves like a declared - settle; `"active"` is the explicit keep-active pin, and it is the only way to - hold a clean-exit row in the active tier because those rows derive their - settle and have no `settled_at` for unsettle to clear; `null` returns the row - to the derived rules. An explicit settle drops a stale `"active"` pin, and - unsettle drops only a `"settled"` pin. + settle; `"active"` is the explicit keep-active pin; `null` returns the row + to the declared rules. An explicit settle drops a stale `"active"` pin, and + unsettle drops only a `"settled"` pin. `attention_source` and + `settle_source` record whether the current declaration came from an agent, + user, provider request, operator, or lane PR merge; Session Info displays + that provenance. 8. **Snooze and early wake** — snooze is a synced **visibility overlay**, never a lifecycle phase: `canonicalSessionState()` does not read it, only the @@ -1266,10 +1267,10 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. the row has been visited. Two halves keep the hand-raise contract honest for **non-chat** sessions, - whose needs-input state is derived and whose failures are not chat turns: + whose failures are not chat turns: a session that ends in failure (non-zero exit code, or a `"failed"` end with no exit code) wakes with reason `error` at the end write sites — exit 0 does - not, because a clean exit is the settled path; and every Snoozed group files + not because it is not a failure; and every Snoozed group files through `isSessionFiledAsSnoozed(session, phase)`, which yields to a `needs_you` phase, so a snoozed row blocked on the user is never hidden even under an "Until I'm asked" (~100 year) deadline. The desktop flat list and @@ -1411,7 +1412,7 @@ Sessions: | `ade.sessions.updateMeta` | rename (sets `manuallyNamed`), pin, edit goal, update resume metadata | | `ade.sessions.settle` / `.unsettle` | Set or clear `settled_at` for one session. Settle accepts `{ outcome?, dismissPendingInput? }`; dismissal is handled atomically by `settleTerminalSession` before the settle mutation. | | `ade.sessions.settleMany` / `.unsettleMany` | bulk lifecycle mutation used by the Work multi-select footer; settle returns only newly-settled ids for precise undo | -| `ade.sessions.setSettleOverride` | tri-state settle pin: `"settled"` behaves like a declared settle, `"active"` is the keep-active pin that beats the derived exit-0 auto-settle, `null` hands the row back to the derived rules | +| `ade.sessions.setSettleOverride` | tri-state settle pin: `"settled"` behaves like a declared settle, `"active"` suppresses a declared settle, `null` hands the row back to declared lifecycle state | | `ade.sessions.snooze` / `.snoozeMany` | set `snoozed_until` (+ `snoozed_at`) and clear any stale woke marker. Snooze is a **visibility overlay**, not a lifecycle phase — `canonicalSessionState()` never reads it. Bulk returns the ids it changed. | | `ade.sessions.wake` / `.wakeMany` | clear the snooze now and record `woke_reason` (`timer \| needs_you \| error \| turn_complete \| manual`, default `manual`). Bulk returns the ids that were actually snoozed. | | `ade.sessions.clearWokeMarker` | drop `woke_at`/`woke_reason` once the user has visited the row | @@ -1485,11 +1486,9 @@ runtime and agent chat runtime both layer the same identity envs is not running it. Similarly, only `markLastTurnFailed` applies the strictly-newer-than-`snoozed_at` comparison — drop it and the error the user snoozed on top of instantly re-wakes the row, making snooze a no-op. -- **A derived settle has no `settled_at` to clear.** Clean exit-0 rows and - `settleOverride: "settled"` rows are settled by derivation, so plain Unsettle - does nothing to them; the `"active"` keep-active pin is their only unsettle. - Any new lifecycle surface must branch on declared-vs-derived or it will ship a - row the user cannot get out of the quiet tier. +- **Process exit is not settlement.** A clean exit-0 row remains ended until an + agent/user declaration or the enabled PR-merge policy settles it. New + lifecycle surfaces must not infer task completion from process mechanics. - **Settlement is not a pending-input response.** Never restore the old renderer sequence of `respondToInput` then settle. A provider decline may resume work, Codex plan declines may stage a revision, and a stale persisted diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md index 29d92c64e..d4b6f701d 100644 --- a/docs/features/terminals-and-sessions/pty-and-sessions.md +++ b/docs/features/terminals-and-sessions/pty-and-sessions.md @@ -310,7 +310,9 @@ non-empty line, capped at 220 chars. Preview is flushed to runtime state changes, when the preview changes more than 1.2 s after the previous signal, or as a 10 s heartbeat. Runtime states: `running`, `waiting-input`, `idle`, `exited`, `killed`. `idle` is -inferred from OSC 133 prompt markers. +inferred from output silence. OSC 133 `B`/`C` markers may confirm running, but +prompt markers never infer `waiting-input`; only explicit or +provider-structured lifecycle requests raise attention. ### Process tree termination diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index d831e7b9f..9a290dc9b 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -760,11 +760,10 @@ Every row also carries one exhaustive lifecycle block holding each action that changes where the sidebar files it. **Snooze…** expands in place into the four durations (`1 hour`, `Until this evening`, `Until tomorrow 9am`, `Until I'm asked`); an already-snoozed row instead shows **Wake now** with its -wake label. Settle actions branch on declared vs derived: a declared settle has -`settledAt` for **Unsettle** to clear and can additionally be pinned with -**Keep active**, while a derived settle — a clean exit 0, or -`settleOverride: "settled"` — has nothing to clear, so writing the `"active"` -keep-active pin *is* its unsettle. The block is kept exhaustive on purpose: a +wake label. Settle actions operate on explicit declarations: a declared settle +has `settledAt` for **Unsettle** to clear and can additionally be pinned with +**Keep active**, while `settleOverride` explicitly pins either state. The block +is kept exhaustive on purpose: a row that reaches the end of it with nothing rendered is a row the user cannot un-hide. All writes go through `components/terminals/sessionLifecycleActions.ts`, which also owns the