@loicngr/kobo 2.1.10 → 2.1.11

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 +22 -3
  2. package/CHANGELOG.md +4 -0
  3. package/CONFIGURATION.md +28 -3
  4. package/dist/server/services/agent/engines/claude-code/engine.js +130 -43
  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,7 @@ 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`.
260
271
 
261
272
  **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
273
  - `jsonrpc/transport.ts` + `jsonrpc/peer.ts`: generic JSON-RPC 2.0 stdio peer (request correlation, notifications, server-initiated requests)
@@ -438,6 +449,14 @@ One task, two engines, two sibling worktrees. `POST /api/workspaces` accepts a `
438
449
 
439
450
  ### Workspace templates and duplication
440
451
 
452
+ Workspace template presets include optional `tags`. New form captures always
453
+ store the selection (including `[]`); legacy omission preserves the current
454
+ form selection. Server reads/writes filter tags against the current global
455
+ catalogue. Catalogue updates and config imports prune saved template references
456
+ before saving settings, so removed tags cannot reappear when re-added later.
457
+ Only affected presets are rewritten; existing workspace tags are untouched.
458
+ A corrupt template file aborts catalogue cleanup without replacing that file.
459
+
441
460
  `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
461
 
443
462
  ### Onboarding tours
package/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ 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.11
8
+
9
+ - fix: harden agent lifecycle and workspace workflows
10
+
7
11
  ## 2.1.10
8
12
 
9
13
  - 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,23 @@ 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 15 seconds for
1277
+ SDK cleanup. Metadata alone cannot extend this cleanup deadline.
1278
+
1279
+ Silence limits are 2 minutes without a pending tool, 10 minutes for background
1280
+ agents, and 30 minutes for tools (including background Bash, workflow and MCP
1281
+ jobs). A compaction has a separate absolute 10-minute limit. Waiting for your
1282
+ answer or permission pauses the inactivity watchdog.
1283
+
1284
+ Watchdog notices remain in conversation history, but do not display a red error
1285
+ banner. Other failures retain their banners. Watchdog shutdown requests both
1286
+ cancellation and closure of the SDK transport. Kōbō still requires confirmed
1287
+ closure before another agent can write in the same workspace; a runtime that
1288
+ refuses to stop is not treated as safely terminated.
1289
+
1265
1290
  ### OpenAI Codex
1266
1291
 
1267
1292
  > Experimental. The Claude Code engine remains the primary, battle-tested path.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { query, } from '@anthropic-ai/claude-agent-sdk';
2
3
  import { nanoid } from 'nanoid';
3
4
  import { isSubagentTask } from '../../../../../shared/subagent-classification.js';
@@ -35,8 +36,7 @@ export const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 120_000;
35
36
  * stream is still reaped once this longer ceiling elapses.
36
37
  */
37
38
  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. */
39
+ /** Absolute ceiling on a context compaction, independent of tool idle time. */
40
40
  export const COMPACTION_STALL_TIMEOUT_MS = 10 * 60_000;
41
41
  // Safety net for a subagent whose terminal `task_notification` carries a
42
42
  // status outside the mapper's known-terminal set (SDK schema drift) — it
@@ -73,7 +73,7 @@ class ClaudeInputStream {
73
73
  waiting;
74
74
  closed = false;
75
75
  queuedForcedMessages = 0;
76
- yieldedMessages = 0;
76
+ unansweredMessages = new Set();
77
77
  constructor(initialPrompt) {
78
78
  this.messages = [{ message: this.toUserMessage(initialPrompt), forced: false }];
79
79
  }
@@ -95,16 +95,43 @@ class ClaudeInputStream {
95
95
  this.waiting = undefined;
96
96
  wake?.();
97
97
  }
98
- hasUnansweredInput(completedResponses) {
99
- return this.queuedForcedMessages > 0 || this.yieldedMessages > completedResponses;
98
+ hasUnansweredInput() {
99
+ return this.queuedForcedMessages > 0 || this.unansweredMessages.size > 0;
100
+ }
101
+ acknowledgeResult(result) {
102
+ // One Claude turn can consume several queued prompts. A result counts
103
+ // turns, not messages: only retire the sends explicitly covered by it.
104
+ const ids = [...(result.user_message_uuids ?? []), ...(result.user_message_uuid ? [result.user_message_uuid] : [])];
105
+ if (result.user_message_uuids !== undefined || result.user_message_uuid !== undefined) {
106
+ for (const id of ids)
107
+ this.unansweredMessages.delete(id);
108
+ }
109
+ else {
110
+ // A legacy result can acknowledge an already delivered input, never a
111
+ // future one (e.g. an interrupted turn's result on resume).
112
+ const oldest = this.unansweredMessages.values().next().value;
113
+ if (oldest)
114
+ this.unansweredMessages.delete(oldest);
115
+ }
116
+ const wake = this.waiting;
117
+ this.waiting = undefined;
118
+ wake?.();
100
119
  }
101
120
  async *[Symbol.asyncIterator]() {
102
121
  while (!this.closed || this.messages.length > 0) {
122
+ // The SDK echoes at most 64 consumed UUIDs per result. Backpressure
123
+ // prevents losing correlation without guessing which inputs were read.
124
+ if (!this.closed && this.unansweredMessages.size >= 64) {
125
+ await new Promise((resolve) => {
126
+ this.waiting = resolve;
127
+ });
128
+ continue;
129
+ }
103
130
  const next = this.messages.shift();
104
131
  if (next) {
105
132
  if (next.forced)
106
133
  this.queuedForcedMessages--;
107
- this.yieldedMessages++;
134
+ this.unansweredMessages.add(next.message.uuid);
108
135
  yield next.message;
109
136
  continue;
110
137
  }
@@ -114,7 +141,7 @@ class ClaudeInputStream {
114
141
  }
115
142
  }
116
143
  toUserMessage(text) {
117
- return { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null };
144
+ return { type: 'user', uuid: randomUUID(), message: { role: 'user', content: text }, parent_tool_use_id: null };
118
145
  }
119
146
  }
120
147
  export function createClaudeCodeEngine() {
@@ -210,6 +237,9 @@ export function createClaudeCodeEngine() {
210
237
  const onAbort = () => {
211
238
  if (pendingResolvers.get(toolCallId) === resolver) {
212
239
  pendingResolvers.delete(toolCallId);
240
+ reevaluateLivenessPause();
241
+ if (settlementPending)
242
+ armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
213
243
  const abortError = new Error('Pending user input aborted');
214
244
  abortError.name = 'AbortError';
215
245
  reject(abortError);
@@ -304,6 +334,27 @@ export function createClaudeCodeEngine() {
304
334
  sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
305
335
  const inputStream = new ClaudeInputStream(effectivePrompt);
306
336
  const q = query({ prompt: inputStream, options: sdkOptions });
337
+ let queryCloseRequested = false;
338
+ const terminateQuery = () => {
339
+ if (queryCloseRequested)
340
+ return;
341
+ queryCloseRequested = true;
342
+ inputStream.close();
343
+ abortController.abort();
344
+ turnLiveness.stop();
345
+ clearContinuationGrace();
346
+ clearResultDrainWatchdog();
347
+ clearSubagentStallWatchdog();
348
+ clearCompactionStallTimer();
349
+ // Abort alone can leave the SDK iterator parked. close() explicitly
350
+ // tears down its transport/process. Ownership still waits for closed.
351
+ try {
352
+ q.close?.();
353
+ }
354
+ catch (error) {
355
+ console.warn('[claude-engine] SDK transport close failed:', error);
356
+ }
357
+ };
307
358
  // Best-effort SDK stop of one background task; failures are ignored.
308
359
  const requestStopTask = (taskId) => {
309
360
  try {
@@ -356,7 +407,9 @@ export function createClaudeCodeEngine() {
356
407
  completedResponses === turnCompletedEmittedForResponse ||
357
408
  activeSubagentTaskIds.size > 0 ||
358
409
  pendingResolvers.size > 0 ||
359
- inputStream.hasUnansweredInput(completedResponses)) {
410
+ isCompacting ||
411
+ pendingToolCallIds.size > 0 ||
412
+ inputStream.hasUnansweredInput()) {
360
413
  return;
361
414
  }
362
415
  turnCompletedEmittedForResponse = completedResponses;
@@ -366,6 +419,7 @@ export function createClaudeCodeEngine() {
366
419
  // catch block) never both emit `session:ended` for the same run.
367
420
  let sessionEndedEmitted = false;
368
421
  let isCompacting = false;
422
+ let settlementPending = false;
369
423
  const emitSessionEnded = (reason, exitCode) => {
370
424
  if (sessionEndedEmitted)
371
425
  return;
@@ -384,7 +438,7 @@ export function createClaudeCodeEngine() {
384
438
  // its task notification and an automatic continuation turn.
385
439
  let resultDrainTimer;
386
440
  // D2 — one shared liveness module for both engines. Armed before the
387
- // first SDK message, re-armed on EVERY message, suspended while a human
441
+ // first SDK message, re-armed on meaningful progress, suspended while a human
388
442
  // decision or a background subagent is outstanding, stopped in `finally`.
389
443
  const turnLiveness = createTurnLiveness({
390
444
  timeoutMs: CLAUDE_STREAM_IDLE_TIMEOUT_MS,
@@ -408,7 +462,7 @@ export function createClaudeCodeEngine() {
408
462
  });
409
463
  emitSessionEnded('watchdog', null);
410
464
  }
411
- abortController.abort();
465
+ terminateQuery();
412
466
  },
413
467
  });
414
468
  // The single source of truth for "should the idle deadline be paused
@@ -430,9 +484,8 @@ export function createClaudeCodeEngine() {
430
484
  // Backstop for a compaction that never reports completion (stuck
431
485
  // `session:compacting` with `active: true` forever): `isCompacting`
432
486
  // 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`.
