diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index ff438f006f1cb..5cfe709c901c8 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -137,8 +137,9 @@ describe("resolveThreadListV2Status", () => { }); it.each([ - { roster: "dev server", task: { kind: "command" }, status: "ready" }, - { roster: "dev server watchdog", task: { kind: "monitor" }, status: "ready" }, + { roster: "dev server", task: { kind: "command", awaited: false }, status: "ready" }, + { roster: "dev server watchdog", task: { kind: "monitor", awaited: false }, status: "ready" }, + { roster: "unmarked command", task: { kind: "command" }, status: "waiting" }, { roster: "awaited command", task: { kind: "command", awaited: true }, status: "waiting" }, ] as const)( "presents an unseen completion with a $roster roster as $status", diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 413ab984211f8..7466302ec2fa6 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -139,7 +139,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { const BackgroundWaitTool = Tool.make("t3_background_wait", { description: - "Keep this thread in Working while background work runs. Pass its task ID after starting it. Not for work left running (dev server, watcher). `waiting=false` clears. Does not stop the task. Claude only.", + "Background commands and monitors keep this thread in Working until they end. For a process you leave running and are not waiting on (dev server, watcher), pass its task ID with `waiting=false` so the thread can finish. `waiting=true` restores the hold. Does not stop the task. Claude only.", parameters: Schema.Struct({ taskIds: Schema.Array(TrimmedNonEmptyString).check( Schema.isMinLength(1), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fb17c34713fd8..4d3fa4fbd2db5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7709,7 +7709,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("holds only the background tasks the agent marks awaited, until they end", () => + it.effect("keeps the agent's awaited marks on background tasks, until they end", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -7740,7 +7740,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { } }); const marks = (tasks: ReadonlyArray) => - tasks.map((task) => [task.taskId, task.awaited === true]); + tasks.map((task) => [task.taskId, task.awaited ?? null]); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -7766,6 +7766,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepStrictEqual(marked.matched, [WAKE_TASK_ID]); assert.deepStrictEqual( marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + [WAKE_TASK_ID, true], + ["dev-server", null], + ], + ); + yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: false, + }); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === false))), [ [WAKE_TASK_ID, true], ["dev-server", false], @@ -7774,7 +7786,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, turnOneResult); yield* Queue.take(harness.terminalReceipts); - // A replacing level rebuilds every entry; the mark must survive it. + // A replacing level rebuilds every entry; both marks must survive it. yield* Queue.offer( harness.sdkMessages, roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000302"), @@ -7799,7 +7811,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.deepStrictEqual(marks(yield* nextRoster((tasks) => tasks.length === 2)), [ ["dev-server", false], - [WAKE_TASK_ID, false], + [WAKE_TASK_ID, null], ]); yield* awaitBackgroundTasks({ @@ -7811,7 +7823,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), [ ["dev-server", true], - [WAKE_TASK_ID, false], + [WAKE_TASK_ID, null], ], ); const released = yield* awaitBackgroundTasks({ @@ -7824,7 +7836,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { marks(yield* nextRoster((tasks) => !tasks.some((task) => task.awaited === true))), [ ["dev-server", false], - [WAKE_TASK_ID, false], + [WAKE_TASK_ID, null], ], ); }).pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 496a0b2e77d0f..3a417a88d7ce0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1437,7 +1437,7 @@ const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = ` ### Background work you wait on -To wait for a background command or Monitor you started (tests, a build), call \`t3_background_wait\` with its task ID before you end your turn. Do not mark work you leave running, or PRs. T3 MCP tools are deferred: load one with ToolSearch before you call it. +Background commands and Monitors you start keep the thread in Working until they end. For a process you leave running and are not waiting on (dev server, watcher), call \`t3_background_wait\` with its task ID and \`waiting: false\` before you end your turn. T3 MCP tools are deferred: load one with ToolSearch before you call it. `; // Native subagents start from their task, not this session's system prompt. @@ -2882,9 +2882,14 @@ function isClaudeRootTurnFrame(message: SDKMessage): boolean { function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, awaitedTaskIds: ReadonlySet, + unawaitedTaskIds: ReadonlySet, ): ReadonlyArray { return Array.from(roster.values(), (task) => - awaitedTaskIds.has(task.taskId) ? { ...task, awaited: true as const } : task, + awaitedTaskIds.has(task.taskId) + ? { ...task, awaited: true } + : unawaitedTaskIds.has(task.taskId) + ? { ...task, awaited: false } + : task, ); } @@ -4659,10 +4664,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingBackgroundTasksByNativeThread = yield* Ref.make( new Map>(), ); - // Survives roster REPLACE levels, which rebuild every entry without the agent's mark. + // Survive roster REPLACE levels, which rebuild every entry without the agent's mark. const awaitedBackgroundTasksByNativeThread = yield* Ref.make( new Map>(), ); + const unawaitedBackgroundTasksByNativeThread = yield* Ref.make( + new Map>(), + ); // Wake eligibility is separate from the Waiting roster. It survives empty // background_tasks_changed levels (SDK: empty level can precede task_notification) and // is consumed when the first idle notification is buffered/offered so duplicates cannot @@ -5122,12 +5130,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.get(awaitedBackgroundTasksByNativeThread), input.nativeThreadId, ); + const unawaitedTaskIds = taskIdSetForNativeThread( + yield* Ref.get(unawaitedBackgroundTasksByNativeThread), + input.nativeThreadId, + ); const now = yield* DateTime.now; const providerThread: OrchestrationV2ProviderThread = { ...input.providerThread, providerSessionId: session.id, ...(input.status === undefined ? {} : { status: input.status }), - pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster, awaitedTaskIds), + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster( + roster, + awaitedTaskIds, + unawaitedTaskIds, + ), ...(goalsByNativeThread.has(input.nativeThreadId) ? { goal: goalsByNativeThread.get(input.nativeThreadId) ?? null } : {}), @@ -5210,16 +5226,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); } const listed = new Set(tasks.map((task) => task.taskId)); - yield* Ref.update(awaitedBackgroundTasksByNativeThread, (current) => { - const awaited = current.get(nativeThreadId); - if (awaited === undefined) return current; - const kept = new Set([...awaited].filter((taskId) => listed.has(taskId))); - if (kept.size === awaited.size) return current; - const updated = new Map(current); - if (kept.size === 0) updated.delete(nativeThreadId); - else updated.set(nativeThreadId, kept); - return updated; - }); + for (const marks of [ + awaitedBackgroundTasksByNativeThread, + unawaitedBackgroundTasksByNativeThread, + ]) { + yield* Ref.update(marks, (current) => { + const marked = current.get(nativeThreadId); + if (marked === undefined) return current; + const kept = new Set([...marked].filter((taskId) => listed.has(taskId))); + if (kept.size === marked.size) return current; + const updated = new Map(current); + if (kept.size === 0) updated.delete(nativeThreadId); + else updated.set(nativeThreadId, kept); + return updated; + }); + } }); const upsertPendingBackgroundTask = @@ -5260,6 +5281,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( taskId, ), ), + Effect.tap(() => + clearTaskIdFromNativeThreadSet( + unawaitedBackgroundTasksByNativeThread, + nativeThreadId, + taskId, + ), + ), ); const clearPendingBackgroundTasksForNativeThread = @@ -5274,6 +5302,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return updated; }); yield* clearNativeThreadTaskIdSet(awaitedBackgroundTasksByNativeThread, nativeThreadId); + yield* clearNativeThreadTaskIdSet( + unawaitedBackgroundTasksByNativeThread, + nativeThreadId, + ); yield* endClaudeMonitorTasks((_taskId, task) => task.nativeThreadId === nativeThreadId); }); @@ -7433,6 +7465,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.get(awaitedBackgroundTasksByNativeThread), nativeThreadId, ); + const unawaitedTaskIds = + nativeThreadId === null + ? new Set() + : taskIdSetForNativeThread( + yield* Ref.get(unawaitedBackgroundTasksByNativeThread), + nativeThreadId, + ); // A stopped turn that wrote to the transcript moved past the pinned head. const clearConversationHead = (input.status === "completed" || input.context.nativeMessageCursor !== null) && @@ -7474,6 +7513,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster( roster, awaitedTaskIds, + unawaitedTaskIds, ), status: input.status === "completed" ? "active" : "idle", updatedAt: input.completedAt, @@ -10880,20 +10920,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); const matched = [...new Set(taskIds)].filter((taskId) => roster.has(taskId)); if (matched.length === 0) return { matched }; - if (awaited) { - yield* addTaskIdsToNativeThreadSet( - awaitedBackgroundTasksByNativeThread, - nativeThreadId, - matched, - ); - } else { - for (const taskId of matched) { - yield* clearTaskIdFromNativeThreadSet( - awaitedBackgroundTasksByNativeThread, - nativeThreadId, - taskId, - ); - } + const [marked, unmarked] = awaited + ? [awaitedBackgroundTasksByNativeThread, unawaitedBackgroundTasksByNativeThread] + : [unawaitedBackgroundTasksByNativeThread, awaitedBackgroundTasksByNativeThread]; + yield* addTaskIdsToNativeThreadSet(marked, nativeThreadId, matched); + for (const taskId of matched) { + yield* clearTaskIdFromNativeThreadSet(unmarked, nativeThreadId, taskId); } const active = yield* Ref.get(activeTurn); yield* emitRosterUpdate( diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index 24afd61d6186a..660c27b107015 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -150,9 +150,9 @@ export function isRestartNoteSource( } /** - * Matches the Working-section hold: a shell or monitor wakes it only when the agent said it - * waits on it. Most are dev servers and their watchdogs, so their note waits for the next - * user message. + * Stricter than the Working-section hold: a shell or monitor wakes the agent only when it said + * it waits on it, since turn-item work carries no mark and many are dev servers and their + * watchdogs. Their note waits for the next user message. */ export function isAwaitedWork(work: Pick): boolean { return work.awaited === true || (work.kind !== "shell" && work.kind !== "monitor"); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 0a0996c0ede0c..0689c9942ea8f 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -135,7 +135,12 @@ describe("isAutoSettlementCandidate", () => { ThreadSettlementService.isAutoSettlementCandidate( shell({ pendingBackgroundTasks: [ - { taskId: "dev", kind: "command", description: "vp run dev --share" }, + { + taskId: "dev", + kind: "command", + description: "vp run dev --share", + awaited: false, + }, ], }), NOW_MS, @@ -143,6 +148,15 @@ describe("isAutoSettlementCandidate", () => { ).toBe(true); }); + it("keeps a thread whose agent may be waiting on an unmarked command", () => { + expect( + ThreadSettlementService.isAutoSettlementCandidate( + shell({ pendingBackgroundTasks: [{ taskId: "tests", kind: "command" }] }), + NOW_MS, + ), + ).toBe(false); + }); + it("never settles, or keeps snoozed, a thread whose guest is waiting on the owner", () => { expect( ThreadSettlementService.isAutoSettlementCandidate( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e4fa68a65201b..f7d1e92315426 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -982,18 +982,25 @@ describe("unseen completion with background work", () => { it.each([ { roster: "dev server", - task: { kind: "command" }, + task: { kind: "command", awaited: false }, status: "ready", receded: false, pill: "Completed", }, { roster: "dev server watchdog", - task: { kind: "monitor" }, + task: { kind: "monitor", awaited: false }, status: "ready", receded: false, pill: "Completed", }, + { + roster: "unmarked command", + task: { kind: "command" }, + status: "waiting", + receded: true, + pill: "Waiting", + }, { roster: "awaited command", task: { kind: "command", awaited: true }, diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index abc16b9a7df67..08eb34b092666 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -99,7 +99,7 @@ describe("V2 client presentation", () => { }); it.each([ - { kinds: ["command"], expected: "completed" }, + { kinds: ["command"], expected: "idle" }, { kinds: ["command", "subagent"], expected: "idle" }, { kinds: ["background_task"], expected: "idle" }, ] as const)("presents a completed shell with $kinds as $expected", ({ kinds, expected }) => { @@ -120,8 +120,20 @@ describe("V2 client presentation", () => { expect(shell.pendingBackgroundTasks).toEqual(pendingBackgroundTasks); }); + it("presents a completed shell whose only command is left running as completed", () => { + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: RunId.make("run-completed"), + activeRunId: null, + status: "completed", + pendingBackgroundTasks: [{ taskId: "dev-server", kind: "command", awaited: false }], + }); + + expect(shell.runtime).toMatchObject({ status: "completed", activeRunId: null }); + }); + it.each(["running", "waiting"] as const)( - "preserves shell %s while only commands remain in the roster", + "preserves shell %s while only commands left running remain in the roster", (status) => { const runId = RunId.make("run-command"); const shell = presentThreadShell(environmentId, { @@ -129,12 +141,14 @@ describe("V2 client presentation", () => { latestRunId: runId, activeRunId: runId, status, - pendingBackgroundTasks: [{ taskId: "dev-server", kind: "command" }], + pendingBackgroundTasks: [{ taskId: "dev-server", kind: "command", awaited: false }], }); expect(shell.latestRun?.status).toBe(status); expect(shell.runtime).toMatchObject({ status, activeRunId: runId }); - expect(shell.pendingBackgroundTasks).toEqual([{ taskId: "dev-server", kind: "command" }]); + expect(shell.pendingBackgroundTasks).toEqual([ + { taskId: "dev-server", kind: "command", awaited: false }, + ]); }, ); @@ -397,6 +411,7 @@ describe("V2 client presentation", () => { deriveThreadRuntime({ ...v2Projection, runs: [run], turnItems: [commandItem] }), ).toMatchObject({ status: "waiting", activeRunId: null }); + // A command known only from its turn item has no left-running mark, so it does not hold. for (const [turnItems, status] of [ [[commandItem], "completed"], [[backgroundItem], "idle"], diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 026e74831233d..fe704d275c2ce 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -980,10 +980,10 @@ const PendingBackgroundTaskFields = { /** The subagent task that started this work and waits on it. */ parentTaskId: Schema.optional(TrimmedNonEmptyString), /** - * The agent declared it resumes when this work ends, so the thread holds like it does for a - * subagent. Only the agent knows whether a command is a wait or a dev server it left running. + * The agent's declaration: `true` when it resumes when this work ends, `false` for work it + * left running (dev server, watcher). Unset commands and monitors hold like `true`. */ - awaited: Schema.optional(Schema.Literal(true)), + awaited: Schema.optional(Schema.Boolean), }; export const OrchestrationV2PendingBackgroundTask = kindUnionWithFallback( diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index caf261bfbb1e9..4354104d926bf 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -154,8 +154,13 @@ describe("projectThreadAwarenessV2", () => { }); it.each([ - ["only a dev server", "completed", [{ taskId: "dev", kind: "command" }]], - ["a dev server's watchdog monitor", "completed", [{ taskId: "watch", kind: "monitor" }]], + ["only a dev server", "completed", [{ taskId: "dev", kind: "command", awaited: false }]], + [ + "a dev server's watchdog monitor", + "completed", + [{ taskId: "watch", kind: "monitor", awaited: false }], + ], + ["an unmarked command", "running", [{ taskId: "deploy", kind: "command" }]], [ "a command the agent waits on", "running", @@ -165,7 +170,7 @@ describe("projectThreadAwarenessV2", () => { "a dev server and a subagent", "running", [ - { taskId: "dev", kind: "command" }, + { taskId: "dev", kind: "command", awaited: false }, { taskId: "review", kind: "subagent" }, ], ], diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 1b5331c0d1bed..fc36bca83dbb7 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -7,9 +7,19 @@ import { describe("backgroundWorkHoldsCompletion", () => { it.each([ - ["a dev server", false, [{ taskId: "dev", kind: "command" }]], - ["a dev server's watchdog monitor", false, [{ taskId: "watch", kind: "monitor" }]], + ["an unmarked command", true, [{ taskId: "tests", kind: "command" }]], + ["an unmarked monitor", true, [{ taskId: "watch", kind: "monitor" }]], ["a command the agent waits on", true, [{ taskId: "tests", kind: "command", awaited: true }]], + ["a dev server left running", false, [{ taskId: "dev", kind: "command", awaited: false }]], + ["its watchdog left running", false, [{ taskId: "watch", kind: "monitor", awaited: false }]], + [ + "a dev server beside a command the agent waits on", + true, + [ + { taskId: "dev", kind: "command", awaited: false }, + { taskId: "tests", kind: "command" }, + ], + ], ["a subagent", true, [{ taskId: "review", kind: "subagent" }]], ] as const)("with %s pending, holds completion: %s", (_case, holds, tasks) => { expect(backgroundWorkHoldsCompletion(tasks)).toBe(holds); @@ -109,7 +119,9 @@ describe("derivePendingBackgroundWork", () => { }, ], }); - expect(tasks).toEqual([{ taskId: "cmd-1", description: "npm test", kind: "command" }]); + expect(tasks).toEqual([ + { taskId: "cmd-1", description: "npm test", kind: "command", awaited: false }, + ]); }); it("excludes rolled_back items when the latest run is waiting", () => { @@ -146,6 +158,7 @@ describe("derivePendingBackgroundWork", () => { taskId: "cmd-new", description: "still pending", kind: "command", + awaited: false, }, ]); }); @@ -173,7 +186,9 @@ describe("derivePendingBackgroundWork", () => { }, ], }); - expect(tasks).toEqual([{ taskId: "cmd-1", description: "npm test", kind: "command" }]); + expect(tasks).toEqual([ + { taskId: "cmd-1", description: "npm test", kind: "command", awaited: false }, + ]); }); it("dedupes roster entries against turn items by native task id", () => { @@ -306,6 +321,7 @@ describe("derivePendingBackgroundWork", () => { taskId: "cmd-null", description: "orphan item", kind: "command", + awaited: false, }, ]); }); @@ -356,7 +372,7 @@ describe("derivePendingBackgroundWork kinds", () => { kind: "subagent", childThreadId: "thread:child", }, - { taskId: "cmd", description: "npm test", kind: "command" }, + { taskId: "cmd", description: "npm test", kind: "command", awaited: false }, ]); }); diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 6dc635b5153a6..c31f7332ccbc0 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -89,18 +89,21 @@ export function deriveBlockedOnThreadWait(input: { export function backgroundWorkHoldsCompletion( tasks: ReadonlyArray>, ): boolean { - return tasks.some( - (task) => task.awaited === true || backgroundWorkKindHoldsCompletion(task.kind), - ); + return tasks.some((task) => backgroundWorkKindHoldsCompletion(task.kind, task.awaited)); } -// Commands and monitors are as often a dev server or its watchdog as a wait, so they hold -// only when the agent marks them awaited. -function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean { +// Agents usually wait on the commands and monitors they start, so those hold unless marked +// `awaited: false`: by the agent for one it leaves running (dev server, watcher), or for turn-item +// work from adapters with no way to mark it. Subagents and other +// background tasks always hold. +function backgroundWorkKindHoldsCompletion( + kind: PendingBackgroundWorkTask["kind"], + awaited: PendingBackgroundWorkTask["awaited"], +): boolean { switch (kind) { case "command": case "monitor": - return false; + return awaited !== false; case "subagent": case "background_task": return true; @@ -192,7 +195,10 @@ function pendingTaskFromTurnItem( ...(item.childThreadId == null ? {} : { childThreadId: item.childThreadId }), }; case "command_execution": - return { ...named, kind: item.monitor === true ? "monitor" : "command" }; + // Only provider rosters (Claude) let the agent mark a command it leaves running, so a + // command known only from its turn item, such as a Codex background terminal, may be a dev + // server and does not hold. + return { ...named, kind: item.monitor === true ? "monitor" : "command", awaited: false }; default: return { ...named, kind: "background_task" }; }