@loicngr/kobo 2.1.10 → 2.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/AGENTS.md +30 -3
  2. package/CHANGELOG.md +8 -0
  3. package/CONFIGURATION.md +32 -3
  4. package/dist/server/services/agent/engines/claude-code/engine.js +184 -47
  5. package/dist/server/services/sentry-service.js +13 -3
  6. package/dist/server/services/settings-service.js +4 -0
  7. package/dist/server/services/workspace-template-service.js +37 -3
  8. package/dist/server/utils/mcp-client.js +33 -3
  9. package/package.json +1 -1
  10. package/src/client/dist/pwa/assets/ActivityFeed-BTRzc60o.js +2 -0
  11. package/src/client/dist/pwa/assets/{ChangelogPage-DkoB2IEa.js → ChangelogPage-DD90Nl6c.js} +1 -1
  12. package/src/client/dist/pwa/assets/ComparisonPanel-D_BQFWiT.js +2 -0
  13. package/src/client/dist/pwa/assets/{CreatePage-Com5PQPV.js → CreatePage-BZAoCukb.js} +2 -2
  14. package/src/client/dist/pwa/assets/CreatePage-CdCs8PRS.css +1 -0
  15. package/src/client/dist/pwa/assets/{DashboardPage-DOeZUleS.js → DashboardPage-DyvTdmBg.js} +1 -1
  16. package/src/client/dist/pwa/assets/{DiffViewer-Bke40k8T.js → DiffViewer-D0K2PlZD.js} +4 -4
  17. package/src/client/dist/pwa/assets/{DocumentsPanel-CO16-Q1X.js → DocumentsPanel-bm639sIu.js} +1 -1
  18. package/src/client/dist/pwa/assets/{DrawerToggleButton-fjWJjVF_.js → DrawerToggleButton-KfwSzjeY.js} +1 -1
  19. package/src/client/dist/pwa/assets/GitPanel-C-Ejlc9_.js +3 -0
  20. package/src/client/dist/pwa/assets/{GitPanel-C3blk_Pd.css → GitPanel-CNFtj6k0.css} +1 -1
  21. package/src/client/dist/pwa/assets/HealthPage-37IHOIW-.js +1 -0
  22. package/src/client/dist/pwa/assets/HealthPage-Cnlhrn--.css +1 -0
  23. package/src/client/dist/pwa/assets/{MainLayout-DAcSwgUw.css → MainLayout-BQOOup9o.css} +1 -1
  24. package/src/client/dist/pwa/assets/MainLayout-DXQ1ouYl.js +2 -0
  25. package/src/client/dist/pwa/assets/{MemoryPanel-DKp9mn5X.js → MemoryPanel-CucZMhjy.js} +1 -1
  26. package/src/client/dist/pwa/assets/{MemoryProposalList-B3v7marz.js → MemoryProposalList-CUQybzVR.js} +1 -1
  27. package/src/client/dist/pwa/assets/{QBtnDropdown-a6q0PVGm.js → QBtnDropdown-x6qPyl_7.js} +1 -1
  28. package/src/client/dist/pwa/assets/{QExpansionItem-2fylQBjy.js → QExpansionItem-D33aoRoG.js} +1 -1
  29. package/src/client/dist/pwa/assets/{QScrollArea-I9pplcfM.js → QScrollArea-Di-D6t2u.js} +1 -1
  30. package/src/client/dist/pwa/assets/QSelect-MdPXvwm4.js +1 -0
  31. package/src/client/dist/pwa/assets/{QTooltip-fzzd3anS.js → QTooltip-BhwG7lp2.js} +1 -1
  32. package/src/client/dist/pwa/assets/SchedulePanel-AugvL_tp.css +1 -0
  33. package/src/client/dist/pwa/assets/SchedulePanel-qFi9Wxzh.js +1 -0
  34. package/src/client/dist/pwa/assets/{SearchPage-CGul0ui9.js → SearchPage-DA9nRmsA.js} +1 -1
  35. package/src/client/dist/pwa/assets/{SettingsPage-C1dcMqCO.css → SettingsPage-BRvUAZ5r.css} +1 -1
  36. package/src/client/dist/pwa/assets/SettingsPage-DcV6apqE.js +9 -0
  37. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DxifLUAa.js → SplitWorkspacePage-Brw2XsYJ.js} +1 -1
  38. package/src/client/dist/pwa/assets/{TerminalPanel-CRo3ETg_.js → TerminalPanel-DVP18-nk.js} +9 -9
  39. package/src/client/dist/pwa/assets/{ToolCallItem-i60zEGsY.js → ToolCallItem-cSHh3p_F.js} +3 -3
  40. package/src/client/dist/pwa/assets/{TouchPan-DjFq7Kjf.js → TouchPan-C6LZZ3rw.js} +1 -1
  41. package/src/client/dist/pwa/assets/{TourReplayButton-DmEgJ_5R.js → TourReplayButton-Czlqo18H.js} +1 -1
  42. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-B9LE8HtU.css +1 -0
  43. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-BDYGq0pC.js +1 -0
  44. package/src/client/dist/pwa/assets/WorkspacePage-BwUkijpS.js +4 -0
  45. package/src/client/dist/pwa/assets/{WorkspacePage-Ba0jAHhn.css → WorkspacePage-Bwfv559o.css} +1 -1
  46. package/src/client/dist/pwa/assets/{browser-3unJ6AQX.js → browser-ifCwUGpE.js} +1 -1
  47. package/src/client/dist/pwa/assets/{build-path-tree-CPo0G1mj.js → build-path-tree-BBkGq3lN.js} +1 -1
  48. package/src/client/dist/pwa/assets/custom-sounds-CeSgjIY7.js +1 -0
  49. package/src/client/dist/pwa/assets/{de-B3oUHpbN.js → de-CHY2BAfL.js} +1 -1
  50. package/src/client/dist/pwa/assets/{editor.api-CMBx0cvf.js → editor.api-CV_seGJR.js} +1 -1
  51. package/src/client/dist/pwa/assets/{efforts-Bjrcpo2R.js → efforts-ttw0bL63.js} +2 -2
  52. package/src/client/dist/pwa/assets/{es-X1ki-Z2w.js → es-DbcSKp38.js} +1 -1
  53. package/src/client/dist/pwa/assets/{expand-template-cR43uBa5.js → expand-template-C8biMRXo.js} +1 -1
  54. package/src/client/dist/pwa/assets/{format-B1k_qdxW.js → format-Cu95dbxt.js} +1 -1
  55. package/src/client/dist/pwa/assets/{formatters-gz0Rg7Rw.js → formatters-C25D1RSd.js} +1 -1
  56. package/src/client/dist/pwa/assets/{fr-nkT5LuOx.js → fr-CyyJ8S2c.js} +1 -1
  57. package/src/client/dist/pwa/assets/{freemarker2-C8DHgbX4.js → freemarker2-DJEFr74q.js} +1 -1
  58. package/src/client/dist/pwa/assets/{handlebars-BmEGWkcn.js → handlebars-C_lzsMjX.js} +1 -1
  59. package/src/client/dist/pwa/assets/{html-giJc-yQc.js → html-CWmLFAmY.js} +1 -1
  60. package/src/client/dist/pwa/assets/i18n-CobVQiTM.js +1 -0
  61. package/src/client/dist/pwa/assets/{index-BjqJ45nb.js → index-ImUH7Bbt.js} +3 -3
  62. package/src/client/dist/pwa/assets/{it-Bfp_J8p0.js → it-XQJW2Xkr.js} +1 -1
  63. package/src/client/dist/pwa/assets/{javascript-CwQBOtjT.js → javascript-tsuiEUe-.js} +1 -1
  64. package/src/client/dist/pwa/assets/{liquid-BPEVDB8V.js → liquid-BBr9BvUk.js} +1 -1
  65. package/src/client/dist/pwa/assets/{mdx-DAa4McnC.js → mdx-gjBH1cXG.js} +1 -1
  66. package/src/client/dist/pwa/assets/{monaco-contributions-Ct4RhitT.js → monaco-contributions-CvGcBIsb.js} +1 -1
  67. package/src/client/dist/pwa/assets/{monaco.contribution-BKnd0ZwH.js → monaco.contribution-Uw-Lfb8Z.js} +2 -2
  68. package/src/client/dist/pwa/assets/network-auth-D0Vm6a09.js +1 -0
  69. package/src/client/dist/pwa/assets/notify-theme-PMSxqP6t.js +1 -0
  70. package/src/client/dist/pwa/assets/{project-color-LZ5CKuTW.js → project-color-BDQa2TgU.js} +1 -1
  71. package/src/client/dist/pwa/assets/{python-D8xYERv8.js → python-DlIgD1n6.js} +1 -1
  72. package/src/client/dist/pwa/assets/{razor-BnFyr836.js → razor-AraFPlpM.js} +1 -1
  73. package/src/client/dist/pwa/assets/{typescript-DW45V56c.js → typescript-CB8UpXHM.js} +1 -1
  74. package/src/client/dist/pwa/assets/{use-search-index-status-DzlqDFPy.js → use-search-index-status-CSREHQM4.js} +1 -1
  75. package/src/client/dist/pwa/assets/{use-tours-CErX5nL7.js → use-tours-6yxNVmjH.js} +2 -2
  76. package/src/client/dist/pwa/assets/use-worktree-restore-BM3Giig1.js +1 -0
  77. package/src/client/dist/pwa/assets/{workspace-templates-CKduJ2zA.js → workspace-templates-BPMFvTnQ.js} +1 -1
  78. package/src/client/dist/pwa/assets/{xml-gQTv5O31.js → xml-uRjL1P43.js} +1 -1
  79. package/src/client/dist/pwa/assets/{yaml-B4TEYUEa.js → yaml-C9CFR4qV.js} +1 -1
  80. package/src/client/dist/pwa/index.html +1 -1
  81. package/src/client/dist/pwa/sw.js +1 -1
  82. package/src/client/dist/pwa/assets/ActivityFeed-CkiYsNd5.js +0 -2
  83. package/src/client/dist/pwa/assets/ComparisonPanel-DH9Q0XNK.js +0 -2
  84. package/src/client/dist/pwa/assets/CreatePage-C7fLOpai.css +0 -1
  85. package/src/client/dist/pwa/assets/GitPanel-BAgnXzXP.js +0 -3
  86. package/src/client/dist/pwa/assets/HealthPage-DR-ywiB2.js +0 -1
  87. package/src/client/dist/pwa/assets/HealthPage-DT8FGrKA.css +0 -1
  88. package/src/client/dist/pwa/assets/MainLayout-B1suu_Nn.js +0 -2
  89. package/src/client/dist/pwa/assets/QSelect-CWXNtFAO.js +0 -1
  90. package/src/client/dist/pwa/assets/SchedulePanel-4KNBIQvY.js +0 -1
  91. package/src/client/dist/pwa/assets/SchedulePanel-CBM_GpS3.css +0 -1
  92. package/src/client/dist/pwa/assets/SettingsPage-_h4xQlof.js +0 -9
  93. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-C7MLWyKy.js +0 -1
  94. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DSw8Upcp.css +0 -1
  95. package/src/client/dist/pwa/assets/WorkspacePage-Bw7yTZ3h.js +0 -4
  96. package/src/client/dist/pwa/assets/custom-sounds-C5fGFFdT.js +0 -1
  97. package/src/client/dist/pwa/assets/i18n-PAAy4Hkn.js +0 -1
  98. package/src/client/dist/pwa/assets/network-auth-S-KP7qhA.js +0 -1
  99. package/src/client/dist/pwa/assets/notify-theme-DIqeoflM.js +0 -1
  100. package/src/client/dist/pwa/assets/use-worktree-restore-BpaEGfds.js +0 -1
