From 79e2549c87dbaf55d68182222d8197e62133d889 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:51:01 -0400 Subject: [PATCH 01/16] fix: require explicit session attention and settlement --- .../push/pushPublisherService.test.ts | 2 +- .../src/services/push/pushPublisherService.ts | 11 +- .../services/sync/syncRemoteCommandService.ts | 12 +- .../__tests__/sessionLifecycle.test.tsx | 9 +- apps/ade-cli/src/tuiClient/adeApi.ts | 6 +- .../ade-cli/src/tuiClient/sessionLifecycle.ts | 11 +- .../main/services/adeActions/registry.test.ts | 2 +- .../src/main/services/adeActions/registry.ts | 5 +- .../services/ai/tools/ctoOperatorTools.ts | 7 +- .../lanes/laneListSnapshotService.test.ts | 20 ++-- .../services/lanes/laneListSnapshotService.ts | 37 +----- .../src/main/services/prs/prAsync.test.ts | 2 + .../prs/prMergeAutoSettlementService.ts | 1 + .../sessions/chatSessionProjection.ts | 1 + .../services/sessions/sessionService.test.ts | 11 +- .../main/services/sessions/sessionService.ts | 102 +++++++++++------ .../sessions/settleTerminalSession.ts | 7 +- .../src/main/services/state/kvDb.test.ts | 10 +- apps/desktop/src/main/services/state/kvDb.ts | 7 +- .../main/utils/terminalSessionSignals.test.ts | 4 +- .../src/main/utils/terminalSessionSignals.ts | 4 - .../components/lanes/laneAgents.test.ts | 8 +- .../renderer/components/lanes/laneAgents.ts | 5 +- .../terminals/SessionContextMenu.test.tsx | 46 ++------ .../terminals/SessionContextMenu.tsx | 6 - .../terminals/SessionInfoPopover.tsx | 15 +++ .../terminals/sessionLifecycleActions.ts | 17 +-- .../terminals/useWorkSessions.test.ts | 24 ++-- .../components/terminals/useWorkSessions.ts | 5 +- .../renderer/lib/terminalAttention.test.ts | 35 ++---- .../src/renderer/lib/terminalAttention.ts | 18 --- apps/desktop/src/shared/adeCliGuidance.ts | 5 +- .../src/shared/sessionCanonicalState.test.ts | 32 ++---- .../src/shared/sessionCanonicalState.ts | 62 +++------- apps/desktop/src/shared/types/sessions.ts | 22 ++-- apps/desktop/src/shared/types/sync.ts | 2 +- apps/ios/ADE/Models/RemoteModels.swift | 10 +- apps/ios/ADE/Resources/DatabaseBootstrap.sql | 6 + apps/ios/ADE/Services/Database.swift | 78 +++++++++---- apps/ios/ADE/Services/SyncService.swift | 2 +- .../ADE/Views/Work/WorkRootComponents.swift | 2 +- .../Work/WorkSessionCanonicalState.swift | 108 +++--------------- docs/features/agents/README.md | 4 +- .../sync-and-multi-device/ios-companion.md | 12 +- .../features/terminals-and-sessions/README.md | 59 +++++----- .../pty-and-sessions.md | 4 +- .../terminals-and-sessions/ui-surfaces.md | 9 +- 47 files changed, 361 insertions(+), 506 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 9f8f3fe4f..90ffc081c 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1843,7 +1843,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(); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index ec30d9900..2d4c5f049 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1548,10 +1548,9 @@ 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; @@ -1561,9 +1560,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { // `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 diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 2fa15c70f..6a1aa15fc 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3494,11 +3494,11 @@ 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; lastTurnFailedAt?: string | null; }): "running" | "awaiting-input" | "ended" { // Mirrors the settled-tier precedence in shared/sessionCanonicalState.ts: @@ -3507,21 +3507,13 @@ 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) 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"; diff --git a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx index 71cee4669..7adcf78c8 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx @@ -313,16 +313,17 @@ 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(); - expect(sessionLifecycleMarker({ ...snooze, awaitingInput: true }, { 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 })?.kind).toBe("snoozed"); expect(sessionLifecycleMarker({ ...snooze, pendingInputItemId: "item-1" }, { nowMs: NOW })).toBeNull(); expect(sessionLifecycleMarker( { ...snooze, attentionRequestedAt: "2026-07-26T11:59:00.000Z" }, 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..198a34942 100644 --- a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts +++ b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts @@ -203,16 +203,11 @@ 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; 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.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 694875446..7f2250e52 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) { } 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("does not count stale chat awaiting state without an explicit pending item", async () => { const services = { ...makeHarness({ id: "chat-1", @@ -117,8 +117,8 @@ describe("laneListSnapshotService", () => { ); expect(snapshot?.runtime).toMatchObject({ - bucket: "awaiting-input", - awaitingInputCount: 1, + bucket: "running", + awaitingInputCount: 0, pendingInputCount: 0, }); }); @@ -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..0e2f43983 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -101,33 +101,14 @@ 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; lastTurnFailedAt?: string | null; }): "running" | "awaiting-input" | "ended" { // `ade chat ask` escalation outranks everything; a declared settle maps to @@ -136,25 +117,13 @@ 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) 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"; } 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/sessions/chatSessionProjection.ts b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts index 65c002beb..6f5f076b0 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") { diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index f0a838be7..cb6e56281 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", diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 6c0ea74de..015401473 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,14 @@ 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`. - */ + /** Explicit settle override, cleared with `settled_at` on real activity. */ setSettleOverride(sessionId: string, override: SessionSettleOverride | null): boolean { const normalized = override == null ? null : normalizeSettleOverride(override); 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 = ? where id = ?", + [normalized, normalized === "settled" ? "user" : null, id], + ); }); }, @@ -1465,8 +1485,8 @@ 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 = ? where id in (${updatePlaceholders})`, + [normalized, normalized === "settled" ? "user" : null, ...present], ); for (const id of present) { emitChanged({ sessionId: id, reason: "meta-updated" }); @@ -1482,8 +1502,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 +1515,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 +1660,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 +1686,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 +1700,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 +1729,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..6816f5934 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts @@ -98,13 +98,19 @@ 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 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..2eec41898 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -38,9 +38,10 @@ function chatActivity(summary: AgentChatSessionSummary): LaneAgentActivity { } function cliActivity(summary: TerminalSessionSummary): LaneAgentActivity { + if (summary.pendingInputItemId || summary.attentionRequestedAt) 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 +87,7 @@ function cliAgentFrom(summary: TerminalSessionSummary): LaneAgent { modelId: null, providerLabel: cliProviderLabel(summary.toolType), activity: cliActivity(summary), - lastHint: summary.runtimeState === "waiting-input" + lastHint: summary.pendingInputItemId || summary.attentionRequestedAt ? "Awaiting your input" : summary.summary?.trim() || summary.lastOutputPreview?.trim() || null, lastActivityAt: summary.endedAt ?? summary.startedAt, 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..45ea198da 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,12 @@ 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 + ? ["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/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index 4ee9005c7..0df4c21bf 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,11 +92,11 @@ 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", @@ -106,26 +105,6 @@ describe("terminalAttention", () => { }); }); - describe("sessionNeedsUserInput", () => { - it("keeps idle agent chats out of CLI-style prompt detection", () => { - expect(sessionNeedsUserInput({ - status: "running", - lastOutputPreview: "Completed response", - runtimeState: "idle", - 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", - })).toBe(true); - }); - }); - describe("sessionStatusBucket", () => { it("keeps idle ready agent chats in the awaiting-input bucket for dots and filters", () => { expect(sessionStatusBucket({ @@ -148,14 +127,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..8f1cfb538 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -150,7 +150,6 @@ export function sessionCanonicalUiState(session: SessionCanonicalUiInput): Canon attentionRequestedAt: session.attentionRequestedAt ?? null, lastTurnFailedAt: session.lastTurnFailedAt ?? null, nowMs: session.nowMs, - previewSuggestsNeedsInput: runningSessionNeedsAttention, isChatTool: isChatToolType, }); } @@ -171,22 +170,6 @@ 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; @@ -195,7 +178,6 @@ export function sessionNeedsChatTabHighlight(args: { attentionRequestedAt?: string | null; }): boolean { if (!isChatToolType(args.toolType)) return false; - if (args.runtimeState === "waiting-input") return true; if (args.pendingInputItemId) 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..15724646a 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -18,8 +18,9 @@ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ "Session status protocol (keeps the Work sidebar truthful):", '- 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 original objective is fully closed, not merely that the current turn or CLI process ended. 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 promised follow-up remains; and the next useful user message would start new scope or explicitly reopen the completed result.", + "- If the user could reasonably say “continue”, “verify”, “what remains?”, answer an open question, or refine the current result, do not settle.", "- 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.', "- If uncertain, do not settle; update `ade chat note` and leave it in Your move.", diff --git a/apps/desktop/src/shared/sessionCanonicalState.test.ts b/apps/desktop/src/shared/sessionCanonicalState.test.ts index dcf782a57..744aea6a7 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,11 @@ 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"], + ["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 +42,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 +85,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 +153,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 +173,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..16ec7610e 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.ts @@ -74,17 +74,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 +86,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 +99,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 +117,7 @@ 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) { return { phase: "needs_you", badge: BADGE_BY_KIND.needs_you }; } @@ -145,10 +128,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 +169,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 +193,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 +204,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 +260,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..17bd76067 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, @@ -2917,6 +2935,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 +2955,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..411c35e69 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -8640,7 +8640,7 @@ final class SyncService: ObservableObject { } /// 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..39609ab6f 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:)`. @@ -125,7 +122,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 +136,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 || !attentionRequested.isEmpty { return CanonicalSessionState(phase: .needsYou, badge: badgeByKind[.needsYou]) } @@ -149,10 +145,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 +185,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 +208,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, lastOutputPreview: session.lastOutputPreview, @@ -442,10 +364,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 +528,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/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 From 0dbcf60c78434c54f02ac7fd68c9f06be3fe22ae Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:06:41 -0400 Subject: [PATCH 02/16] ship: fix lifecycle CI and preserve settle provenance --- apps/ade-cli/src/adeRpcServer.test.ts | 2 +- .../ai/tools/ctoOperatorTools.test.ts | 5 +++- .../services/sessions/sessionService.test.ts | 21 +++++++++++++++ .../main/services/sessions/sessionService.ts | 26 ++++++++++++++++--- .../components/terminals/SessionCard.test.tsx | 4 +-- .../work/SessionLifecycleChips.test.tsx | 8 +++--- apps/desktop/src/shared/adeCliGuidance.ts | 9 +++---- 7 files changed, 57 insertions(+), 18 deletions(-) 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/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index eb6f4e829..2e9589268 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -409,7 +409,10 @@ 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"); diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index cb6e56281..9c8736949 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -1798,6 +1798,24 @@ 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("supports the bulk override variant", async () => { const { service } = await makeService("ade-session-service-override-bulk-"); service.create({ @@ -1814,6 +1832,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 015401473..12fccea88 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -1467,8 +1467,17 @@ export function createSessionService({ db }: { db: AdeDb }) { const normalized = override == null ? null : normalizeSettleOverride(override); return mutateSessionMeta(sessionId, (id) => { db.run( - "update terminal_sessions set settle_override = ?, settle_source = ? where id = ?", - [normalized, normalized === "settled" ? "user" : null, id], + ` + 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 = ? + `, + [normalized, normalized, id], ); }); }, @@ -1485,8 +1494,17 @@ export function createSessionService({ db }: { db: AdeDb }) { if (!present.length) return []; const updatePlaceholders = present.map(() => "?").join(", "); db.run( - `update terminal_sessions set settle_override = ?, settle_source = ? where id in (${updatePlaceholders})`, - [normalized, normalized === "settled" ? "user" : null, ...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" }); 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/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/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 15724646a..0d977142e 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -15,14 +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 original objective is fully closed, not merely that the current turn or CLI process ended. 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 promised follow-up remains; and the next useful user message would start new scope or explicitly reopen the completed result.", - "- If the user could reasonably say “continue”, “verify”, “what remains?”, answer an open question, or refine the current result, do not settle.", + "- 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"); From 6b5dcd40e37ac9fa73467e3ae1f77ec831b989cf Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:20:14 -0400 Subject: [PATCH 03/16] ship: preserve provider provenance in remote sessions --- .../services/sync/syncRemoteCommandService.test.ts | 11 +++++++---- .../src/services/sync/syncRemoteCommandService.ts | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 0e612a1a8..a3d2562db 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1767,8 +1767,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,8 +1793,10 @@ 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", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 6a1aa15fc..12b32f975 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1663,6 +1663,7 @@ function projectChatOntoSession( runtimeState: "waiting-input" as const, chatIdleSinceAt: null, pendingInputItemId: chat.pendingInputItemId ?? session.pendingInputItemId ?? null, + attentionSource: "provider_structured" as const, }; } if (chat.status === "active") { @@ -2231,6 +2232,7 @@ async function listRemoteWorkSessions( runtimeState: "waiting-input" as const, chatIdleSinceAt: null, pendingInputItemId: chat.pendingInputItemId ?? null, + attentionSource: "provider_structured" as const, }; } if (chat.status === "active") return { ...session, runtimeState: "running" as const, chatIdleSinceAt: null }; From a02623219b37d535e6b0dc4199876aa6bfa45734 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:36:28 -0400 Subject: [PATCH 04/16] ship: harden explicit lifecycle provenance --- .../push/pushPublisherService.test.ts | 12 +++++++ .../src/services/push/pushPublisherService.ts | 4 +++ .../sync/syncRemoteCommandService.test.ts | 17 +++++++++ .../services/sync/syncRemoteCommandService.ts | 36 ++++++++++++++++--- .../services/ai/tools/ctoOperatorTools.ts | 2 +- .../sessions/chatSessionProjection.ts | 2 ++ apps/ios/ADE/Services/Database.swift | 4 +++ apps/ios/ADE/Services/SyncService.swift | 9 +++-- 8 files changed, 79 insertions(+), 7 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 90ffc081c..f9802ed44 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1875,6 +1875,18 @@ 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.dispose(); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 2d4c5f049..b4c04ebbd 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1557,6 +1557,10 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const existing = runs.get(signal.sessionId); // 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. diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index a3d2562db..c5bbd8c05 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1801,6 +1801,23 @@ describe("createSyncRemoteCommandService", () => { 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 () => { diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 12b32f975..540e3ab0f 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1667,10 +1667,22 @@ function projectChatOntoSession( }; } 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; } @@ -2235,8 +2247,24 @@ async function listRemoteWorkSessions( attentionSource: "provider_structured" as const, }; } - 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 }; + if (chat.status === "active") { + return { + ...session, + runtimeState: "running" as const, + chatIdleSinceAt: null, + pendingInputItemId: null, + attentionSource: session.attentionSource === "provider_structured" ? null : session.attentionSource, + }; + } + if (chat.status === "idle") { + return { + ...session, + runtimeState: "idle" as const, + chatIdleSinceAt: chat.idleSinceAt ?? null, + pendingInputItemId: null, + attentionSource: session.attentionSource === "provider_structured" ? null : session.attentionSource, + }; + } return session; }); } diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 7f2250e52..3dac07efc 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -589,7 +589,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record Date: Wed, 29 Jul 2026 16:51:46 -0400 Subject: [PATCH 05/16] ship: resolve explicit attention and lane projection --- apps/ade-cli/src/bootstrap.ts | 3 +++ .../services/push/pushPublisherService.test.ts | 8 ++++++++ .../src/services/push/pushPublisherService.ts | 13 +++++++++++++ .../services/sync/syncRemoteCommandService.ts | 17 +++++++++++++++-- .../desktop/src/main/services/pty/ptyService.ts | 3 +++ 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 8f6803c3a..1242abbff 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -931,6 +931,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) => { diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index f9802ed44..4943e8eeb 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1887,6 +1887,14 @@ describe("createPushPublisherService flush", () => { phase: "waiting_for_input", }); + publisher.handleSessionAttentionResolved("scope-1", "cli-ask-1"); + 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(); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index b4c04ebbd..67a7ab180 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1887,6 +1887,19 @@ export function createPushPublisherService(deps: PushPublisherDeps) { scheduleFlush(true); }, + handleSessionAttentionResolved(scopeKey: string, sessionId: string): void { + if (disposed || !sessionId) return; + const run = runs.get(sessionId); + if (!run || run.scopeKey !== scopeKey) return; + if (run.phase !== "waiting_for_input" && run.phase !== "waiting_for_approval") return; + run.phase = "running"; + run.itemId = null; + markRunUpdated(run); + 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.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 540e3ab0f..8a2fa1ddb 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3598,8 +3598,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 }) ?? []), @@ -3611,6 +3613,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)); @@ -3684,7 +3691,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/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) { From a2cd5d701bf63a75f0e412642e7b7b45f0fe7a37 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:06:24 -0400 Subject: [PATCH 06/16] fix: preserve optional lifecycle projections --- .../services/sync/syncRemoteCommandService.ts | 2 +- .../Work/WorkSessionCanonicalState.swift | 6 ++++-- .../WorkSessionCanonicalStateTests.swift | 21 +++++++++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 8a2fa1ddb..dea7b9166 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3675,7 +3675,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)), diff --git a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift index 39609ab6f..ddc55cd00 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift @@ -114,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, @@ -136,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 || !attentionRequested.isEmpty { + if !pending.isEmpty || providerStructuredInput || !attentionRequested.isEmpty { return CanonicalSessionState(phase: .needsYou, badge: badgeByKind[.needsYou]) } @@ -245,7 +246,8 @@ func workCanonicalSessionState( status: session.status, runtimeState: session.runtimeState, toolType: session.toolType, - pendingInputItemId: session.pendingInputItemId, + pendingInputItemId: summary?.pendingInputItemId ?? session.pendingInputItemId, + providerStructuredInput: summary?.awaitingInput == true, lastOutputPreview: session.lastOutputPreview, lastActivityAt: workSessionStaleActivityTimestamp(session: session, summary: summary), exitCode: session.exitCode, diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 92dba660f..6bda0c987 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -246,6 +246,19 @@ 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 testCapsuleBadgeSurfacesFailedExit() { let session = makeSession(status: "ended", runtimeState: "exited", toolType: "codex", exitCode: 130) let badge = workSessionCapsuleBadge(session: session, summary: nil, now: now) @@ -527,7 +540,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 +585,7 @@ final class WorkSessionCanonicalStateTests: XCTestCase { lastOutputPreview: nil, summary: nil, awaitingInput: awaitingInput, - pendingInputItemId: nil, + pendingInputItemId: pendingInputItemId, threadId: nil, requestedCwd: nil ) From 1e7d423959fbaee89c3262c52a7e97d60fc5bdc6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:21:59 -0400 Subject: [PATCH 07/16] fix: preserve operator settle provenance --- .../main/services/ai/tools/ctoOperatorTools.test.ts | 4 ++-- .../src/main/services/ai/tools/ctoOperatorTools.ts | 2 +- .../main/services/sessions/sessionService.test.ts | 11 +++++++++++ .../src/main/services/sessions/sessionService.ts | 13 +++++++++---- 4 files changed, 23 insertions(+), 7 deletions(-) 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 2e9589268..2c2204e07 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -418,9 +418,9 @@ describe("createCtoOperatorTools", () => { 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 3dac07efc..7dc93faf2 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -597,7 +597,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/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 9c8736949..ddb3dbd58 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -1816,6 +1816,17 @@ describe("sessionService settle override", () => { })); }); + 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({ diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 12fccea88..c26c149ff 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -1463,21 +1463,26 @@ export function createSessionService({ db }: { db: AdeDb }) { }, /** Explicit settle override, cleared with `settled_at` on real activity. */ - setSettleOverride(sessionId: string, override: SessionSettleOverride | null): boolean { + 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 = ?, - settle_source = case - when ? = 'settled' then 'user' + settle_source = case + when ? = 'settled' then ? when settled_at is null then null else settle_source end where id = ? `, - [normalized, normalized, id], + [normalized, normalized, normalizedSource, id], ); }); }, From bad6ab2727c02a0d4736cd285b06d6736064a971 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:38:39 -0400 Subject: [PATCH 08/16] fix: resolve lifecycle attention consistently --- .../push/pushPublisherService.test.ts | 1 + .../src/services/push/pushPublisherService.ts | 9 ++- .../sync/syncRemoteCommandService.test.ts | 33 +++++++- .../services/sync/syncRemoteCommandService.ts | 3 + .../sessions/settleTerminalSession.ts | 2 + .../WorkSessionCanonicalStateTests.swift | 81 ++++++++++--------- 6 files changed, 85 insertions(+), 44 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 4943e8eeb..b92470211 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1888,6 +1888,7 @@ describe("createPushPublisherService flush", () => { }); 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({ diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 67a7ab180..5f0693933 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1887,14 +1887,19 @@ export function createPushPublisherService(deps: PushPublisherDeps) { scheduleFlush(true); }, - handleSessionAttentionResolved(scopeKey: string, sessionId: string): void { + handleSessionAttentionResolved(scopeKey: string | null, sessionId: string): void { if (disposed || !sessionId) return; const run = runs.get(sessionId); - if (!run || run.scopeKey !== scopeKey) return; + 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); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index c5bbd8c05..6fdba537d 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, @@ -2235,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), @@ -2247,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 }; } @@ -2266,6 +2273,26 @@ describe("session lifecycle remote commands", () => { expect(sessionService.unsettleSession).toHaveBeenCalledWith("session-1"); }); + it("resolves push attention when dismissing an explicit CLI ask", async () => { + const handleSessionAttentionResolved = vi.fn(); + const { service, sessionService } = createLifecycleService({ + pushPublisherService: { handleSessionAttentionResolved }, + 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(handleSessionAttentionResolved).toHaveBeenCalledWith(null, "session-1"); + expect(sessionService.settleSession).toHaveBeenCalledWith("session-1", {}); + }); + it("normalizes the snooze deadline and rejects unparseable ones", async () => { const { service, sessionService } = createLifecycleService(); const futureIso = new Date(Date.now() + 60 * 60_000).toISOString(); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index dea7b9166..503aa6d78 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3980,6 +3980,9 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio sessionService: args.sessionService, agentChatService: args.agentChatService ?? null, ptyService: args.ptyService, + onAttentionResolved: (resolvedSessionId) => { + args.pushPublisherService?.handleSessionAttentionResolved(null, resolvedSessionId); + }, }); if (!settled) throw new Error(`Session '${sessionId}' was not found.`); return { ok: true, sessionId }; diff --git a/apps/desktop/src/main/services/sessions/settleTerminalSession.ts b/apps/desktop/src/main/services/sessions/settleTerminalSession.ts index 6b49ed36e..a06e684df 100644 --- a/apps/desktop/src/main/services/sessions/settleTerminalSession.ts +++ b/apps/desktop/src/main/services/sessions/settleTerminalSession.ts @@ -16,6 +16,7 @@ export async function settleTerminalSession(args: { sessionService: ReturnType; agentChatService?: ReturnType | null; ptyService?: ReturnType | null; + onAttentionResolved?: (sessionId: string) => void; }): Promise { if (args.opts?.dismissPendingInput === true) { const session = args.sessionService.get(args.sessionId); @@ -32,6 +33,7 @@ export async function settleTerminalSession(args: { throw new Error("Terminal runtime is unavailable; the explicit attention request could not be dismissed."); } args.ptyService.setSessionRuntimeState(args.sessionId, "idle"); + args.onAttentionResolved?.(args.sessionId); } else { throw new Error("Resolve the terminal input before settling this session."); } diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 6bda0c987..5109b28aa 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) } @@ -678,23 +683,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) @@ -815,9 +818,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 { @@ -830,14 +832,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 @@ -886,6 +888,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" From 229d316b8d16b456bb0f5a5c27acd92225d597dc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:52:51 -0400 Subject: [PATCH 09/16] fix: terminate push runs on settlement --- .../push/pushPublisherService.test.ts | 19 +++++++++++++++++++ .../src/services/push/pushPublisherService.ts | 18 ++++++++++++++++++ .../sync/syncRemoteCommandService.test.ts | 6 +++--- .../services/sync/syncRemoteCommandService.ts | 2 +- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index b92470211..a1bdce3a0 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1937,6 +1937,25 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("terminates a waiting CLI run when dismiss-and-settle resolves attention", async () => { + const { publisher } = 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.get("cli-settle-1")?.phase).toBe("completed"); + + 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 5f0693933..a70c4ef6b 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1905,6 +1905,24 @@ export function createPushPublisherService(deps: PushPublisherDeps) { 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 }); + 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 6fdba537d..0367a5a78 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -2274,9 +2274,9 @@ describe("session lifecycle remote commands", () => { }); it("resolves push attention when dismissing an explicit CLI ask", async () => { - const handleSessionAttentionResolved = vi.fn(); + const handleSessionSettled = vi.fn(); const { service, sessionService } = createLifecycleService({ - pushPublisherService: { handleSessionAttentionResolved }, + pushPublisherService: { handleSessionSettled }, session: { id: "session-1", toolType: "codex", @@ -2289,7 +2289,7 @@ describe("session lifecycle remote commands", () => { dismissPendingInput: true, }))).resolves.toEqual({ ok: true, sessionId: "session-1" }); - expect(handleSessionAttentionResolved).toHaveBeenCalledWith(null, "session-1"); + expect(handleSessionSettled).toHaveBeenCalledWith(null, "session-1"); expect(sessionService.settleSession).toHaveBeenCalledWith("session-1", {}); }); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 503aa6d78..206d7f6fc 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3981,7 +3981,7 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio agentChatService: args.agentChatService ?? null, ptyService: args.ptyService, onAttentionResolved: (resolvedSessionId) => { - args.pushPublisherService?.handleSessionAttentionResolved(null, resolvedSessionId); + args.pushPublisherService?.handleSessionSettled(null, resolvedSessionId); }, }); if (!settled) throw new Error(`Session '${sessionId}' was not found.`); From 1f460b292e4d2aca459a49ab8a8ed37e0ae998b1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:07:09 -0400 Subject: [PATCH 10/16] fix: preserve restored provider attention --- apps/desktop/src/renderer/lib/terminalAttention.ts | 3 +++ apps/desktop/src/shared/sessionCanonicalState.test.ts | 1 + apps/desktop/src/shared/sessionCanonicalState.ts | 8 +++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 8f1cfb538..0b3411e93 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, diff --git a/apps/desktop/src/shared/sessionCanonicalState.test.ts b/apps/desktop/src/shared/sessionCanonicalState.test.ts index 744aea6a7..710f06faa 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.test.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.test.ts @@ -32,6 +32,7 @@ describe("canonicalSessionState precedence", () => { // 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"], + ["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"], diff --git a/apps/desktop/src/shared/sessionCanonicalState.ts b/apps/desktop/src/shared/sessionCanonicalState.ts index 16ec7610e..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; @@ -117,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.attentionRequestedAt) { + if ( + args.pendingInputItemId + || args.attentionRequestedAt + || args.attentionSource === "provider_structured" + ) { return { phase: "needs_you", badge: BADGE_BY_KIND.needs_you }; } From 7c27d3d0220e0a5d3e42790230c87096667d2951 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:22:12 -0400 Subject: [PATCH 11/16] fix: align lifecycle projections after settlement --- .../sync/syncRemoteCommandService.test.ts | 13 +++++++++++ .../services/sync/syncRemoteCommandService.ts | 22 ++++++++++++++----- .../lanes/laneListSnapshotService.test.ts | 6 ++--- .../services/lanes/laneListSnapshotService.ts | 10 ++++++++- .../sessions/settleTerminalSession.ts | 2 -- .../terminals/SessionInfoPopover.tsx | 4 +++- 6 files changed, 44 insertions(+), 13 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 0367a5a78..5c1a70432 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -2293,6 +2293,19 @@ describe("session lifecycle remote commands", () => { expect(sessionService.settleSession).toHaveBeenCalledWith("session-1", {}); }); + it("ends push runs for ordinary and bulk settlements", 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).toHaveBeenNthCalledWith(1, null, "session-1"); + expect(handleSessionSettled).toHaveBeenNthCalledWith(2, null, "session-1"); + }); + it("normalizes the snooze deadline and rejects unparseable ones", async () => { const { service, sessionService } = createLifecycleService(); const futureIso = new Date(Date.now() + 60 * 60_000).toISOString(); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 206d7f6fc..4b17b1799 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3529,6 +3529,7 @@ function sessionStatusBucket(argsIn: { 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: @@ -3537,7 +3538,11 @@ 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 || argsIn.pendingInputItemId) 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); @@ -3980,19 +3985,24 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio sessionService: args.sessionService, agentChatService: args.agentChatService ?? null, ptyService: args.ptyService, - onAttentionResolved: (resolvedSessionId) => { - args.pushPublisherService?.handleSessionSettled(null, resolvedSessionId); - }, }); if (!settled) throw new Error(`Session '${sessionId}' was not found.`); + args.pushPublisherService?.handleSessionSettled(null, sessionId); return { ok: true, sessionId }; }); register("session.unsettleSession", { viewerAllowed: true, queueable: true }, async (payload) => { const sessionId = requireString(payload.sessionId, "session.unsettleSession requires sessionId."); return { ok: args.sessionService.unsettleSession(sessionId), sessionId }; }); - register("session.settleSessions", { viewerAllowed: true, queueable: true }, async (payload) => - args.sessionService.settleSessions(parseRemoteSessionIds(payload, "session.settleSessions"))); + register("session.settleSessions", { viewerAllowed: true, queueable: true }, async (payload) => { + const settledSessionIds = args.sessionService.settleSessions( + parseRemoteSessionIds(payload, "session.settleSessions"), + ); + for (const settledSessionId of settledSessionIds) { + args.pushPublisherService?.handleSessionSettled(null, settledSessionId); + } + return settledSessionIds; + }); register("session.unsettleSessions", { viewerAllowed: true, queueable: true }, async (payload) => { args.sessionService.unsettleSessions(parseRemoteSessionIds(payload, "session.unsettleSessions")); return { ok: true }; diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts index 3ba0d6142..444e7b1e6 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.test.ts @@ -86,7 +86,7 @@ describe("laneListSnapshotService", () => { }); }); - it("does not count stale chat awaiting state without an explicit pending item", async () => { + it("counts provider-structured awaiting state without an item id", async () => { const services = { ...makeHarness({ id: "chat-1", @@ -117,8 +117,8 @@ describe("laneListSnapshotService", () => { ); expect(snapshot?.runtime).toMatchObject({ - bucket: "running", - awaitingInputCount: 0, + bucket: "awaiting-input", + awaitingInputCount: 1, pendingInputCount: 0, }); }); diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts index 0e2f43983..dcaf9ab3c 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -109,6 +109,8 @@ function sessionStatusBucket(args: { 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 @@ -117,7 +119,12 @@ 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 || args.pendingInputItemId) 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); @@ -136,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/sessions/settleTerminalSession.ts b/apps/desktop/src/main/services/sessions/settleTerminalSession.ts index a06e684df..6b49ed36e 100644 --- a/apps/desktop/src/main/services/sessions/settleTerminalSession.ts +++ b/apps/desktop/src/main/services/sessions/settleTerminalSession.ts @@ -16,7 +16,6 @@ export async function settleTerminalSession(args: { sessionService: ReturnType; agentChatService?: ReturnType | null; ptyService?: ReturnType | null; - onAttentionResolved?: (sessionId: string) => void; }): Promise { if (args.opts?.dismissPendingInput === true) { const session = args.sessionService.get(args.sessionId); @@ -33,7 +32,6 @@ export async function settleTerminalSession(args: { throw new Error("Terminal runtime is unavailable; the explicit attention request could not be dismissed."); } args.ptyService.setSessionRuntimeState(args.sessionId, "idle"); - args.onAttentionResolved?.(args.sessionId); } else { throw new Error("Resolve the terminal input before settling this session."); } diff --git a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx index 45ea198da..9cdadf16a 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx @@ -219,7 +219,9 @@ 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.attentionRequestedAt + || session.pendingInputItemId + || session.attentionSource === "provider_structured" ? ["Attention source", lifecycleSourceLabel(session.attentionSource)] : null, session.settledAt || session.settleOverride === "settled" From 21034434b63cf61cd0eda9ee8fe4e5fda7340b5e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:36:10 -0400 Subject: [PATCH 12/16] fix: keep attention parity across clients --- .../push/pushPublisherService.test.ts | 2 +- .../src/services/push/pushPublisherService.ts | 1 + .../sync/syncRemoteCommandService.test.ts | 50 ++++++++++++++++++- .../services/sync/syncRemoteCommandService.ts | 8 +-- .../__tests__/sessionLifecycle.test.tsx | 2 +- .../ade-cli/src/tuiClient/sessionLifecycle.ts | 1 + .../Work/WorkSessionCanonicalState.swift | 2 +- .../WorkSessionCanonicalStateTests.swift | 9 ++++ 8 files changed, 66 insertions(+), 9 deletions(-) diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index a1bdce3a0..eab8a5e87 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1951,7 +1951,7 @@ describe("createPushPublisherService flush", () => { publisher.handleSessionSettled("scope-1", "cli-settle-1"); expect(publisher._debug.getPendingAlerts()).toEqual([]); - expect(publisher._debug.runs.get("cli-settle-1")?.phase).toBe("completed"); + expect(publisher._debug.runs.has("cli-settle-1")).toBe(false); publisher.dispose(); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index a70c4ef6b..815492a08 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -1913,6 +1913,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { run.itemId = null; markRunUpdated(run); recentRuns.set(sessionId, { ...run }); + runs.delete(sessionId); pendingAlerts = pendingAlerts.filter( (alert) => alert.dedupeKey !== `alert:${sessionId}:approval` diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 5c1a70432..7565eeea5 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -2689,25 +2689,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 4b17b1799..97265184d 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3582,10 +3582,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"; diff --git a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx index 7adcf78c8..2b049c54b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx @@ -323,7 +323,7 @@ describe("lifecycle markers", () => { // 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 })?.kind).toBe("snoozed"); + expect(sessionLifecycleMarker({ ...snooze, awaitingInput: true }, { nowMs: NOW })).toBeNull(); expect(sessionLifecycleMarker({ ...snooze, pendingInputItemId: "item-1" }, { nowMs: NOW })).toBeNull(); expect(sessionLifecycleMarker( { ...snooze, attentionRequestedAt: "2026-07-26T11:59:00.000Z" }, diff --git a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts index 198a34942..9d58e6387 100644 --- a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts +++ b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts @@ -209,6 +209,7 @@ export type SessionLifecycleSnapshot = Partial & { */ function snapshotRaisesHand(session: SessionLifecycleSnapshot): boolean { 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/ios/ADE/Views/Work/WorkSessionCanonicalState.swift b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift index ddc55cd00..7c0eff655 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift @@ -247,7 +247,7 @@ func workCanonicalSessionState( runtimeState: session.runtimeState, toolType: session.toolType, pendingInputItemId: summary?.pendingInputItemId ?? session.pendingInputItemId, - providerStructuredInput: summary?.awaitingInput == true, + providerStructuredInput: summary?.awaitingInput == true || session.attentionSource == "provider_structured", lastOutputPreview: session.lastOutputPreview, lastActivityAt: workSessionStaleActivityTimestamp(session: session, summary: summary), exitCode: session.exitCode, diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 5109b28aa..874e5ffc2 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -264,6 +264,15 @@ final class WorkSessionCanonicalStateTests: XCTestCase { ) } + 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) From 1acadf8b62a28bff5e8e85bb97fae57987db4e1f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:50:49 -0400 Subject: [PATCH 13/16] fix: suppress settled push runs centrally --- apps/ade-cli/src/bootstrap.ts | 21 ++++++++++--- .../push/pushPublisherService.test.ts | 22 ++++++++++++-- .../src/services/push/pushPublisherService.ts | 30 +++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 1242abbff..fdd2526a9 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -688,9 +688,22 @@ 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; const sessionService = createSessionService({ db }); sessionService.onChanged((event) => { pushEvent("runtime", { type: "terminal_session_changed", event }); + const session = sessionService.get(event.sessionId); + if ( + session + && ( + session.settleOverride === "settled" + || (session.settleOverride !== "active" && Boolean(session.settledAt)) + ) + ) { + pushPublisherForPtySignals?.handleSessionSettled(projectId, event.sessionId); + } }); const processRegistry = createProcessRegistryService({ db, @@ -900,10 +913,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, @@ -1512,6 +1523,8 @@ export async function createAdeRuntime(args: { title: session.title ?? null, toolType: session.toolType ?? null, chatSessionId: session.chatSessionId ?? 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 eab8a5e87..fa9b62354 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -285,7 +285,13 @@ 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", @@ -1938,7 +1944,7 @@ describe("createPushPublisherService flush", () => { }); it("terminates a waiting CLI run when dismiss-and-settle resolves attention", async () => { - const { publisher } = makeHarness(); + const { publisher, cliSessions } = makeHarness(); await publisher.start(); publisher.handleSessionAttentionRequested("scope-1", { @@ -1953,6 +1959,18 @@ describe("createPushPublisherService flush", () => { 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", + settledAt: "2026-07-29T22:00:00.000Z", + }); + publisher.handleCliRuntimeSignal("scope-1", { + laneId: "auth-lane", + sessionId: "cli-settle-1", + runtimeState: "running", + }); + expect(publisher._debug.runs.has("cli-settle-1")).toBe(false); + publisher.dispose(); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 815492a08..b454471fa 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -196,6 +196,8 @@ export type PushPublisherSources = { title: string | null; toolType?: string | null; chatSessionId?: string | null; + settledAt?: string | null; + settleOverride?: "settled" | "active" | null; } | null; }; @@ -932,6 +934,12 @@ export function createPushPublisherService(deps: PushPublisherDeps) { runs.delete(run.sessionId); continue; } + if (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; @@ -1555,6 +1563,28 @@ export function createPushPublisherService(deps: PushPublisherDeps) { 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; + if ( + 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 From c8de87ffeb83d43e01482ce7ec57ed5dcd4f3b49 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:44:34 -0400 Subject: [PATCH 14/16] fix: end settled push runs only at rest --- apps/ade-cli/src/bootstrap.ts | 3 +++ .../push/pushPublisherService.test.ts | 10 ++++++++++ .../src/services/push/pushPublisherService.ts | 19 ++++++++++++++++--- .../sync/syncRemoteCommandService.test.ts | 7 +++---- .../services/sync/syncRemoteCommandService.ts | 12 ++---------- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index fdd2526a9..512427764 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -697,6 +697,7 @@ export async function createAdeRuntime(args: { const session = sessionService.get(event.sessionId); if ( session + && (session.status !== "running" || session.runtimeState === "idle") && ( session.settleOverride === "settled" || (session.settleOverride !== "active" && Boolean(session.settledAt)) @@ -1523,6 +1524,8 @@ 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, }; diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index fa9b62354..3d870dabf 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -289,6 +289,8 @@ describe("createPushPublisherService flush", () => { title: string | null; toolType?: string | null; chatSessionId?: string | null; + status?: string | null; + runtimeState?: string | null; settledAt?: string | null; settleOverride?: "settled" | "active" | null; }>(); @@ -1962,6 +1964,7 @@ describe("createPushPublisherService flush", () => { cliSessions.set("cli-settle-1", { title: "Fix auth race", toolType: "codex", + status: "running", settledAt: "2026-07-29T22:00:00.000Z", }); publisher.handleCliRuntimeSignal("scope-1", { @@ -1969,6 +1972,13 @@ describe("createPushPublisherService flush", () => { 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(); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index b454471fa..6b4b73582 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -196,6 +196,8 @@ 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; @@ -934,7 +936,14 @@ export function createPushPublisherService(deps: PushPublisherDeps) { runs.delete(run.sessionId); continue; } - if (record.settleOverride === "settled" || (record.settleOverride !== "active" && record.settledAt)) { + 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); @@ -1564,9 +1573,13 @@ export function createPushPublisherService(deps: PushPublisherDeps) { 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 ( - session?.settleOverride === "settled" - || (session?.settleOverride !== "active" && Boolean(session?.settledAt)) + atRest + && ( + session?.settleOverride === "settled" + || (session?.settleOverride !== "active" && Boolean(session?.settledAt)) + ) ) { if (existing) { existing.phase = "completed"; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 7565eeea5..1e2776483 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -2289,11 +2289,11 @@ describe("session lifecycle remote commands", () => { dismissPendingInput: true, }))).resolves.toEqual({ ok: true, sessionId: "session-1" }); - expect(handleSessionSettled).toHaveBeenCalledWith(null, "session-1"); + expect(handleSessionSettled).not.toHaveBeenCalled(); expect(sessionService.settleSession).toHaveBeenCalledWith("session-1", {}); }); - it("ends push runs for ordinary and bulk settlements", async () => { + it("leaves settlement push projection to the central session listener", async () => { const handleSessionSettled = vi.fn(); const { service } = createLifecycleService({ pushPublisherService: { handleSessionSettled }, @@ -2302,8 +2302,7 @@ describe("session lifecycle remote commands", () => { await service.execute(makePayload("session.settleSession", { sessionId: "session-1" })); await service.execute(makePayload("session.settleSessions", { sessionIds: ["session-1"] })); - expect(handleSessionSettled).toHaveBeenNthCalledWith(1, null, "session-1"); - expect(handleSessionSettled).toHaveBeenNthCalledWith(2, null, "session-1"); + expect(handleSessionSettled).not.toHaveBeenCalled(); }); it("normalizes the snooze deadline and rejects unparseable ones", async () => { diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 97265184d..d5dcdd476 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3987,22 +3987,14 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio ptyService: args.ptyService, }); if (!settled) throw new Error(`Session '${sessionId}' was not found.`); - args.pushPublisherService?.handleSessionSettled(null, sessionId); return { ok: true, sessionId }; }); register("session.unsettleSession", { viewerAllowed: true, queueable: true }, async (payload) => { const sessionId = requireString(payload.sessionId, "session.unsettleSession requires sessionId."); return { ok: args.sessionService.unsettleSession(sessionId), sessionId }; }); - register("session.settleSessions", { viewerAllowed: true, queueable: true }, async (payload) => { - const settledSessionIds = args.sessionService.settleSessions( - parseRemoteSessionIds(payload, "session.settleSessions"), - ); - for (const settledSessionId of settledSessionIds) { - args.pushPublisherService?.handleSessionSettled(null, settledSessionId); - } - return settledSessionIds; - }); + register("session.settleSessions", { viewerAllowed: true, queueable: true }, async (payload) => + args.sessionService.settleSessions(parseRemoteSessionIds(payload, "session.settleSessions"))); register("session.unsettleSessions", { viewerAllowed: true, queueable: true }, async (payload) => { args.sessionService.unsettleSessions(parseRemoteSessionIds(payload, "session.unsettleSessions")); return { ok: true }; From 2ace3e0ac38dd1424a0d35c0dd3c42d3ff04fd34 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:59:32 -0400 Subject: [PATCH 15/16] fix: read live runtime state for settlement --- apps/ade-cli/src/bootstrap.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 512427764..f59102e50 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -691,13 +691,18 @@ export async function createAdeRuntime(args: { // 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" || session.runtimeState === "idle") + && (session.status !== "running" || runtimeState === "idle") && ( session.settleOverride === "settled" || (session.settleOverride !== "active" && Boolean(session.settledAt)) @@ -960,6 +965,7 @@ export async function createAdeRuntime(args: { loadPty: ptyBackend ?? (() => nodePty), disposePtyBackend: ptyBackend?.dispose }); + ptyServiceForSessionChanges = ptyService; const testService = createTestService({ db, From 67b47f6a85822dc23ed72e8eb27f0a486ec08804 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:08:06 -0400 Subject: [PATCH 16/16] fix: align structured attention projections --- .../services/sync/syncRemoteCommandService.ts | 29 +------------------ .../components/lanes/laneAgents.test.ts | 10 +++++++ .../renderer/components/lanes/laneAgents.ts | 11 +++++-- .../renderer/lib/terminalAttention.test.ts | 5 ++++ .../src/renderer/lib/terminalAttention.ts | 2 ++ 5 files changed, 27 insertions(+), 30 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index d5dcdd476..2fe35ee2e 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2238,34 +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, - attentionSource: "provider_structured" as const, - }; - } - if (chat.status === "active") { - return { - ...session, - runtimeState: "running" as const, - chatIdleSinceAt: null, - pendingInputItemId: null, - attentionSource: session.attentionSource === "provider_structured" ? null : session.attentionSource, - }; - } - if (chat.status === "idle") { - return { - ...session, - runtimeState: "idle" as const, - chatIdleSinceAt: chat.idleSinceAt ?? null, - pendingInputItemId: null, - attentionSource: session.attentionSource === "provider_structured" ? null : session.attentionSource, - }; - } - return session; + return projectChatOntoSession(session, chat); }); } diff --git a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts index 6816f5934..db2031b84 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.test.ts @@ -111,6 +111,16 @@ describe("buildLaneAgents", () => { 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 2eec41898..8bc2c65b6 100644 --- a/apps/desktop/src/renderer/components/lanes/laneAgents.ts +++ b/apps/desktop/src/renderer/components/lanes/laneAgents.ts @@ -38,7 +38,11 @@ function chatActivity(summary: AgentChatSessionSummary): LaneAgentActivity { } function cliActivity(summary: TerminalSessionSummary): LaneAgentActivity { - if (summary.pendingInputItemId || summary.attentionRequestedAt) return "awaiting-input"; + if ( + summary.pendingInputItemId + || summary.attentionRequestedAt + || summary.attentionSource === "provider_structured" + ) return "awaiting-input"; switch (summary.runtimeState) { case "running": return "working"; case "waiting-input": return "idle"; @@ -87,7 +91,10 @@ function cliAgentFrom(summary: TerminalSessionSummary): LaneAgent { modelId: null, providerLabel: cliProviderLabel(summary.toolType), activity: cliActivity(summary), - lastHint: summary.pendingInputItemId || summary.attentionRequestedAt + 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/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index 0df4c21bf..4c15502c4 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.test.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.test.ts @@ -102,6 +102,11 @@ describe("terminalAttention", () => { toolType: "codex-chat", pendingInputItemId: "approval-1", })).toBe(true); + expect(sessionNeedsChatTabHighlight({ + runtimeState: "waiting-input", + toolType: "claude-chat", + attentionSource: "provider_structured", + })).toBe(true); }); }); diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 0b3411e93..8f16dd655 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -178,10 +178,12 @@ 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.pendingInputItemId) return true; + if (args.attentionSource === "provider_structured") return true; if (args.attentionRequestedAt) return true; return false; }