487
+ // never trips `isAlive()`'s own sweep either. End it at the absolute
488
+ // ceiling instead of adding another tool-aware idle window afterward.
436
489
  let compactionStallTimer;
437
490
  const clearCompactionStallTimer = () => {
438
491
  if (!compactionStallTimer)
@@ -441,15 +494,23 @@ export function createClaudeCodeEngine() {
441
494
  compactionStallTimer = undefined;
442
495
  };
443
496
  const armCompactionStallTimer = () => {
444
- clearCompactionStallTimer();
497
+ if (compactionStallTimer)
498
+ return;
445
499
  compactionStallTimer = setTimeout(() => {
446
500
  compactionStallTimer = undefined;
447
501
  if (!isCompacting)
448
502
  return;
449
- console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — resuming the liveness deadline.`);
503
+ console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — closing the session.`);
450
504
  isCompacting = false;
451
505
  safeEmit({ kind: 'session:compacting', active: false });
452
- reevaluateLivenessPause();
506
+ safeEmit({
507
+ kind: 'error',
508
+ category: 'other',
509
+ code: 'compaction_stall_timeout',
510
+ message: 'Session force-ended: context compaction stopped reporting completion (watchdog).',
511
+ });
512
+ emitSessionEnded('watchdog', null);
513
+ terminateQuery();
453
514
  }, COMPACTION_STALL_TIMEOUT_MS);
454
515
  compactionStallTimer.unref?.();
455
516
  };