package/AGENTS.md CHANGED
@@ -197,6 +197,15 @@ Every feature that touches the schema:
197
197
 
198
198
  ## WebSocket protocol
199
199
 
200
+ Claude streaming input is correlated by per-message UUIDs. A result can cover
201
+ several prompts through `user_message_uuids` (messages folded into one turn),
202
+ not one result per sent message. Retire only the matching yielded inputs;
203
+ locally queued or unacknowledged messages still block `turn:completed` and
204
+ input closure. UUID-less legacy results retain a conservative one-input
205
+ fallback for already yielded inputs only; a result never credits a future send.
206
+ Backpressure caps unacknowledged yielded inputs at 64, matching the SDK's result
207
+ UUID list bound. Keep the inactivity watchdog for genuinely unanswered work.
208
+
200
209
  Claude SDK `task_*` messages cover agents **and** background jobs. Preserve `task_type` by task/tool identity across progress and terminal messages, which often omit it. The existing `subagent:progress` envelope also carries this lifecycle metadata for compatibility; `local_bash`, `local_workflow` and `mcp_task` must be excluded from agent cards, busy-agent counts, chat agent-name resolution and the selective stop-subagents action. Keep tracking these jobs for engine liveness and whole-session interruption. Legacy/unknown classifications and Codex agents remain supported; never classify from a description or from the agent's last tool (`Bash` can be a real agent's current action).
