@cloudcli-ai/cloudcli 1.37.1 → 1.37.2

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 (153) hide show
  1. package/dist/assets/abnfDiagram-N423BO3Z-C4UAoHJb.js +1 -0
  2. package/dist/assets/arc-B8jF-ja9.js +1 -0
  3. package/dist/assets/architectureDiagram-T3A2C74G-C_Wur_CU.js +36 -0
  4. package/dist/assets/blockDiagram-VBNYF7ZC-Cq4P1i8t.js +132 -0
  5. package/dist/assets/c4Diagram-5PPSVZJV-C1wG4eUj.js +10 -0
  6. package/dist/assets/channel-_falYbU1.js +1 -0
  7. package/dist/assets/chunk-2GRJ4B5K-B8fnBG6E.js +1 -0
  8. package/dist/assets/chunk-2Q5K7J3B-WdfPooPJ.js +1 -0
  9. package/dist/assets/chunk-5RXB4S5H-DhZJuG2E.js +231 -0
  10. package/dist/assets/chunk-5VM5RSS4-B0rjslal.js +15 -0
  11. package/dist/assets/chunk-6Q2QTUOP-CG2Oj4jL.js +88 -0
  12. package/dist/assets/chunk-GF5L2VYU-rrl2lRxY.js +206 -0
  13. package/dist/assets/chunk-JWPE2WC7-CjVbrVMA.js +1 -0
  14. package/dist/assets/chunk-KBJHAD2P-CRzKgU8S.js +1 -0
  15. package/dist/assets/chunk-RYQCIY6F-DBz4GTZF.js +1 -0
  16. package/dist/assets/chunk-XXDRQBXY-wHxalsXk.js +1 -0
  17. package/dist/assets/classDiagram-JCYQIIEL-Dn-3CfpR.js +1 -0
  18. package/dist/assets/classDiagram-v2-OCEON4UE-Dn-3CfpR.js +1 -0
  19. package/dist/assets/cose-bilkent-JH36ORCC-s1hygY8e.js +1 -0
  20. package/dist/assets/cynefin-VYW2F7L2-C4LGV15i.js +178 -0
  21. package/dist/assets/cynefinDiagram-MW4NZA55-C5uhu7B3.js +62 -0
  22. package/dist/assets/cytoscape.esm-C10L9Icf.js +331 -0
  23. package/dist/assets/dagre-VZM6K2ZE-B-OBTewm.js +4 -0
  24. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  25. package/dist/assets/diagram-7IWD3JNH-BjGKQxih.js +30 -0
  26. package/dist/assets/diagram-B4RE2ZJO-BMKz1X2L.js +3 -0
  27. package/dist/assets/diagram-LBJQPF4R-DNh-GhKC.js +24 -0
  28. package/dist/assets/diagram-Q27KOJAE-BaTG9jPT.js +24 -0
  29. package/dist/assets/diagram-UB23O5K3-BUwo2OvJ.js +41 -0
  30. package/dist/assets/ebnfDiagram-BXEA7PRR-DAZgfzX2.js +1 -0
  31. package/dist/assets/erDiagram-JOGREHBK-1w1CcGEb.js +85 -0
  32. package/dist/assets/flowDiagram-UKHOOZJN-D4EII8Jj.js +156 -0
  33. package/dist/assets/ganttDiagram-PKOTCBZU-ChvJZAgB.js +292 -0
  34. package/dist/assets/gitGraphDiagram-DS77QQ5N-DtOn41S8.js +106 -0
  35. package/dist/assets/graph-Cx-EqisW.js +1 -0
  36. package/dist/assets/index-CzPLBvXT.css +32 -0
  37. package/dist/assets/{index-DTMWjJj8.js → index-DFkBBuSA.js} +303 -304
  38. package/dist/assets/infoDiagram-6WML65LV-DN_PVEJZ.js +2 -0
  39. package/dist/assets/init-Gi6I4Gst.js +1 -0
  40. package/dist/assets/ishikawaDiagram-WSZJBQD7-DbkF1kXb.js +70 -0
  41. package/dist/assets/journeyDiagram-NVQOT4AX-pJwmhddE.js +139 -0
  42. package/dist/assets/kanban-definition-27J2QSJJ-PPAPgNp5.js +89 -0
  43. package/dist/assets/layout-D6BstSzF.js +1 -0
  44. package/dist/assets/linear-iPR-J9f7.js +1 -0
  45. package/dist/assets/map-CV9uif0g.js +1 -0
  46. package/dist/assets/mermaid.core-BEUL86Z6.js +312 -0
  47. package/dist/assets/mindmap-definition-FAOFIHXS-BvZoBib7.js +96 -0
  48. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  49. package/dist/assets/pegDiagram-VL7TDLO6-DhYreaUd.js +1 -0
  50. package/dist/assets/pieDiagram-7S7Q4E2Y-CrOHlyD0.js +39 -0
  51. package/dist/assets/quadrantDiagram-CIZ2JOQS-D4g5yH3t.js +7 -0
  52. package/dist/assets/railroadDiagram-AXF67PYL-Dj39lHlf.js +1 -0
  53. package/dist/assets/requirementDiagram-LRYGKXZP-1AS4Zgm_.js +84 -0
  54. package/dist/assets/sankeyDiagram-W5VNT64P-CKlMn19P.js +40 -0
  55. package/dist/assets/sequenceDiagram-SI44F4Z6-B_j3qyvo.js +162 -0
  56. package/dist/assets/sizeCapture-X5ZJPWSS-Bp2Op16v.js +1 -0
  57. package/dist/assets/stateDiagram-OKZ733FA-C0cN6DoY.js +1 -0
  58. package/dist/assets/stateDiagram-v2-UEYNNEHI-BvBxUUrZ.js +1 -0
  59. package/dist/assets/swimlanes-SLNWSIFB-BEvXOV9b.js +2 -0
  60. package/dist/assets/swimlanesDiagram-ULZ7WXOC-CsE-x4YK.js +8 -0
  61. package/dist/assets/timeline-definition-Z64GVDOM-zdJQinvZ.js +120 -0
  62. package/dist/assets/vennDiagram-T6HMQDX7-DuF1uZMY.js +34 -0
  63. package/dist/assets/wardleyDiagram-T6FBY63Y-B9AlLQup.js +78 -0
  64. package/dist/assets/xychartDiagram-ELKLHX3M-DTJX48Qg.js +7 -0
  65. package/dist/index.html +2 -2
  66. package/dist-server/server/index.js +1 -2
  67. package/dist-server/server/index.js.map +1 -1
  68. package/dist-server/server/modules/agent/agent.module.js +2 -1
  69. package/dist-server/server/modules/agent/agent.module.js.map +1 -1
  70. package/dist-server/server/modules/auth/auth.middleware.js +1 -1
  71. package/dist-server/server/modules/auth/auth.middleware.js.map +1 -1
  72. package/dist-server/server/modules/browser-use/browser-use-runtime.js +10 -0
  73. package/dist-server/server/modules/browser-use/browser-use-runtime.js.map +1 -0
  74. package/dist-server/server/modules/browser-use/browser-use.service.js +3 -6
  75. package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -1
  76. package/dist-server/server/modules/browser-use/index.js +2 -0
  77. package/dist-server/server/modules/browser-use/index.js.map +1 -1
  78. package/dist-server/server/modules/cli/cli.js +9 -3
  79. package/dist-server/server/modules/cli/cli.js.map +1 -1
  80. package/dist-server/server/modules/cli/cli.module.js +0 -1
  81. package/dist-server/server/modules/cli/cli.module.js.map +1 -1
  82. package/dist-server/server/modules/cli/cli.service.js +0 -18
  83. package/dist-server/server/modules/cli/cli.service.js.map +1 -1
  84. package/dist-server/server/modules/cli/tests/cli-environment-bootstrap.test.js +154 -0
  85. package/dist-server/server/modules/cli/tests/cli-environment-bootstrap.test.js.map +1 -0
  86. package/dist-server/server/modules/cli/tests/cli.service.test.js +0 -3
  87. package/dist-server/server/modules/cli/tests/cli.service.test.js.map +1 -1
  88. package/dist-server/server/modules/file-tree/file-tree.service.js +18 -10
  89. package/dist-server/server/modules/file-tree/file-tree.service.js.map +1 -1
  90. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js +62 -11
  91. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js.map +1 -1
  92. package/dist-server/server/modules/git/git-branch.service.js +15 -0
  93. package/dist-server/server/modules/git/git-branch.service.js.map +1 -0
  94. package/dist-server/server/modules/git/git.routes.js +15 -7
  95. package/dist-server/server/modules/git/git.routes.js.map +1 -1
  96. package/dist-server/server/modules/git/tests/git-branch.service.test.js +52 -0
  97. package/dist-server/server/modules/git/tests/git-branch.service.test.js.map +1 -0
  98. package/dist-server/server/modules/git/tests/git-init.routes.test.js +84 -0
  99. package/dist-server/server/modules/git/tests/git-init.routes.test.js.map +1 -1
  100. package/dist-server/server/modules/notifications/index.js +3 -1
  101. package/dist-server/server/modules/notifications/index.js.map +1 -1
  102. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +22 -1
  103. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -1
  104. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +254 -48
  105. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  106. package/dist-server/server/modules/providers/provider.routes.js +5 -0
  107. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  108. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +63 -7
  109. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  110. package/dist-server/server/modules/providers/tests/provider.routes.test.js +35 -0
  111. package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -1
  112. package/dist-server/server/shared/utils.js +8 -0
  113. package/dist-server/server/shared/utils.js.map +1 -1
  114. package/package.json +6 -2
  115. package/scripts/promote-dist-server.mjs +50 -0
  116. package/server/index.ts +1 -2
  117. package/server/modules/agent/agent.module.ts +2 -1
  118. package/server/modules/auth/auth.middleware.ts +2 -2
  119. package/server/modules/browser-use/browser-use-runtime.ts +10 -0
  120. package/server/modules/browser-use/browser-use.service.ts +5 -8
  121. package/server/modules/browser-use/index.ts +3 -0
  122. package/server/modules/cli/cli.module.ts +0 -1
  123. package/server/modules/cli/cli.service.ts +0 -20
  124. package/server/modules/cli/cli.ts +9 -3
  125. package/server/modules/cli/tests/cli-environment-bootstrap.test.ts +186 -0
  126. package/server/modules/cli/tests/cli.service.test.ts +0 -3
  127. package/server/modules/file-tree/file-tree.service.ts +21 -10
  128. package/server/modules/file-tree/tests/file-tree.service.test.ts +65 -11
  129. package/server/modules/git/git-branch.service.ts +43 -0
  130. package/server/modules/git/git.routes.ts +16 -9
  131. package/server/modules/git/tests/git-branch.service.test.ts +60 -0
  132. package/server/modules/git/tests/git-init.routes.test.ts +83 -0
  133. package/server/modules/notifications/index.ts +2 -0
  134. package/server/modules/notifications/services/notification-orchestrator.service.js +24 -1
  135. package/server/modules/providers/list/claude/claude-runtime.provider.js +275 -54
  136. package/server/modules/providers/provider.routes.ts +5 -0
  137. package/server/modules/providers/services/session-conversations-search.service.ts +94 -7
  138. package/server/modules/providers/tests/provider.routes.test.ts +61 -0
  139. package/server/shared/types.ts +0 -1
  140. package/server/shared/utils.ts +10 -0
  141. package/server/tsconfig.json +6 -2
  142. package/README.de.md +0 -257
  143. package/README.ja.md +0 -249
  144. package/README.ko.md +0 -249
  145. package/README.md +0 -269
  146. package/README.ru.md +0 -257
  147. package/README.tr.md +0 -258
  148. package/README.zh-CN.md +0 -249
  149. package/README.zh-TW.md +0 -249
  150. package/dist/assets/index-D3jfzuKN.css +0 -32
  151. package/dist-server/server/constants/config.js +0 -6
  152. package/dist-server/server/constants/config.js.map +0 -1
  153. package/server/constants/config.ts +0 -5