@@ -471,7 +532,7 @@ export function createClaudeCodeEngine() {
471
532
  });
472
533
  emitSessionEnded('watchdog', null);
473
534
  }
474
- abortController.abort();
535
+ terminateQuery();
475
536
  }, RESULT_DRAIN_TIMEOUT_MS);
476
537
  resultDrainTimer.unref?.();
477
538
  };
@@ -498,7 +559,8 @@ export function createClaudeCodeEngine() {
498
559
  if (activeSubagentTaskIds.size > 0 ||
499
560
  pendingResolvers.size > 0 ||
500
561
  pendingToolCallIds.size > 0 ||
501
- inputStream.hasUnansweredInput(completedResponses))
562
+ isCompacting ||
563
+ inputStream.hasUnansweredInput())
502
564
  return;
503
565
  // Settled only now: a result the CLI follows up on (e.g. a resumed
504
566
  // turn's empty result) must not hide the busy banner meanwhile.
@@ -525,19 +587,26 @@ export function createClaudeCodeEngine() {
525
587
  clearSubagentStallWatchdog();
526
588
  if (pendingResolvers.size > 0 || activeSubagentTaskIds.size === 0)
527
589
  return;
590
+ const hasAgent = [...activeSubagentTaskIds].some((id) => isSubagentTask({ taskType: mapperState.taskTypes.get(id) }));
591
+ // Shell/workflow/MCP jobs do not emit agent progress during a long
592
+ // command. Give them the same silence allowance as foreground tools.
593
+ const timeoutMs = hasAgent ? SUBAGENT_STALL_TIMEOUT_MS : CLAUDE_TOOL_IDLE_TIMEOUT_MS;
528
594
  subagentStallTimer = setTimeout(() => {
529
595
  subagentStallTimer = undefined;
530
596
  if (pendingResolvers.size > 0 || activeSubagentTaskIds.size === 0)
531
597
  return;
532
- console.warn(`[claude-engine] No subagent activity for ${SUBAGENT_STALL_TIMEOUT_MS}ms — forcing session:ended`);
598
+ console.warn(`[claude-engine] No background activity for ${timeoutMs}ms — forcing session:ended`);
533
599
  safeEmit({
534
600
  kind: 'error',
535
601
  category: 'other',
536
- message: 'Session force-ended: background subagents stopped reporting activity (watchdog).',
602
+ message: hasAgent
603
+ ? 'Session force-ended: background subagents stopped reporting activity (watchdog).'
604
+ : 'Session force-ended: background tools stopped reporting activity (watchdog).',
605
+ code: hasAgent ? 'subagent_stall_timeout' : 'background_task_stall_timeout',
537
606
  });
538
607
  emitSessionEnded('watchdog', null);
539
- abortController.abort();
540
- }, SUBAGENT_STALL_TIMEOUT_MS);
608
+ terminateQuery();
609
+ }, timeoutMs);
541
610
  subagentStallTimer.unref?.();