201
210
 
202
211
  ### External MCP dialogue and update checks
@@ -220,8 +229,10 @@ Clients subscribe to individual workspace ids. The server sends `WsEvent` object
220
229
  Agent engines emit a normalized `AgentEvent` union, carried by `agent:event`. Common outer types include `user:message`, `task:updated`, `devserver:status`, `workspace:status`, `workspace:archived`, `workspace:unarchived`, and `sync:response`. Legacy `agent:output` rows remain supported through content migration. Keep the backend union in `services/agent/engines/types.ts` and its client mirror in `client/src/types/agent-event.ts` synchronized.
221
230
 
222
231
  Error events may carry a stable `code`. Claude's post-result drain timeout uses
223
- `result_drain_timeout` and its stream inactivity timeout uses `stream_idle_timeout`:
224
- keep both in the chat but exclude them from `AgentErrorBanner`. The selector also
232
+ `result_drain_timeout` and its stream inactivity timeout uses `stream_idle_timeout`.
233
+ Background agents, background tools, and compaction use `subagent_stall_timeout`,
234
+ `background_task_stall_timeout`, and `compaction_stall_timeout`, respectively:
235
+ keep these notices in the chat but exclude them from `AgentErrorBanner`. The selector also
225
236
  recognizes their exact legacy messages for persisted history. Other errors keep
226
237
  their banners. A `session:ended` with reason `watchdog` leaves a manual workspace
227
238
  `idle`, unless accompanied by a nonzero exit code. Preserve the watchdog end reason
@@ -256,7 +267,15 @@ See the "Notion integration" section of the README for the end-user setup guide.
256
267
 
257
268
  Two engines live under `src/server/services/agent/engines/`, both implementing the `AgentEngine` contract in `types.ts`:
258
269
 
259
- **Claude Code** (`claude-code/`): consumes the `@anthropic-ai/claude-agent-sdk` async iterator. The SDK manages the Claude runtime; Kōbō does not launch a separate `claude` CLI command. Authentication reuses the user's Claude login or `ANTHROPIC_API_KEY`. The engine arms a **15 s result-drain watchdog** when the SDK emits its `result` message: if the async iterator does not close cleanly within the window, `session:ended` is force-emitted so the orchestrator and auto-loop never hang on a stuck generator. The watchdog is idempotent via a `sessionEndedEmitted` guard and the timer is cleared in `finally`.
270
+ **Claude Code** (`claude-code/`): consumes the `@anthropic-ai/claude-agent-sdk` async iterator. The SDK manages the Claude runtime; Kōbō does not launch a separate `claude` CLI command. Authentication reuses the user's Claude login or `ANTHROPIC_API_KEY`. A settled result gets a **3 s continuation grace**, then a **15 s result-drain watchdog**. Trailing metadata and child output must not cancel the parent's drain/grace. Silence is bounded at **2 minutes** without tools, **30 minutes** for foreground tools and background shell/workflow/MCP jobs, and **10 minutes** for agents. Compaction has an absolute **10-minute** ceiling; repeated status messages cannot extend it. Human questions/permissions pause inactivity; resolution AND SDK cancellation re-evaluate that pause. Watchdogs request abort plus SDK `query.close()`; neither is proof of closure: controller ownership is released only when `EngineProcess.closed` confirms it. Never fake closure or allow a second writer if the SDK ignores termination. End emission is idempotent and timers are cleared on termination and in `finally`.
271
+
272
+ After the continuation grace, Claude explicitly calls `query.close()`; closing
273
+ the input iterator alone can leave the SDK waiting for an idle notification after
274
+ a successful result. The 15-second drain watchdog is a failed-close backstop,
275
+ not normal cleanup. The SDK still constructs its runtime command; Kōbō's
276
+ `spawnClaudeCodeProcess` hook tracks actual child exit and forwards stderr.
277
+ `EngineProcess.closed` waits for both that exit and iterator completion because
278
+ the SDK's internal cleanup wait is bounded and can return before child exit.
260
279
 
261
280
  **OpenAI Codex** (`codex/`): uses the **`codex app-server` JSON-RPC protocol** (line-delimited JSON over stdio with a long-lived `codex` subprocess). The engine layers are:
262
281
  - `jsonrpc/transport.ts` + `jsonrpc/peer.ts`: generic JSON-RPC 2.0 stdio peer (request correlation, notifications, server-initiated requests)