@@ -28,6 +28,7 @@ import { CLAUDE_PREDEFINED_MODELS } from '@/modules/providers/list/claude/claude
28
28
  import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
29
29
  import {
30
30
  createNotificationEvent,
31
+ notifyBackgroundWorkCompleted,
31
32
  notifyRunFailed,
32
33
  notifyRunStopped,
33
34
  notifyUserIfEnabled
@@ -40,9 +41,32 @@ const pendingToolApprovals = new Map();
40
41
  // terminal `complete` (aborted: true) to the client, so the run loop must not
41
42
  // emit a second one when its generator winds down.
42
43
  const abortedSessionIds = new Set();
44
+ // Query instances interrupted because a newer run took over their session id
45
+ // (see addSession). Their run loops must stay silent on wind-down: the map
46
+ // entry, the abort flag, and all client-facing events belong to the new run.
47
+ const supersededInstances = new WeakSet();
43
48
 
44
49
  const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
45
50
 
51
+ // How long background work is allowed to keep running after a turn ends. This drives
52
+ // two halves of the same behaviour:
53
+ //
54
+ // 1. Passed to the spawned CLI as CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, which is how
55
+ // long it waits for still-running background *agents* before killing them.
56
+ // 2. A backstop on how long we hold the SDK's stdin open after a turn's `result`.
57
+ // The SDK closes stdin as soon as a turn ends, and the CLI reads that EOF as
58
+ // "print wind-down" — killing background *shells* after a short grace period,
59
+ // which the ceiling above does not cover. Holding stdin open also lets the CLI
60
+ // push follow-up turns (background-task completions, Monitor notifications,
61
+ // scheduled wake-ups).
62
+ //
63
+ // The hold normally ends long before this: a turn with nothing outstanding closes
64
+ // stdin immediately, background work releases it as soon as it reports back, and a
65
+ // new turn supersedes the previous hold. This ceiling only catches background work
66
+ // that never reports at all, so an abandoned session cannot leak a CLI process
67
+ // forever. The timer resets on every message, so it measures silence, not total time.
68
+ const BG_WAIT_CEILING_MS = 30 * 60 * 1000;
69
+
46
70
  const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
47
71
 
48
72
  function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_PREDEFINED_MODELS) {
@@ -165,10 +189,7 @@ function mapCliOptionsToSDK(options = {}) {
165
189
 
166
190
  // Forward all host env vars (e.g. ANTHROPIC_BASE_URL) to the subprocess.
167
191
  // Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
168
- // This snapshot is also how CLI-side knobs reach the child: e.g. how long the CLI waits for
169
- // still-running background agents after a turn ends is set by CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS
170
- // (default 600000 ms; 0 = wait indefinitely) on this server's own environment.
171
- sdkOptions.env = { ...process.env };
192
+ sdkOptions.env = { ...process.env, CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS: String(BG_WAIT_CEILING_MS) };
172
193
 
173
194
  // Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
174
195
  // which does not reliably follow npm's shell wrappers like cross-spawn does.
@@ -243,13 +264,37 @@ function mapCliOptionsToSDK(options = {}) {
243
264
  * @param {string} sessionId - Session identifier
244
265
  * @param {Object} queryInstance - SDK query instance
245
266
  * @param {Object} writer - WebSocket writer for reconnect support
267
+ * @param {Function} releaseInput - Closes the held stdin stream so the CLI can exit
246
268
  */
247
- function addSession(sessionId, queryInstance, writer = null) {
269
+ function addSession(sessionId, queryInstance, writer = null, releaseInput = null) {
270
+ const existing = activeSessions.get(sessionId);
271
+ // A different live instance under the same key means an earlier run was
272
+ // superseded without being stopped (e.g. an abort that raced run setup and
273
+ // found nothing to interrupt). Overwriting it here would strand its
274
+ // generator forever — this map entry is the only handle for interrupting
275
+ // it. Stop it directly rather than via abortClaudeSDKSession, whose
276
+ // session-keyed abortedSessionIds flag would be consumed by the new run
277
+ // and suppress its terminal `complete`.
278
+ const superseding = Boolean(
279
+ existing && existing.status === 'active' && existing.instance && existing.instance !== queryInstance
280
+ );
281
+ if (superseding) {
282
+ supersededInstances.add(existing.instance);
283
+ Promise.resolve()
284
+ .then(() => existing.instance.interrupt())
285
+ .catch((error) => {
286
+ console.error(`Error interrupting superseded run for session ${sessionId}:`, error?.message || error);
287
+ });
288
+ existing.releaseInput?.();
289
+ }
290
+ const carried = superseding ? null : existing;
248
291
  activeSessions.set(sessionId, {
249
292
  instance: queryInstance,
250
- startTime: Date.now(),
293
+ startTime: carried?.startTime || Date.now(),
251
294
  status: 'active',
252
- writer
295
+ writer,
296
+ // Re-registered mid-run once the provider session id lands; keep the closer.
297
+ releaseInput: releaseInput || carried?.releaseInput || null
253
298
  });
254
299
  }
255
300
 
@@ -366,38 +411,93 @@ function extractTokenBudget(sdkMessage) {
366
411
  };
367
412
  }
368
413
 
414
+ // Tool calls that leave work running past the end of a turn. Bash only counts
415
+ // when it is explicitly backgrounded; the rest defer or watch work by nature.
416
+ const DEFERRED_WORK_TOOLS = new Set(['Monitor', 'ScheduleWakeup', 'CronCreate', 'TaskCreate']);
417
+
369
418
  /**
370
- * Builds the SDK `prompt` payload for one turn.
419
+ * Detects tool calls that keep working after the turn's `result` arrives.
371
420
  *
372
- * Plain text turns pass the string through unchanged. Turns with image
373
- * attachments use the SDK's streaming-input mode: a single SDKUserMessage
374
- * whose content carries the prompt text plus one base64 `image` block per
375
- * attachment (read from the global `~/.cloudcli/assets` folder).
421
+ * Only turns that start background work need their CLI process held open; every
422
+ * other turn can let it exit immediately, as it did before the hold existed.
423
+ *
424
+ * @param {Object} sdkMessage - SDK stream message
425
+ * @returns {boolean} True when the message launches work that outlives the turn
426
+ */
427
+ function startsBackgroundWork(sdkMessage) {
428
+ const content = sdkMessage?.message?.content;
429
+ if (!Array.isArray(content)) {
430
+ return false;
431
+ }
432
+
433
+ return content.some((block) => {
434
+ if (block?.type !== 'tool_use') {
435
+ return false;
436
+ }
437
+ if (block.name === 'Bash') {
438
+ return block.input?.run_in_background === true;
439
+ }
440
+ return DEFERRED_WORK_TOOLS.has(block.name);
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Builds the SDK user messages for one turn.
446
+ *
447
+ * Always returns SDKUserMessage records rather than a bare string: a string
448
+ * prompt makes the SDK flag the query as single-turn and close stdin the moment
449
+ * the turn's `result` arrives, which kills the CLI's background tasks. Plain
450
+ * text turns carry string content; turns with image attachments carry the
451
+ * prompt text plus one base64 `image` block per attachment (read from the
452
+ * global `~/.cloudcli/assets` folder).
376
453
  *
377
454
  * @param {string} command - User prompt
378
455
  * @param {Array} images - Image descriptors ({ path, name?, mimeType? })
379
456
  * @param {Array} files - Non-image attachment descriptors
380
457
  * @param {string} cwd - Project working directory attachment paths resolve against
381
- * @returns {Promise<string|AsyncIterable>} SDK prompt payload
458
+ * @returns {Promise<Array<Object>>} SDKUserMessage records for the turn
382
459
  */
383
- async function buildPromptPayload(command, images, files, cwd) {
460
+ async function buildPromptMessages(command, images, files, cwd) {
384
461
  const promptWithFiles = appendFilesInputTag(command, files);
385
- if (normalizeImageDescriptors(images).length === 0) {
386
- return promptWithFiles;
387
- }
462
+ const content = normalizeImageDescriptors(images).length === 0
463
+ ? promptWithFiles
464
+ : await buildClaudeUserContent(promptWithFiles, images, cwd);
465
+
466
+ return [{
467
+ type: 'user',
468
+ message: {
469
+ role: 'user',
470
+ content
471
+ },
472
+ parent_tool_use_id: null,
473
+ timestamp: new Date().toISOString()
474
+ }];
475
+ }
388
476
 
389
- const content = await buildClaudeUserContent(promptWithFiles, images, cwd);
390
- return (async function* () {
391
- yield {
392
- type: 'user',
393
- message: {
394
- role: 'user',
395
- content
396
- },
397
- parent_tool_use_id: null,
398
- timestamp: new Date().toISOString()
399
- };
477
+ /**
478
+ * Wraps prompt messages in an async iterable that yields them and then parks.
479
+ *
480
+ * The SDK closes the CLI's stdin as soon as its input iterable is exhausted (and
481
+ * immediately on `result` for string prompts). The CLI reads that EOF as the end
482
+ * of the run and kills anything still going in the background, so the iterable
483
+ * has to stay pending until we actually want the process gone.
484
+ *
485
+ * @param {Array<Object>} messages - SDKUserMessage records to send
486
+ * @returns {{ stream: AsyncIterable, release: () => void }} Stream plus its closer
487
+ */
488
+ function createHeldPromptStream(messages) {
489
+ let release;
490
+ const held = new Promise((resolve) => { release = resolve; });
491
+
492
+ const stream = (async function* () {
493
+ for (const message of messages) {
494
+ yield message;
495
+ }
496
+ // Keeps stdin open — the CLI stays alive until release() is called.
497
+ await held;
400
498
  })();
499
+
500
+ return { stream, release };
401
501
  }
402
502
 
403
503
  /**
@@ -486,6 +586,44 @@ async function queryClaudeSDK(command, options = {}, ws, context) {
486
586
  });
487
587
  };
488
588
 
589
+ // Closes the held stdin stream so the CLI can wind down. Replaced once the
590
+ // stream exists; the finally block calls it no matter how the run ends.
591
+ let releasePromptStream = () => {};
592
+ let idleReleaseTimer = null;
593
+ // The client is told the turn is over as soon as `result` lands, even though
594
+ // the process lingers, so the UI never waits out the idle hold.
595
+ let turnCompleteSent = false;
596
+ // Set when a turn starts background work, cleared when the next `result`
597
+ // arrives — only turns with work still outstanding hold their process open.
598
+ let backgroundWorkPending = false;
599
+ // True while the process is being held open for background work, so a later
600
+ // `result` can be recognised as that work reporting back.
601
+ let heldForBackgroundWork = false;
602
+
603
+ // A new turn supersedes any earlier one still holding this session's process
604
+ // open, so held runs cannot stack up across a conversation.
605
+ if (sessionKey()) {
606
+ getSession(sessionKey())?.releaseInput?.();
607
+ }
608
+
609
+ // Arms (or re-arms) the idle countdown that eventually closes stdin.
610
+ const scheduleRelease = () => {
611
+ if (idleReleaseTimer) {
612
+ clearTimeout(idleReleaseTimer);
613
+ idleReleaseTimer = null;
614
+ }
615
+ idleReleaseTimer = setTimeout(() => {
616
+ idleReleaseTimer = null;
617
+ releasePromptStream();
618
+ }, BG_WAIT_CEILING_MS);
619
+ // Never let the hold keep the server process alive on its own.
620
+ idleReleaseTimer.unref?.();
621
+ };
622
+
623
+ // Hoisted above the try so the catch's cleanup can tell whether this run
624
+ // still owns the activeSessions entry (or was superseded by a newer run).
625
+ let queryInstance = null;
626
+
489
627
  try {
490
628
  const resolvedModel = await context.resolveResumeModel(sessionId, options.model);
491
629
  let effortModels = CLAUDE_PREDEFINED_MODELS;
@@ -507,10 +645,10 @@ async function queryClaudeSDK(command, options = {}, ws, context) {
507
645
  sdkOptions.mcpServers = mcpServers;
508
646
  }
509
647
 
510
- // Turns with image attachments switch to streaming input so the images
511
- // ride along as real content blocks. Built per query attempt because an
512
- // async generator cannot be replayed once consumed.
513
- const createPrompt = () => buildPromptPayload(command, options.images, options.files, options.cwd);
648
+ // Every turn uses streaming input so stdin stays open past the turn's
649
+ // `result`. The message list is reusable, but each query attempt needs its
650
+ // own stream because an async generator cannot be replayed once consumed.
651
+ const promptMessages = await buildPromptMessages(command, options.images, options.files, options.cwd);
514
652
 
515
653
  sdkOptions.hooks = {
516
654
  Notification: [{
@@ -613,10 +751,11 @@ async function queryClaudeSDK(command, options = {}, ws, context) {
613
751
  return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
614
752
  };
615
753
 
616
- let queryInstance;
754
+ let heldPrompt = createHeldPromptStream(promptMessages);
755
+ releasePromptStream = heldPrompt.release;
617
756
  try {
618
757
  queryInstance = query({
619
- prompt: await createPrompt(),
758
+ prompt: heldPrompt.stream,
620
759
  options: sdkOptions
621
760
  });
622
761
  } catch (hookError) {
@@ -624,15 +763,19 @@ async function queryClaudeSDK(command, options = {}, ws, context) {
624
763
  // Keep notification behavior operational via runtime events even if hook registration fails.
625
764
  console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
626
765
  delete sdkOptions.hooks;
766
+ // Discard the abandoned stream and build a fresh one for the retry.
767
+ heldPrompt.release();
768
+ heldPrompt = createHeldPromptStream(promptMessages);
769
+ releasePromptStream = heldPrompt.release;
627
770
  queryInstance = query({
628
- prompt: await createPrompt(),
771
+ prompt: heldPrompt.stream,
629
772
  options: sdkOptions
630
773
  });
631
774
  }
632
775
 
633
776
  // Track the query instance for abort capability
634
777
  if (sessionKey()) {
635
- addSession(sessionKey(), queryInstance, ws);
778
+ addSession(sessionKey(), queryInstance, ws, releasePromptStream);
636
779
  }
637
780
 
638
781
  // Process streaming messages
@@ -642,7 +785,7 @@ async function queryClaudeSDK(command, options = {}, ws, context) {
642
785
  if (message.session_id && !capturedSessionId) {
643
786
 
644
787
  capturedSessionId = message.session_id;
645
- addSession(sessionKey(), queryInstance, ws);
788
+ addSession(sessionKey(), queryInstance, ws, releasePromptStream);
646
789
 
647
790
  // Set session ID on writer
648
791
  if (ws.setSessionId && typeof ws.setSessionId === 'function') {
@@ -677,36 +820,98 @@ async function queryClaudeSDK(command, options = {}, ws, context) {
677
820
  if (tokenBudgetData) {
678
821
  ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
679
822
  }
823
+
824
+ if (startsBackgroundWork(message)) {
825
+ backgroundWorkPending = true;
826
+ }
827
+
828
+ if (message.type === 'result') {
829
+ // The turn is done as far as the client is concerned.
830
+ const abortPending = sessionKey() ? abortedSessionIds.has(sessionKey()) : false;
831
+ if (!turnCompleteSent && !abortPending) {
832
+ turnCompleteSent = true;
833
+ ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
834
+ notifyRunStopped({
835
+ userId: ws?.userId || null,
836
+ provider: 'claude',
837
+ sessionId: sessionId || capturedSessionId || null,
838
+ sessionName: sessionSummary,
839
+ stopReason: 'completed'
840
+ });
841
+ } else if (heldForBackgroundWork && !abortPending) {
842
+ // A result after the turn already reported complete means the work we
843
+ // held the process open for has finished and pushed a follow-up turn.
844
+ notifyBackgroundWorkCompleted({
845
+ userId: ws?.userId || null,
846
+ provider: 'claude',
847
+ sessionId: sessionId || capturedSessionId || null,
848
+ sessionName: sessionSummary
849
+ });
850
+ }
851
+ if (backgroundWorkPending) {
852
+ // Work started during this turn is still running. Hold the process
853
+ // open so it can finish and report back in a follow-up turn; the
854
+ // ceiling is only a backstop for work that never reports.
855
+ backgroundWorkPending = false;
856
+ heldForBackgroundWork = true;
857
+ scheduleRelease();
858
+ } else {
859
+ // Either nothing was backgrounded, or the background work just
860
+ // reported in — let the CLI exit now, as it always has.
861
+ heldForBackgroundWork = false;
862
+ releasePromptStream();
863
+ }
864
+ } else if (idleReleaseTimer) {
865
+ // Background activity after the turn — push the countdown back out.
866
+ scheduleRelease();
867
+ }
680
868
  }
681
869
 
682
- // Clean up session on completion
683
- if (sessionKey()) {
870
+ // Clean up session on completion — only while this run still owns the map
871
+ // entry. A superseding run may have replaced it, and deleting here would
872
+ // strand that run.
873
+ if (sessionKey() && getSession(sessionKey())?.instance === queryInstance) {
684
874
  removeSession(sessionKey());
685
875
  }
686
876
 
877
+ // A superseded run winds down silently: the map entry, the abort flag,
878
+ // and all client-facing events belong to the run that replaced it.
879
+ const superseded = supersededInstances.has(queryInstance);
880
+
687
881
  // Send the terminal completion event — skipped for aborted runs, whose
688
- // terminal `complete` (aborted: true) was already sent by abort-session.
689
- const wasAborted = sessionKey() ? abortedSessionIds.delete(sessionKey()) : false;
690
- if (!wasAborted) {
691
- ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
882
+ // terminal `complete` (aborted: true) was already sent by abort-session, and
883
+ // for runs that already reported completion when their `result` arrived.
884
+ const wasAborted = !superseded && sessionKey() ? abortedSessionIds.delete(sessionKey()) : false;
885
+ if (!turnCompleteSent && !superseded) {
886
+ turnCompleteSent = true;
887
+ if (!wasAborted) {
888
+ ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
889
+ }
890
+ notifyRunStopped({
891
+ userId: ws?.userId || null,
892
+ provider: 'claude',
893
+ sessionId: sessionId || capturedSessionId || null,
894
+ sessionName: sessionSummary,
895
+ stopReason: wasAborted ? 'aborted' : 'completed'
896
+ });
692
897
  }
693
- notifyRunStopped({
694
- userId: ws?.userId || null,
695
- provider: 'claude',
696
- sessionId: sessionId || capturedSessionId || null,
697
- sessionName: sessionSummary,
698
- stopReason: wasAborted ? 'aborted' : 'completed'
699
- });
700
898
  // Complete
701
899
 
702
900
  } catch (error) {
703
901
  console.error('SDK query error:', error);
704
902
 
705
- // Clean up session on error
706
- if (sessionKey()) {
903
+ // Clean up session on error — only while this run still owns the map entry
904
+ // (a superseding run may have replaced it).
905
+ if (sessionKey() && getSession(sessionKey())?.instance === queryInstance) {
707
906
  removeSession(sessionKey());
708
907
  }
709
908
 
909
+ if (supersededInstances.has(queryInstance)) {
910
+ // Interrupted because a newer run took over this session id; that run
911
+ // owns the abort flag and all further client-facing events.
912
+ return;
913
+ }
914
+
710
915
  const wasAborted = sessionKey() ? abortedSessionIds.delete(sessionKey()) : false;
711
916
  if (wasAborted) {
712
917
  // The abort already produced the terminal complete; a generator throw
@@ -720,9 +925,13 @@ async function queryClaudeSDK(command, options = {}, ws, context) {
720
925
  ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
721
926
  : error.message;
722
927
 
723
- // Send error to WebSocket, then the terminal complete
928
+ // Send error to WebSocket, then the terminal complete. A run that already
929
+ // reported completion and then failed during its post-turn hold still
930
+ // surfaces the error, but must not emit a second terminal complete.
724
931
  ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
725
- ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
932
+ if (!turnCompleteSent) {
933
+ ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
934
+ }
726
935
  notifyRunFailed({
727
936
  userId: ws?.userId || null,
728
937
  provider: 'claude',
@@ -730,6 +939,14 @@ async function queryClaudeSDK(command, options = {}, ws, context) {
730
939
  sessionName: sessionSummary,
731
940
  error
732
941
  });
942
+ } finally {
943
+ // Always close stdin — otherwise an aborted or failed run leaves the CLI
944
+ // process (and its MCP servers) alive until the server exits.
945
+ if (idleReleaseTimer) {
946
+ clearTimeout(idleReleaseTimer);
947
+ idleReleaseTimer = null;
948
+ }
949
+ releasePromptStream();
733
950
  }
734
951
  }
735
952
 
@@ -756,6 +973,10 @@ async function abortClaudeSDKSession(sessionId) {
756
973
  // Call interrupt() on the query instance
757
974
  await session.instance.interrupt();
758
975
 
976
+ // Release the held stdin stream; without this the CLI stays up for the rest
977
+ // of the post-turn hold even though the user cancelled.
978
+ session.releaseInput?.();
979
+
759
980
  // Update session status
760
981
  session.status = 'aborted';
761
982
 
@@ -840,6 +840,11 @@ router.get('/search/sessions', asyncHandler(async (req: Request, res: Response)
840
840
  query,
841
841
  limit,
842
842
  signal: abortController.signal,
843
+ onTitleResults: (titleResults) => {
844
+ if (!closed) {
845
+ res.write(`event: title-results\ndata: ${JSON.stringify({ titleResults })}\n\n`);
846
+ }
847
+ },
843
848
  onProgress: ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
844
849
  if (closed) {
845
850
  return;
@@ -1,4 +1,4 @@
1
- import fsSync, { promises as fs } from 'node:fs';
1
+ import fsSync from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import readline from 'node:readline';
4
4
 
@@ -38,6 +38,15 @@ type ProjectConversationResult = {
38
38
  sessions: SessionConversationResult[];
39
39
  };
40
40
 
41
+ type SessionTitleSearchResult = {
42
+ sessionId: string;
43
+ provider: string;
44
+ projectId: string | null;
45
+ projectDisplayName: string;
46
+ sessionTitle: string;
47
+ lastActivity: string | null;
48
+ };
49
+
41
50
  export type SessionConversationSearchProgressUpdate = {
42
51
  projectResult: ProjectConversationResult | null;
43
52
  totalMatches: number;
@@ -49,6 +58,7 @@ type SearchSessionConversationsInput = {
49
58
  query: string;
50
59
  limit: number;
51
60
  signal?: AbortSignal;
61
+ onTitleResults?: (results: SessionTitleSearchResult[]) => void;
52
62
  onProgress?: (update: SessionConversationSearchProgressUpdate) => void;
53
63
  };
54
64
 
@@ -156,6 +166,67 @@ function toSummaryText(customName: string | null, fallback: string | null | unde
156
166
  return trimmedFallback.length > 50 ? `${trimmedFallback.slice(0, 50)}...` : trimmedFallback;
157
167
  }
158
168
 
169
+ /**
170
+ * Finds visible sessions whose displayed title contains the query. Title
171
+ * matches are resolved from the database before transcript scanning so the UI
172
+ * can always present them first, including sessions without a transcript yet.
173
+ */
174
+ function findSessionTitleResults(
175
+ sessions: SessionRepositoryRow[],
176
+ query: string,
177
+ limit: number,
178
+ ): SessionTitleSearchResult[] {
179
+ const normalizedQuery = query.toLocaleLowerCase().replace(/\s+/g, ' ');
180
+ const projectCache = new Map<string, ReturnType<typeof projectsDb.getProjectPath>>();
181
+
182
+ return sessions
183
+ .flatMap((session) => {
184
+ const sessionTitle = toSummaryText(session.custom_name, session.session_id, session.session_id);
185
+ const normalizedTitle = sessionTitle.toLocaleLowerCase().replace(/\s+/g, ' ');
186
+ const matchIndex = normalizedTitle.indexOf(normalizedQuery);
187
+ if (matchIndex === -1) {
188
+ return [];
189
+ }
190
+
191
+ const projectPath = typeof session.project_path === 'string' && session.project_path.trim()
192
+ ? session.project_path.trim()
193
+ : null;
194
+ const projectKey = projectPath ?? UNKNOWN_PROJECT_KEY;
195
+ if (!projectCache.has(projectKey)) {
196
+ projectCache.set(
197
+ projectKey,
198
+ projectPath ? projectsDb.getProjectPath(projectPath) : null,
199
+ );
200
+ }
201
+
202
+ const project = projectCache.get(projectKey) ?? null;
203
+ if (project?.isArchived) {
204
+ return [];
205
+ }
206
+
207
+ return [{
208
+ sessionId: session.session_id,
209
+ provider: session.provider,
210
+ projectId: project?.project_id ?? null,
211
+ projectDisplayName: projectPath
212
+ ? (project?.custom_project_name?.trim() || path.basename(projectPath) || projectPath)
213
+ : 'Unknown Project',
214
+ sessionTitle,
215
+ lastActivity: session.updated_at || session.created_at || null,
216
+ matchIndex,
217
+ }];
218
+ })
219
+ .sort((left, right) => {
220
+ if (left.matchIndex !== right.matchIndex) {
221
+ return left.matchIndex - right.matchIndex;
222
+ }
223
+
224
+ return new Date(right.lastActivity ?? 0).getTime() - new Date(left.lastActivity ?? 0).getTime();
225
+ })
226
+ .slice(0, limit)
227
+ .map(({ matchIndex: _matchIndex, ...result }) => result);
228
+ }
229
+
159
230
  function isInternalContent(content: string): boolean {
160
231
  return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
161
232
  }
@@ -1071,28 +1142,42 @@ async function parseSessionMatches(
1071
1142
  return null;
1072
1143
  }
1073
1144
 
1145
+ /**
1146
+ * Searches session titles and provider transcripts for the provider search
1147
+ * service and its route-level integration tests.
1148
+ */
1074
1149
  export async function searchConversations(
1075
1150
  query: string,
1076
1151
  limit = 50,
1077
1152
  onProjectResult: ((update: SessionConversationSearchProgressUpdate) => void) | null = null,
1078
1153
  signal: AbortSignal | null = null,
1079
- ): Promise<{ results: ProjectConversationResult[]; totalMatches: number; query: string }> {
1154
+ onTitleResults: ((results: SessionTitleSearchResult[]) => void) | null = null,
1155
+ ): Promise<{
1156
+ results: ProjectConversationResult[];
1157
+ titleResults: SessionTitleSearchResult[];
1158
+ totalMatches: number;
1159
+ query: string;
1160
+ }> {
1080
1161
  const safeQuery = typeof query === 'string' ? query.trim() : '';
1081
1162
  const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200));
1082
1163
  const words = safeQuery.toLowerCase().split(/\s+/).filter((word) => word.length > 0);
1083
1164
 
1084
1165
  if (words.length === 0) {
1085
- return { results: [], totalMatches: 0, query: safeQuery };
1166
+ return { results: [], titleResults: [], totalMatches: 0, query: safeQuery };
1086
1167
  }
1087
1168
 
1088
1169
  const isAborted = () => signal?.aborted === true;
1089
1170
  if (isAborted()) {
1090
- return { results: [], totalMatches: 0, query: safeQuery };
1171
+ return { results: [], titleResults: [], totalMatches: 0, query: safeQuery };
1091
1172
  }
1092
1173
 
1093
- const searchableSessions = normalizeSearchableSessions(sessionsDb.getAllSessions());
1174
+ const activeSessions = sessionsDb.getAllSessions();
1175
+ const titleResults = findSessionTitleResults(activeSessions, safeQuery, safeLimit);
1176
+ onTitleResults?.(titleResults);
1177
+
1178
+ const searchableSessions = normalizeSearchableSessions(activeSessions);
1094
1179
  if (searchableSessions.length === 0) {
1095
- return { results: [], totalMatches: 0, query: safeQuery };
1180
+ return { results: [], titleResults, totalMatches: 0, query: safeQuery };
1096
1181
  }
1097
1182
 
1098
1183
  const sessionsByPathKey = new Map<string, SearchableSessionRow[]>();
@@ -1123,7 +1208,7 @@ export async function searchConversations(
1123
1208
  signal ?? undefined,
1124
1209
  );
1125
1210
  if (isAborted() || matchedFileKeys.size === 0) {
1126
- return { results: [], totalMatches: 0, query: safeQuery };
1211
+ return { results: [], titleResults, totalMatches: 0, query: safeQuery };
1127
1212
  }
1128
1213
 
1129
1214
  const matchedSessionKeys = new Set<string>();
@@ -1207,6 +1292,7 @@ export async function searchConversations(
1207
1292
 
1208
1293
  return {
1209
1294
  results,
1295
+ titleResults,
1210
1296
  totalMatches: runtime.totalMatches,
1211
1297
  query: safeQuery,
1212
1298
  };
@@ -1229,6 +1315,7 @@ export const sessionConversationsSearchService = {
1229
1315
  input.limit,
1230
1316
  input.onProgress ?? null,
1231
1317
  input.signal ?? null,
1318
+ input.onTitleResults ?? null,
1232
1319
  );
1233
1320
  },
1234
1321
  };