542
611
  };
543
612
  const iteratorPromise = (async () => {
@@ -545,22 +614,27 @@ export function createClaudeCodeEngine() {
545
614
  turnLiveness.start();
546
615
  try {
547
616
  for await (const msg of q) {
617
+ const events = mapSdkMessage(msg, mapperState);
618
+ const isForeground = !('parent_tool_use_id' in msg && msg.parent_tool_use_id != null);
619
+ const foregroundProgress = isForeground &&
620
+ events.some((ev) => ev.kind === 'message:text' ||
621
+ ev.kind === 'message:thinking' ||
622
+ ev.kind === 'tool:call' ||
623
+ ev.kind === 'tool:result');
548
624
  // A parent continuation owns the foreground again. Background
549
625
  // progress notifications alone do not end the between-turn wait.
550
- if (msg.type === 'assistant' || msg.type === 'user' || msg.type === 'stream_event') {
626
+ if (foregroundProgress) {
551
627
  waitingForBackground = false;
628
+ settlementPending = false;
552
629
  clearContinuationGrace();
630
+ clearResultDrainWatchdog();
553
631
  }
554
632
  else if (msg.type === 'system' && msg.subtype === 'init') {
555
633
  // A new run starts on this stream (e.g. after a resumed turn's empty result).
556
634
  clearContinuationGrace();
635
+ settlementPending = false;
636
+ clearResultDrainWatchdog();
557
637
  }
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
638
  for (const ev of events) {
565
639
  if (ev.kind === 'subagent:progress')
566
640
  trackSubagentProgress(ev);
@@ -578,17 +652,26 @@ export function createClaudeCodeEngine() {
578
652
  armSubagentStallWatchdog();
579
653
  for (const ev of events) {
580
654
  if (ev.kind === 'session:compacting') {
655
+ const wasCompacting = isCompacting;
581
656
  isCompacting = ev.active;
582
- if (ev.active)
657
+ if (ev.active) {
658
+ clearContinuationGrace();
659
+ clearResultDrainWatchdog();
583
660
  armCompactionStallTimer();
584
- else
661
+ }
662
+ else {
585
663
  clearCompactionStallTimer();
664
+ if (wasCompacting && settlementPending)
665
+ armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
666
+ }
586
667
  }
587
668
  // Older SDKs can emit only compact_boundary, without a trailing
588
669
  // status update. This boundary marks compaction as complete.
589
670
  else if (ev.kind === 'session:compacted') {
590
671
  isCompacting = false;
591
672
  clearCompactionStallTimer();
673
+ if (settlementPending)
674
+ armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
592
675
  }
593
676
  }
594
677
  // Actual foreground output proves work resumed even if the SDK
@@ -602,18 +685,16 @@ export function createClaudeCodeEngine() {
602
685
  safeEmit({ kind: 'session:compacting', active: false });
603
686
  }
604
687
  for (const ev of events) {
605
- if (ev.kind === 'tool:call')
688
+ if (isForeground && ev.kind === 'tool:call')
606
689
  pendingToolCallIds.add(ev.toolCallId);
607
- else if (ev.kind === 'tool:result')
690
+ else if (isForeground && ev.kind === 'tool:result')
608
691
  pendingToolCallIds.delete(ev.toolCallId);
609
692
  }
610
693
  // After a settled result, the last background completion drains
611
694
  // the stream only if the parent does not continue (see
612
695
  // BACKGROUND_CONTINUATION_GRACE_MS): its continuation still needs
613
696
  // the input for permission requests.
614
- if (subagentStallTimer &&
615
- activeSubagentTaskIds.size === 0 &&
616
- !inputStream.hasUnansweredInput(completedResponses)) {
697
+ if (subagentStallTimer && activeSubagentTaskIds.size === 0 && !inputStream.hasUnansweredInput()) {
617
698
  clearSubagentStallWatchdog();
618
699
  armContinuationGrace(BACKGROUND_CONTINUATION_GRACE_MS);
619
700
  }
@@ -622,9 +703,12 @@ export function createClaudeCodeEngine() {
622
703
  discoveredSessionId = ev.engineSessionId;
623
704
  safeEmit(ev);
624
705
  }
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();
706
+ // Transport metadata is not forward progress. In particular a
707
+ // stream of trailing notifications must not defeat shutdown.
708
+ if (foregroundProgress ||
709
+ msg.type === 'result' ||
710
+ events.some((ev) => ev.kind === 'subagent:progress' && !ev.ambient))
711
+ turnLiveness.activity();
628
712
  // A background subagent or a pending permission card can stay
629
713
  // legitimately quiet for minutes; their own dedicated watchdogs
630
714
  // own those windows, so suspend the turn deadline meanwhile.
@@ -634,11 +718,14 @@ export function createClaudeCodeEngine() {
634
718
  reevaluateLivenessPause();
635
719
  if (msg.type === 'result') {
636
720
  waitingForBackground = false;
721
+ settlementPending = true;
637
722
  clearContinuationGrace();
638
723
  pendingToolCallIds.clear();
724
+ turnLiveness.setTimeoutMs(CLAUDE_STREAM_IDLE_TIMEOUT_MS);
639
725
  completedResponses++;
726
+ inputStream.acknowledgeResult(msg);
640
727
  // A queued forced message starts the next response on this same SDK stream.
641
- if (!inputStream.hasUnansweredInput(completedResponses)) {
728
+ if (!inputStream.hasUnansweredInput()) {
642
729
  if (activeSubagentTaskIds.size === 0) {
643
730
  clearSubagentStallWatchdog();
644
731
  armContinuationGrace(RESULT_CONTINUATION_GRACE_MS);
@@ -721,7 +808,7 @@ export function createClaudeCodeEngine() {
721
808
  return iteratorRunning;
722
809
  },
723
810
  sendMessage(text) {
724
- if (!iteratorRunning)
811
+ if (!iteratorRunning || queryCloseRequested || sessionEndedEmitted)
725
812
  throw new Error(`Claude ${AGENT_NO_LONGER_RUNNING_TEXT}`);
726
813
  inputStream.send(text);
727
814
  armSubagentStallWatchdog();
@@ -735,7 +822,7 @@ export function createClaudeCodeEngine() {
735
822
  pendingToolCallIds.size > 0 ||
736
823
  pendingResolvers.size > 0 ||
737
824
  isCompacting ||
738
- inputStream.hasUnansweredInput(completedResponses))
825
+ inputStream.hasUnansweredInput())
739
826
  return false;
740
827
  inputStream.send(text);
741
828
  waitingForBackground = false;
@@ -800,7 +887,7 @@ export function createClaudeCodeEngine() {
800
887
  }
801
888
  },
802
889
  async stop() {
803
- abortController.abort();
890
+ terminateQuery();
804
891
  try {
805
892
  await iteratorPromise;
806
893
  }
@@ -1,4 +1,4 @@
1
- import { callMcpTool, initializeMcp, readClaudeMcpEntry, spawnMcpProcess, unwrapMcpResult, } from '../utils/mcp-client.js';
1
+ import { callMcpTool, initializeMcp, listMcpToolNames, readClaudeMcpEntry, spawnMcpProcess, unwrapMcpResult, } from '../utils/mcp-client.js';
2
2
  import { stopMcpProcess } from '../utils/mcp-process.js';
3
3
  import { getIntegrationConfig } from './integration-config-service.js';
4
4
  import { getGlobalSettings } from './settings-service.js';
@@ -23,7 +23,9 @@ export async function testSentryConnection() {
23
23
  const mcpProcess = spawnMcpProcess(config.command, config.args, config.env);
24
24
  try {
25
25
  await initializeMcp(mcpProcess);
26
- await callMcpTool(mcpProcess, 'whoami', {});
26
+ // whoami is not exposed by all Sentry MCP servers. Organization discovery
27
+ // verifies authenticated read access without requiring an issue or mutating it.
28
+ await callMcpTool(mcpProcess, 'find_organizations', {});
27
29
  return {
28
30
  ok: true,
29
31
  durationMs: Date.now() - startedAt,
@@ -205,7 +207,15 @@ export async function assignSentryIssueToSelf(issueUrl) {
205
207
  });
206
208
  });
207
209
  await initializeMcp(mcpProcess);
208
- const whoamiRaw = await callMcpTool(mcpProcess, 'whoami', {});
210
+ const tools = await listMcpToolNames(mcpProcess);
211
+ if (!tools.includes('whoami') && !tools.includes('execute_sentry_tool')) {
212
+ return { assigned: false, reason: 'Sentry MCP does not expose a current-user lookup tool' };
213
+ }
214
+ // Current Sentry servers expose identity through the tool catalogue;
215
+ // older servers still provide whoami directly. Never retry the write.
216
+ const whoamiRaw = tools.includes('whoami')
217
+ ? await callMcpTool(mcpProcess, 'whoami', {})
218
+ : await callMcpTool(mcpProcess, 'execute_sentry_tool', { name: 'whoami', arguments: {} });
209
219
  const whoami = unwrapMcpResult(whoamiRaw);
210
220
  const userId = extractSentryUserId(whoami);
211
221
  if (!userId) {
@@ -15,6 +15,7 @@ import { LEGACY_AGNOSTIC_AUTO_LOOP_REVIEW_GATE, LEGACY_AGNOSTIC_REVIEW_TEMPLATE,
15
15
  import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from './review-template-service.js';
16
16
  import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT, DEFAULT_MODEL_BY_ENGINE, FRESH_MODEL_BY_ENGINE, } from './settings-defaults.js';
17
17
  import { AGNOSTIC_PROMPTS } from './skill-suite-prompts.js';
18
+ import { pruneWorkspaceTemplateTags } from './workspace-template-service.js';
18
19
  export const STANDARD_GIT_CONVENTIONS = `Follow the repository's existing Git conventions and the user's instructions.
19
20
  Work locally by default. Commit, push, publish comments or open pull requests only when explicitly authorized.
20
21
  Inspect git status and all staged, unstaged and untracked changes before staging. Preserve unrelated work.
@@ -1408,6 +1409,7 @@ export function importConfigBundle(bundle) {
1408
1409
  for (const key of SECRET_GLOBAL_KEYS) {
1409
1410
  migrated.global[key] = current.global[key];
1410
1411
  }
1412
+ pruneWorkspaceTemplateTags(migrated.global.tags);
1411
1413
  writeSettings(migrated, { backup: true });
1412
1414
  }
1413
1415
  /** Return only the global settings section. */
@@ -1727,6 +1729,8 @@ export function updateGlobalSettings(input) {
1727
1729
  filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
1728
1730
  ensureGlobalWorktreesRootExists(filtered.worktreesPath);
1729
1731
  }
1732
+ if (filtered.tags !== undefined)
1733
+ pruneWorkspaceTemplateTags(filtered.tags);
1730
1734
  settings.global = { ...settings.global, ...filtered };
1731
1735
  writeSettings(settings, { backup: true });
1732
1736
  return settings.global;
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
3
3
  import { getDb } from '../db/index.js';
4
4
  import { writeJsonFileAtomically } from '../utils/atomic-json-file.js';
5
5
  import { getWorkspaceTemplatesPath } from '../utils/paths.js';
6
+ import { getGlobalSettings } from './settings-service.js';
6
7
  const FILE_VERSION = 1;
7
8
  export const MAX_WORKSPACE_TEMPLATES = 100;
8
9
  const MAX_NAME_LENGTH = 80;
@@ -44,6 +45,14 @@ export function sanitizePreset(input) {
44
45
  if (Array.isArray(raw[key]))
45
46
  preset[key] = raw[key].filter((v) => typeof v === 'string');
46
47
  }
48
+ if (Array.isArray(raw.tags)) {
49
+ preset.tags = [
50
+ ...new Set(raw.tags
51
+ .filter((tag) => typeof tag === 'string')
52
+ .map((tag) => tag.trim())
53
+ .filter((tag) => tag.length > 0 && tag.length <= 50)),
54
+ ];
55
+ }
47
56
  if (typeof raw.agentPermissionMode === 'string' &&
48
57
  PERMISSION_MODES.includes(raw.agentPermissionMode)) {
49
58
  preset.agentPermissionMode = raw.agentPermissionMode;
@@ -97,7 +106,32 @@ function writeFile(templates) {
97
106
  writeJsonFileAtomically(getWorkspaceTemplatesPath(), file);
98
107
  }
99
108
  export function listWorkspaceTemplates() {
100
- return readFile();
109
+ return readFile().map((template) => ({ ...template, preset: sanitizeTemplatePreset(template.preset) }));
110
+ }
111
+ /** Guard writes from stale clients as well as reads of hand-edited/older files. */
112
+ function sanitizeTemplatePreset(input) {
113
+ const preset = sanitizePreset(input);
114
+ if (preset.tags !== undefined) {
115
+ const allowed = new Set(getGlobalSettings().tags);
116
+ preset.tags = preset.tags.filter((tag) => allowed.has(tag));
117
+ }
118
+ return preset;
119
+ }
120
+ /** Called before persisting a changed catalogue; a corrupt file aborts deletion. */
121
+ export function pruneWorkspaceTemplateTags(allowedTags) {
122
+ const allowed = new Set(allowedTags);
123
+ let changed = false;
124
+ const templates = readFile().map((template) => {
125
+ if (template.preset.tags === undefined)
126
+ return template;
127
+ const tags = template.preset.tags.filter((tag) => allowed.has(tag));
128
+ if (tags.length === template.preset.tags.length)
129
+ return template;
130
+ changed = true;
131
+ return { ...template, preset: { ...template.preset, tags }, updatedAt: new Date().toISOString() };
132
+ });
133
+ if (changed)
134
+ writeFile(templates);
101
135
  }
102
136
  /** Throws `Invalid template name`, `already exists` or `Too many templates`. */
103
137
  export function createWorkspaceTemplate(input) {
@@ -116,7 +150,7 @@ export function createWorkspaceTemplate(input) {
116
150
  name,
117
151
  createdAt: now,
118
152
  updatedAt: now,
119
- preset: sanitizePreset(input.preset),
153
+ preset: sanitizeTemplatePreset(input.preset),
120
154
  };
121
155
  writeFile([...templates, template]);
122
156
  return template;
@@ -139,7 +173,7 @@ export function updateWorkspaceTemplate(id, updates) {
139
173
  const updated = {
140
174
  ...current,
141
175
  name,
142
- preset: updates.preset !== undefined ? sanitizePreset(updates.preset) : current.preset,
176
+ preset: sanitizeTemplatePreset(updates.preset !== undefined ? updates.preset : current.preset),
143
177
  updatedAt: new Date().toISOString(),
144
178
  };
145
179
  templates[index] = updated;