@@ -438,6 +457,14 @@ One task, two engines, two sibling worktrees. `POST /api/workspaces` accepts a `
438
457
 
439
458
  ### Workspace templates and duplication
440
459
 
460
+ Workspace template presets include optional `tags`. New form captures always
461
+ store the selection (including `[]`); legacy omission preserves the current
462
+ form selection. Server reads/writes filter tags against the current global
463
+ catalogue. Catalogue updates and config imports prune saved template references
464
+ before saving settings, so removed tags cannot reappear when re-added later.
465
+ Only affected presets are rewritten; existing workspace tags are untouched.
466
+ A corrupt template file aborts catalogue cleanup without replacing that file.
467
+
441
468
  `workspace-template-service.ts` persists named presets of the create form in `<KOBO_HOME>/workspace-templates.json` (same JSON pattern as `templates.json`, no SQLite migration). `sanitizePreset` keeps known keys with the right type and drops the rest, so a hand-edited file degrades to unset fields rather than errors. `presetFromWorkspace(id)` derives the same shape from an existing workspace (tasks as titles, statuses dropped); `GET /api/workspaces/:id/preset` exposes it read-only and "Duplicate" opens `/create?from=<id>` with it. On the client, `utils/workspace-preset.ts` (`capturePreset` / `applyPreset`, pure, round-trip tested) is the single bridge between the form and a preset; the create page applies the engine first and waits a tick so its engine watchers normalise model / effort / permission mode before the preset's values land. See [CONFIGURATION.md → Workspace templates and duplication](CONFIGURATION.md#workspace-templates-and-duplication).
442
469
 
443
470
  ### Onboarding tours
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 2.1.12
8
+
9
+ - fix(claude): close settled SDK sessions without false watchdogs
10
+
11
+ ## 2.1.11
12
+
13
+ - fix: harden agent lifecycle and workspace workflows
14
+
7
15
  ## 2.1.10
8
16
 
9
17
  - fix: correct memory budgets and subagent detection
package/CONFIGURATION.md CHANGED
@@ -623,7 +623,7 @@ original agent automatically; review output already recorded remains available.
623
623
 
624
624
  A template is a named preset of the create-workspace form: project (optional),
625
625
  source branch, branch type, engine, model, reasoning effort, permission mode,
626
- auto-loop settings, description, tasks and acceptance criteria. The workspace
626
+ auto-loop settings, description, tasks, acceptance criteria and tags. The workspace
627
627
  name and working branch are never part of it - they are derived at each
628
628
  creation - and neither are Notion / Sentry / PR URLs, which are one-off
629
629
  context.
@@ -638,6 +638,14 @@ existing name asks before overwriting. Templates are managed (rename, delete,
638
638
  inspect) in **Settings → Workspace templates**; to edit one, load it on the
639
639
  create page, adjust, and save it again under the same name.
640
640
 
641
+ Templates save the selected tags, including an explicit empty selection. Older
642
+ templates without a tags field leave the current selection unchanged. Removing
643
+ a tag from the global catalogue also removes it from every saved workspace
644
+ template (including catalogue changes through configuration import). Other
645
+ template fields and tags on existing workspaces are preserved. Stale clients
646
+ cannot save removed tags back into templates. If the template file is corrupt,
647
+ the catalogue change is refused and the file is preserved for recovery.
648
+
641
649
  **Duplicate**, in a workspace's context menu, opens the create page prefilled
642
650
  from that workspace - its settings, description, tasks and criteria (all reset
643
651
  to "to do") - with the name suffixed "(copy)". Nothing is created until you
@@ -647,8 +655,8 @@ brainstorm effort nor "skip setup script", which are creation-time choices.
647
655
  Archived workspaces can be duplicated too.
648
656
 
649
657
  Templates live in `<KOBO_HOME>/workspace-templates.json` (100 at most). A
650
- corrupt file is read as empty and reported in the server log, and is only
651
- rewritten by the next save.
658
+ corrupt file produces an error and is preserved; restore a valid file before
659
+ saving templates or changing the tag catalogue.
652
660
 
653
661
  ## Auto-purge worktree on PR merged
654
662
 
@@ -1262,6 +1270,27 @@ Kōbō talks to the embedded [`@anthropic-ai/claude-agent-sdk`](https://github.c
1262
1270
 
1263
1271
  The model selector is populated from [`src/shared/models.ts`](./src/shared/models.ts), also used by backend validation. This checkout includes Fable 5.1, Mythos 5, Opus 5, Opus 4.8, Sonnet 5 and older variants. Pick the default in **Settings → Agents**. Catalogue entries describe what Kōbō offers; provider access still depends on your account. `auto` leaves model selection to the runtime.
1264
1272
 
1273
+ #### Session watchdogs
1274
+
1275
+ Kōbō distinguishes a finished response from a fully closed SDK session. Once a
1276
+ response is settled, it allows 3 seconds for continuation, then explicitly closes
1277
+ the SDK query. Normal cleanup is reported as completion, not a watchdog failure.
1278
+ A 15-second drain watchdog remains if that close fails to end the iterator.
1279
+ Metadata alone cannot extend this cleanup deadline. Writer ownership waits for
1280
+ both iterator completion and confirmed runtime exit, even if the SDK's own
1281
+ bounded cleanup returns earlier.
1282
+
1283
+ Silence limits are 2 minutes without a pending tool, 10 minutes for background
1284
+ agents, and 30 minutes for tools (including background Bash, workflow and MCP
1285
+ jobs). A compaction has a separate absolute 10-minute limit. Waiting for your
1286
+ answer or permission pauses the inactivity watchdog.
1287
+
1288
+ Watchdog notices remain in conversation history, but do not display a red error
1289
+ banner. Other failures retain their banners. Watchdog shutdown requests both
1290
+ cancellation and closure of the SDK transport. Kōbō still requires confirmed
1291
+ closure before another agent can write in the same workspace; a runtime that
1292
+ refuses to stop is not treated as safely terminated.
1293
+
1265
1294
  ### OpenAI Codex
1266
1295
 
1267
1296
  > Experimental. The Claude Code engine remains the primary, battle-tested path.
@@ -1,3 +1,5 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
1
3
  import { query, } from '@anthropic-ai/claude-agent-sdk';
2
4
  import { nanoid } from 'nanoid';
3
5
  import { isSubagentTask } from '../../../../../shared/subagent-classification.js';
@@ -35,8 +37,7 @@ export const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 120_000;
35
37
  * stream is still reaped once this longer ceiling elapses.
36
38
  */
37
39
  export const CLAUDE_TOOL_IDLE_TIMEOUT_MS = 30 * 60_000;
38
- /** Ceiling on a context compaction: past this, assume it wedged and let the
39
- * liveness deadline take over instead of pausing forever. */
40
+ /** Absolute ceiling on a context compaction, independent of tool idle time. */
40
41
  export const COMPACTION_STALL_TIMEOUT_MS = 10 * 60_000;
41
42
  // Safety net for a subagent whose terminal `task_notification` carries a
42
43
  // status outside the mapper's known-terminal set (SDK schema drift) — it
@@ -73,7 +74,7 @@ class ClaudeInputStream {
73
74
  waiting;
74
75
  closed = false;
75
76
  queuedForcedMessages = 0;
76
- yieldedMessages = 0;
77
+ unansweredMessages = new Set();
77
78
  constructor(initialPrompt) {
78
79
  this.messages = [{ message: this.toUserMessage(initialPrompt), forced: false }];
79
80
  }
@@ -95,16 +96,43 @@ class ClaudeInputStream {
95
96
  this.waiting = undefined;
96
97
  wake?.();
97
98
  }
98
- hasUnansweredInput(completedResponses) {
99
- return this.queuedForcedMessages > 0 || this.yieldedMessages > completedResponses;
99
+ hasUnansweredInput() {
100
+ return this.queuedForcedMessages > 0 || this.unansweredMessages.size > 0;
101
+ }
102
+ acknowledgeResult(result) {
103
+ // One Claude turn can consume several queued prompts. A result counts
104
+ // turns, not messages: only retire the sends explicitly covered by it.
105
+ const ids = [...(result.user_message_uuids ?? []), ...(result.user_message_uuid ? [result.user_message_uuid] : [])];
106
+ if (result.user_message_uuids !== undefined || result.user_message_uuid !== undefined) {
107
+ for (const id of ids)
108
+ this.unansweredMessages.delete(id);
109
+ }
110
+ else {
111
+ // A legacy result can acknowledge an already delivered input, never a
112
+ // future one (e.g. an interrupted turn's result on resume).
113
+ const oldest = this.unansweredMessages.values().next().value;
114
+ if (oldest)
115
+ this.unansweredMessages.delete(oldest);
116
+ }
117
+ const wake = this.waiting;
118
+ this.waiting = undefined;
119
+ wake?.();
100
120
  }
101
121
  async *[Symbol.asyncIterator]() {
102
122
  while (!this.closed || this.messages.length > 0) {
123
+ // The SDK echoes at most 64 consumed UUIDs per result. Backpressure
124
+ // prevents losing correlation without guessing which inputs were read.
125
+ if (!this.closed && this.unansweredMessages.size >= 64) {
126
+ await new Promise((resolve) => {
127
+ this.waiting = resolve;
128
+ });
129
+ continue;
130
+ }
103
131
  const next = this.messages.shift();
104
132
  if (next) {
105
133
  if (next.forced)
106
134
  this.queuedForcedMessages--;
107
- this.yieldedMessages++;
135
+ this.unansweredMessages.add(next.message.uuid);
108
136
  yield next.message;
109
137
  continue;
110
138
  }
@@ -114,7 +142,7 @@ class ClaudeInputStream {
114
142
  }
115
143
  }
116
144
  toUserMessage(text) {
117
- return { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null };
145
+ return { type: 'user', uuid: randomUUID(), message: { role: 'user', content: text }, parent_tool_use_id: null };
118
146
  }
119
147
  }
120
148
  export function createClaudeCodeEngine() {
@@ -210,6 +238,9 @@ export function createClaudeCodeEngine() {
210
238
  const onAbort = () => {
211
239
  if (pendingResolvers.get(toolCallId) === resolver) {
212
240
  pendingResolvers.delete(toolCallId);
241
+ reevaluateLivenessPause();
242
+ if (settlementPending)
243
+ armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
213
244
  const abortError = new Error('Pending user input aborted');
214
245
  abortError.name = 'AbortError';
215
246
  reject(abortError);
@@ -297,6 +328,31 @@ export function createClaudeCodeEngine() {
297
328
  env: options.env,
298
329
  });
299
330
  sdkOptions.abortController = abortController;
331
+ // SDK cleanup has its own bounded wait. Iterator completion alone does
332
+ // not prove that its runtime exited: retain writer ownership until both.
333
+ let runtimeRunning = false;
334
+ let runtimeClosed = Promise.resolve();
335
+ sdkOptions.spawnClaudeCodeProcess = ({ command, args, cwd, env, signal }) => {
336
+ const child = spawn(command, args, { cwd, env, signal, windowsHide: true, stdio: 'pipe' });
337
+ runtimeRunning = true;
338
+ runtimeClosed = new Promise((resolve) => {
339
+ const exited = () => {
340
+ runtimeRunning = false;
341
+ resolve();
342
+ };
343
+ child.once('exit', exited);
344
+ child.once('error', () => {
345
+ // A failed spawn has no process; an abort/kill error does not
346
+ // establish that an already spawned runtime has actually exited.
347
+ if (child.pid === undefined)
348
+ exited();
349
+ });
350
+ });
351
+ child.stderr.setEncoding('utf8');
352
+ child.stderr.on('data', (data) => sdkOptions.stderr?.(data));
353
+ child.stderr.on('error', () => { });
354
+ return child;
355
+ };
300
356
  // Override the SDK's libc-blind binary resolution on Linux glibc — see
301
357
  // resolve-binary.ts for the full rationale. No-op on macOS/Windows/musl.
302
358
  const explicitBinary = resolveClaudeBinaryPath();
@@ -304,6 +360,27 @@ export function createClaudeCodeEngine() {
304
360
  sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
305
361
  const inputStream = new ClaudeInputStream(effectivePrompt);
306
362
  const q = query({ prompt: inputStream, options: sdkOptions });
363
+ let queryCloseRequested = false;
364
+ const terminateQuery = () => {
365
+ if (queryCloseRequested)
366
+ return;
367
+ queryCloseRequested = true;
368
+ inputStream.close();
369
+ abortController.abort();
370
+ turnLiveness.stop();
371
+ clearContinuationGrace();
372
+ clearResultDrainWatchdog();
373
+ clearSubagentStallWatchdog();
374
+ clearCompactionStallTimer();
375
+ // Abort alone can leave the SDK iterator parked. close() explicitly
376
+ // tears down its transport/process. Ownership still waits for closed.
377
+ try {
378
+ q.close?.();
379
+ }
380
+ catch (error) {
381
+ console.warn('[claude-engine] SDK transport close failed:', error);
382
+ }
383
+ };
307
384
  // Best-effort SDK stop of one background task; failures are ignored.
308
385
  const requestStopTask = (taskId) => {
309
386
  try {
@@ -344,6 +421,9 @@ export function createClaudeCodeEngine() {
344
421
  const safeEmit = (ev) => streamingBatcher.push(ev);
345
422
  let iteratorRunning = false;
346
423
  let userInterrupted = false;
424
+ // Normal post-result disposal is distinct from a watchdog/user abort.
425
+ // The SDK can wait for an `idle` state even after stdin's iterable ends.
426
+ let settledCloseRequested = false;
347
427
  let completedResponses = 0;
348
428
  let waitingForBackground = false;
349
429
  let turnCompletedEmittedForResponse = 0;
@@ -356,7 +436,9 @@ export function createClaudeCodeEngine() {
356
436
  completedResponses === turnCompletedEmittedForResponse ||
357
437
  activeSubagentTaskIds.size > 0 ||
358
438
  pendingResolvers.size > 0 ||
359
- inputStream.hasUnansweredInput(completedResponses)) {
439
+ isCompacting ||
440
+ pendingToolCallIds.size > 0 ||
441
+ inputStream.hasUnansweredInput()) {
360
442
  return;
361
443
  }
362
444
  turnCompletedEmittedForResponse = completedResponses;
@@ -366,6 +448,7 @@ export function createClaudeCodeEngine() {
366
448
  // catch block) never both emit `session:ended` for the same run.
367
449
  let sessionEndedEmitted = false;
368
450
  let isCompacting = false;
451
+ let settlementPending = false;
369
452
  const emitSessionEnded = (reason, exitCode) => {
370
453
  if (sessionEndedEmitted)
371
454
  return;
@@ -384,7 +467,7 @@ export function createClaudeCodeEngine() {
384
467
  // its task notification and an automatic continuation turn.
385
468
  let resultDrainTimer;
386
469
  // D2 — one shared liveness module for both engines. Armed before the
387
- // first SDK message, re-armed on EVERY message, suspended while a human
470
+ // first SDK message, re-armed on meaningful progress, suspended while a human
388
471
  // decision or a background subagent is outstanding, stopped in `finally`.
389
472
  const turnLiveness = createTurnLiveness({
390
473
  timeoutMs: CLAUDE_STREAM_IDLE_TIMEOUT_MS,
@@ -408,7 +491,7 @@ export function createClaudeCodeEngine() {
408
491
  });
409
492
  emitSessionEnded('watchdog', null);
410
493
  }
411
- abortController.abort();
494
+ terminateQuery();
412
495
  },
413
496
  });
414
497
  // The single source of truth for "should the idle deadline be paused
@@ -430,9 +513,8 @@ export function createClaudeCodeEngine() {
430
513
  // Backstop for a compaction that never reports completion (stuck
431
514
  // `session:compacting` with `active: true` forever): `isCompacting`
432
515
  // pauses `turnLiveness` indefinitely, and a wedged-but-alive generator
433
- // never trips `isAlive()`'s own sweep either. Past this ceiling, assume
434
- // it wedged and resume the liveness deadline so the existing tool-aware
435
- // / short deadline can reap it, mirroring `armSubagentStallWatchdog`.
516
+ // never trips `isAlive()`'s own sweep either. End it at the absolute
517
+ // ceiling instead of adding another tool-aware idle window afterward.
436
518
  let compactionStallTimer;
437
519
  const clearCompactionStallTimer = () => {
438
520
  if (!compactionStallTimer)
@@ -441,15 +523,23 @@ export function createClaudeCodeEngine() {
441
523
  compactionStallTimer = undefined;
442
524
  };
443
525
  const armCompactionStallTimer = () => {
444
- clearCompactionStallTimer();
526
+ if (compactionStallTimer)
527
+ return;
445
528
  compactionStallTimer = setTimeout(() => {
446
529
  compactionStallTimer = undefined;
447
530
  if (!isCompacting)
448
531
  return;
449
- console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — resuming the liveness deadline.`);
532
+ console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — closing the session.`);
450
533
  isCompacting = false;
451
534
  safeEmit({ kind: 'session:compacting', active: false });
452
- reevaluateLivenessPause();
535
+ safeEmit({
536
+ kind: 'error',
537
+ category: 'other',
538
+ code: 'compaction_stall_timeout',
539
+ message: 'Session force-ended: context compaction stopped reporting completion (watchdog).',
540
+ });
541
+ emitSessionEnded('watchdog', null);
542
+ terminateQuery();
453
543
  }, COMPACTION_STALL_TIMEOUT_MS);
454
544
  compactionStallTimer.unref?.();
455
545
  };
@@ -471,7 +561,7 @@ export function createClaudeCodeEngine() {
471
561
  });
472
562
  emitSessionEnded('watchdog', null);
473
563
  }
474
- abortController.abort();
564
+ terminateQuery();
475
565
  }, RESULT_DRAIN_TIMEOUT_MS);
476
566
  resultDrainTimer.unref?.();
477
567
  };
@@ -495,16 +585,33 @@ export function createClaudeCodeEngine() {
495
585
  clearContinuationGrace();
496
586
  continuationGraceTimer = setTimeout(() => {
497
587
  continuationGraceTimer = undefined;
498
- if (activeSubagentTaskIds.size > 0 ||
588
+ if (completedResponses === 0 ||
589
+ !settlementPending ||
590
+ activeSubagentTaskIds.size > 0 ||
499
591
  pendingResolvers.size > 0 ||
500
592
  pendingToolCallIds.size > 0 ||
501
- inputStream.hasUnansweredInput(completedResponses))
593
+ isCompacting ||
594
+ inputStream.hasUnansweredInput())
502
595
  return;
503
596
  // Settled only now: a result the CLI follows up on (e.g. a resumed
504
597
  // turn's empty result) must not hide the busy banner meanwhile.
505
598
  emitTurnCompletedIfSettled();
506
599
  inputStream.close();
507
600
  armResultDrainWatchdog();
601
+ // Closing the input iterable alone leaves the SDK in waitForRunEnd
602
+ // when its last session state is still running. Dispose explicitly,
603
+ // but let the iterator confirm actual closure before session:ended.
604
+ // The drain watchdog now guards a FAILED close, not normal cleanup.
605
+ if (!settledCloseRequested && typeof q.close === 'function') {
606
+ settledCloseRequested = true;
607
+ try {
608
+ q.close();
609
+ }
610
+ catch (error) {
611
+ settledCloseRequested = false;
612
+ console.warn('[claude-engine] Post-result SDK close failed:', error);
613
+ }
614
+ }
508
615
  }, delayMs);
509
616
  continuationGraceTimer.unref?.();
510
617
  };
@@ -525,19 +632,26 @@ export function createClaudeCodeEngine() {
525
632
  clearSubagentStallWatchdog();
526
633
  if (pendingResolvers.size > 0 || activeSubagentTaskIds.size === 0)
527
634
  return;
635
+ const hasAgent = [...activeSubagentTaskIds].some((id) => isSubagentTask({ taskType: mapperState.taskTypes.get(id) }));
636
+ // Shell/workflow/MCP jobs do not emit agent progress during a long
637
+ // command. Give them the same silence allowance as foreground tools.
638
+ const timeoutMs = hasAgent ? SUBAGENT_STALL_TIMEOUT_MS : CLAUDE_TOOL_IDLE_TIMEOUT_MS;
528
639
  subagentStallTimer = setTimeout(() => {
529
640
  subagentStallTimer = undefined;
530
641
  if (pendingResolvers.size > 0 || activeSubagentTaskIds.size === 0)
531
642
  return;
532
- console.warn(`[claude-engine] No subagent activity for ${SUBAGENT_STALL_TIMEOUT_MS}ms — forcing session:ended`);
643
+ console.warn(`[claude-engine] No background activity for ${timeoutMs}ms — forcing session:ended`);
533
644
  safeEmit({
534
645
  kind: 'error',
535
646
  category: 'other',
536
- message: 'Session force-ended: background subagents stopped reporting activity (watchdog).',
647
+ message: hasAgent
648
+ ? 'Session force-ended: background subagents stopped reporting activity (watchdog).'
649
+ : 'Session force-ended: background tools stopped reporting activity (watchdog).',
650
+ code: hasAgent ? 'subagent_stall_timeout' : 'background_task_stall_timeout',
537
651
  });
538
652
  emitSessionEnded('watchdog', null);
539
- abortController.abort();
540
- }, SUBAGENT_STALL_TIMEOUT_MS);
653
+ terminateQuery();
654
+ }, timeoutMs);
541
655
  subagentStallTimer.unref?.();
542
656
  };
543
657
  const iteratorPromise = (async () => {
@@ -545,22 +659,27 @@ export function createClaudeCodeEngine() {
545
659
  turnLiveness.start();
546
660
  try {
547
661
  for await (const msg of q) {
662
+ const events = mapSdkMessage(msg, mapperState);
663
+ const isForeground = !('parent_tool_use_id' in msg && msg.parent_tool_use_id != null);
664
+ const foregroundProgress = isForeground &&
665
+ events.some((ev) => ev.kind === 'message:text' ||
666
+ ev.kind === 'message:thinking' ||
667
+ ev.kind === 'tool:call' ||
668
+ ev.kind === 'tool:result');
548
669
  // A parent continuation owns the foreground again. Background
549
670
  // progress notifications alone do not end the between-turn wait.
550
- if (msg.type === 'assistant' || msg.type === 'user' || msg.type === 'stream_event') {
671
+ if (foregroundProgress) {
551
672
  waitingForBackground = false;
673
+ settlementPending = false;
552
674
  clearContinuationGrace();
675
+ clearResultDrainWatchdog();
553
676
  }
554
677
  else if (msg.type === 'system' && msg.subtype === 'init') {
555
678
  // A new run starts on this stream (e.g. after a resumed turn's empty result).
556
679
  clearContinuationGrace();
680
+ settlementPending = false;
681
+ clearResultDrainWatchdog();
557
682
  }
558
- // This SDK message proves that any drain condition armed by a
559
- // *previous* message is no longer current. A drain armed while
560
- // processing this message remains active if the generator then
561
- // goes silent.
562
- clearResultDrainWatchdog();
563
- const events = mapSdkMessage(msg, mapperState);
564
683
  for (const ev of events) {
565
684
  if (ev.kind === 'subagent:progress')
566
685
  trackSubagentProgress(ev);
@@ -578,17 +697,26 @@ export function createClaudeCodeEngine() {
578
697
  armSubagentStallWatchdog();
579
698
  for (const ev of events) {
580
699
  if (ev.kind === 'session:compacting') {
700
+ const wasCompacting = isCompacting;
581
701
  isCompacting = ev.active;
582
- if (ev.active)
702
+ if (ev.active) {
703
+ clearContinuationGrace();
704
+ clearResultDrainWatchdog();
583
705
  armCompactionStallTimer();
584
- else
706
+ }
707
+ else {
585
708
  clearCompactionStallTimer();
709
+ if (wasCompacting && settlementPending)
710
+ armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
711
+ }
586
712
  }
587
713
  // Older SDKs can emit only compact_boundary, without a trailing
588
714
  // status update. This boundary marks compaction as complete.
589
715
  else if (ev.kind === 'session:compacted') {
590
716
  isCompacting = false;
591
717
  clearCompactionStallTimer();
718
+ if (settlementPending)
719
+ armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
592
720
  }
593
721
  }
594
722
  // Actual foreground output proves work resumed even if the SDK
@@ -602,18 +730,16 @@ export function createClaudeCodeEngine() {
602
730
  safeEmit({ kind: 'session:compacting', active: false });
603
731
  }
604
732
  for (const ev of events) {
605
- if (ev.kind === 'tool:call')
733
+ if (isForeground && ev.kind === 'tool:call')
606
734
  pendingToolCallIds.add(ev.toolCallId);
607
- else if (ev.kind === 'tool:result')
735
+ else if (isForeground && ev.kind === 'tool:result')
608
736
  pendingToolCallIds.delete(ev.toolCallId);
609
737
  }
610
738
  // After a settled result, the last background completion drains
611
739
  // the stream only if the parent does not continue (see
612
740
  // BACKGROUND_CONTINUATION_GRACE_MS): its continuation still needs
613
741
  // the input for permission requests.
614
- if (subagentStallTimer &&
615
- activeSubagentTaskIds.size === 0 &&
616
- !inputStream.hasUnansweredInput(completedResponses)) {
742
+ if (subagentStallTimer && activeSubagentTaskIds.size === 0 && !inputStream.hasUnansweredInput()) {
617
743
  clearSubagentStallWatchdog();
618
744
  armContinuationGrace(BACKGROUND_CONTINUATION_GRACE_MS);
619
745
  }
@@ -622,9 +748,12 @@ export function createClaudeCodeEngine() {
622
748
  discoveredSessionId = ev.engineSessionId;
623
749
  safeEmit(ev);
624
750
  }
625
- // Any SDK message is proof of life — re-arm on EVERY message, not
626
- // only on a text-without-tool-call one.
627
- turnLiveness.activity();
751
+ // Transport metadata is not forward progress. In particular a
752
+ // stream of trailing notifications must not defeat shutdown.
753
+ if (foregroundProgress ||
754
+ msg.type === 'result' ||
755
+ events.some((ev) => ev.kind === 'subagent:progress' && !ev.ambient))
756
+ turnLiveness.activity();
628
757
  // A background subagent or a pending permission card can stay
629
758
  // legitimately quiet for minutes; their own dedicated watchdogs
630
759
  // own those windows, so suspend the turn deadline meanwhile.
@@ -634,11 +763,14 @@ export function createClaudeCodeEngine() {
634
763
  reevaluateLivenessPause();
635
764
  if (msg.type === 'result') {
636
765
  waitingForBackground = false;
766
+ settlementPending = true;
637
767
  clearContinuationGrace();
638
768
  pendingToolCallIds.clear();
769
+ turnLiveness.setTimeoutMs(CLAUDE_STREAM_IDLE_TIMEOUT_MS);
639
770
  completedResponses++;
771
+ inputStream.acknowledgeResult(msg);
640
772
  // A queued forced message starts the next response on this same SDK stream.
641
- if (!inputStream.hasUnansweredInput(completedResponses)) {
773
+ if (!inputStream.hasUnansweredInput()) {
642
774
  if (activeSubagentTaskIds.size === 0) {
643
775
  clearSubagentStallWatchdog();
644
776
  armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
@@ -670,7 +802,12 @@ export function createClaudeCodeEngine() {
670
802
  abortController.signal.aborted ||
671
803
  /aborted by user|process aborted|abortError|ede_diagnostic/i.test(error.message ?? '');
672
804
  if (isAbort) {
673
- emitSessionEnded('killed', null);
805
+ if (settledCloseRequested && !userInterrupted && !abortController.signal.aborted) {
806
+ const reason = mapperState.sawErrorResult ? 'error' : 'completed';
807
+ emitSessionEnded(reason, reason === 'completed' ? 0 : null);
808
+ }
809
+ else
810
+ emitSessionEnded('killed', null);
674
811
  }
675
812
  else {
676
813
  safeEmit({
@@ -710,7 +847,7 @@ export function createClaudeCodeEngine() {
710
847
  })();
711
848
  const engineProcess = {
712
849
  ready,
713
- closed: iteratorPromise.then(() => { }),
850
+ closed: iteratorPromise.then(() => runtimeClosed),
714
851
  get pid() {
715
852
  return undefined;
716
853
  },
@@ -718,10 +855,10 @@ export function createClaudeCodeEngine() {
718
855
  return discoveredSessionId;
719
856
  },
720
857
  isAlive() {
721
- return iteratorRunning;
858
+ return iteratorRunning || runtimeRunning;
722
859
  },
723
860
  sendMessage(text) {
724
- if (!iteratorRunning)
861
+ if (!iteratorRunning || queryCloseRequested || settledCloseRequested || sessionEndedEmitted)
725
862
  throw new Error(`Claude ${AGENT_NO_LONGER_RUNNING_TEXT}`);
726
863
  inputStream.send(text);
727
864
  armSubagentStallWatchdog();
@@ -735,7 +872,7 @@ export function createClaudeCodeEngine() {
735
872
  pendingToolCallIds.size > 0 ||
736
873
  pendingResolvers.size > 0 ||
737
874
  isCompacting ||
738
- inputStream.hasUnansweredInput(completedResponses))
875
+ inputStream.hasUnansweredInput())
739
876
  return false;
740
877
  inputStream.send(text);
741
878
  waitingForBackground = false;
@@ -800,7 +937,7 @@ export function createClaudeCodeEngine() {
800
937
  }
801
938
  },
802
939
  async stop() {
803
- abortController.abort();
940
+ terminateQuery();
804
941
  try {
805
942
  await iteratorPromise;
806
943
  }