@loicngr/kobo 1.11.26 → 1.11.27

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 (81) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/server/routes/workspaces.js +28 -11
  3. package/dist/server/services/agent/engines/claude-code/engine.js +109 -15
  4. package/dist/server/services/agent/engines/codex/server-requests.js +5 -20
  5. package/dist/server/services/agent/orchestrator.js +16 -2
  6. package/dist/server/services/agent/streaming-batcher.js +1 -0
  7. package/dist/server/services/agent/turn-liveness.js +12 -1
  8. package/dist/server/services/file-editor-service.js +2 -46
  9. package/dist/server/services/forge/bitbucket/provider.js +9 -8
  10. package/dist/server/services/forge/github/provider.js +4 -3
  11. package/dist/server/services/forge/gitlab/provider.js +4 -3
  12. package/dist/server/services/forge/parse-cli-json.js +14 -0
  13. package/package.json +1 -1
  14. package/src/client/dist/pwa/assets/{ActivityFeed-DUjY6lCm.css → ActivityFeed-LEd9NEbI.css} +1 -1
  15. package/src/client/dist/pwa/assets/{ActivityFeed-CGMEOMn5.js → ActivityFeed-XJeDwa82.js} +5 -5
  16. package/src/client/dist/pwa/assets/{ChangelogPage-rvISZfJb.js → ChangelogPage-Bjls89ZX.js} +1 -1
  17. package/src/client/dist/pwa/assets/CreatePage-BzHQd4uE.js +2 -0
  18. package/src/client/dist/pwa/assets/DiffViewer-BV8SvUIi.css +1 -0
  19. package/src/client/dist/pwa/assets/DiffViewer-DNPD0uVB.js +8 -0
  20. package/src/client/dist/pwa/assets/{DocumentsPanel-DuWbZiID.js → DocumentsPanel-Osdun-Om.js} +1 -1
  21. package/src/client/dist/pwa/assets/{GitPanel-BiIXQPfL.js → GitPanel-8_xQ7V2r.js} +3 -3
  22. package/src/client/dist/pwa/assets/{GitPanel-BKrtkPrV.css → GitPanel-DVs5BCU-.css} +1 -1
  23. package/src/client/dist/pwa/assets/{HealthPage-DTrmhh3X.js → HealthPage-CSEPHslq.js} +1 -1
  24. package/src/client/dist/pwa/assets/{MainLayout-Vq1iUPjx.css → MainLayout-Bdh2JQfn.css} +1 -1
  25. package/src/client/dist/pwa/assets/MainLayout-X1T4sh65.js +2 -0
  26. package/src/client/dist/pwa/assets/{QBtnDropdown-Ckvd3IVR.js → QBtnDropdown-aUlDUJBU.js} +1 -1
  27. package/src/client/dist/pwa/assets/{SchedulePanel-DJWMsAoh.js → SchedulePanel-DIoiKpwZ.js} +1 -1
  28. package/src/client/dist/pwa/assets/{SearchPage-BI8w9utc.js → SearchPage-CKNNtnwv.js} +1 -1
  29. package/src/client/dist/pwa/assets/SettingsPage-njeIse66.js +16 -0
  30. package/src/client/dist/pwa/assets/{TerminalPanel-B6xJJs8p.css → TerminalPanel-DbVZk0vD.css} +1 -1
  31. package/src/client/dist/pwa/assets/TerminalPanel-HIuH2z-Y.js +18 -0
  32. package/src/client/dist/pwa/assets/{TouchPan-pqksSni0.js → TouchPan-CMDAeqj6.js} +1 -1
  33. package/src/client/dist/pwa/assets/WorkspacePage-Bk7pIvC0.js +4 -0
  34. package/src/client/dist/pwa/assets/{build-path-tree-CQ4zZqo0.js → build-path-tree-B07ATM5W.js} +1 -1
  35. package/src/client/dist/pwa/assets/{cssMode-MGWKgqj7.js → cssMode-BvD76b3P.js} +1 -1
  36. package/src/client/dist/pwa/assets/{documents-B6eOZeFK.js → documents-D7AZJAiR.js} +1 -1
  37. package/src/client/dist/pwa/assets/{editor.api-Dq41Mm99.js → editor.api-GJX9JYVf.js} +1 -1
  38. package/src/client/dist/pwa/assets/{efforts-BPZzMyrc.js → efforts-Dn1yRgeo.js} +1 -1
  39. package/src/client/dist/pwa/assets/{engineFeatures-Dd_dxoOS.js → engineFeatures-C4wJRlwO.js} +1 -1
  40. package/src/client/dist/pwa/assets/{expand-template-BfZqON3q.js → expand-template-0hMEF4rZ.js} +1 -1
  41. package/src/client/dist/pwa/assets/expand-template-CTUNyJrv.css +1 -0
  42. package/src/client/dist/pwa/assets/{freemarker2-BqVyQSGe.js → freemarker2-qQC_1HDe.js} +1 -1
  43. package/src/client/dist/pwa/assets/{handlebars-9K762gzW.js → handlebars-pyc5TcAt.js} +1 -1
  44. package/src/client/dist/pwa/assets/{html-CRmp4AC_.js → html-DOaftBCT.js} +1 -1
  45. package/src/client/dist/pwa/assets/{htmlMode-CFxFF9iH.js → htmlMode-DRXGGPOb.js} +1 -1
  46. package/src/client/dist/pwa/assets/i18n-mEG61A5h.js +1 -0
  47. package/src/client/dist/pwa/assets/{index-BpmxQlZi.css → index-6H3m6vH7.css} +1 -1
  48. package/src/client/dist/pwa/assets/{index-xTf0VfFG.js → index-DZ4aWwQs.js} +2 -2
  49. package/src/client/dist/pwa/assets/{javascript-DB9a_ZGm.js → javascript-BTsYFFLK.js} +1 -1
  50. package/src/client/dist/pwa/assets/{jsonMode-JhLTE1-C.js → jsonMode-3Glcck88.js} +1 -1
  51. package/src/client/dist/pwa/assets/{liquid-C67ZTBj7.js → liquid-BdTymdAY.js} +1 -1
  52. package/src/client/dist/pwa/assets/{lspLanguageFeatures-BIDd3ml0.js → lspLanguageFeatures-CvwRMMY9.js} +1 -1
  53. package/src/client/dist/pwa/assets/{mdx-C_K9rc9_.js → mdx-DzJktAFj.js} +1 -1
  54. package/src/client/dist/pwa/assets/network-auth-D59LKtK4.js +1 -0
  55. package/src/client/dist/pwa/assets/{notify-theme-thUiJn9n.js → notify-theme-D1FTBWWF.js} +1 -1
  56. package/src/client/dist/pwa/assets/{pending-diff-open-BdA8vrDx.js → pending-diff-open-DrdnFOI2.js} +1 -1
  57. package/src/client/dist/pwa/assets/permissionModes-_rSNmU9M.js +36 -0
  58. package/src/client/dist/pwa/assets/{project-color-hgNWqp0x.js → project-color-eoGJvY_F.js} +1 -1
  59. package/src/client/dist/pwa/assets/{python-D1CBO9Uf.js → python-onIjs5sJ.js} +1 -1
  60. package/src/client/dist/pwa/assets/{razor-BCiKSj6_.js → razor-DqQGiaiD.js} +1 -1
  61. package/src/client/dist/pwa/assets/{render-chat-markdown-Cw1_NLqs.js → render-chat-markdown-BwbknTum.js} +1 -1
  62. package/src/client/dist/pwa/assets/{tsMode-Db77HzVu.js → tsMode-B24qEjDH.js} +1 -1
  63. package/src/client/dist/pwa/assets/{typescript-D-8UENST.js → typescript-BnI60_wU.js} +1 -1
  64. package/src/client/dist/pwa/assets/{use-onboarding-BafrD9nc.js → use-onboarding-WR8vvxBw.js} +1 -1
  65. package/src/client/dist/pwa/assets/{vs-Bb18eQC8.js → vs-DVt5kpUB.js} +3 -3
  66. package/src/client/dist/pwa/assets/{workers-CImko7IX.js → workers-D28S2iKq.js} +1 -1
  67. package/src/client/dist/pwa/assets/{xml-BBPvJ9QP.js → xml-DQUlBYuZ.js} +1 -1
  68. package/src/client/dist/pwa/assets/{yaml-D4q1p7HX.js → yaml-D3GiqGbP.js} +1 -1
  69. package/src/client/dist/pwa/index.html +2 -2
  70. package/src/client/dist/pwa/sw.js +1 -1
  71. package/src/client/dist/pwa/assets/CreatePage-BptAgVvu.js +0 -2
  72. package/src/client/dist/pwa/assets/DiffViewer-ZrvQ0YWN.js +0 -8
  73. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +0 -1
  74. package/src/client/dist/pwa/assets/MainLayout-PSmlvfJd.js +0 -2
  75. package/src/client/dist/pwa/assets/SettingsPage-Dr8oSevA.js +0 -16
  76. package/src/client/dist/pwa/assets/TerminalPanel-DVC58DJb.js +0 -18
  77. package/src/client/dist/pwa/assets/WorkspacePage-DRP9Ypmz.js +0 -4
  78. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +0 -1
  79. package/src/client/dist/pwa/assets/i18n-BsWCIFmf.js +0 -1
  80. package/src/client/dist/pwa/assets/network-auth-BPxfeLcl.js +0 -1
  81. package/src/client/dist/pwa/assets/permissionModes-D09rnNgv.js +0 -36
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
+ ## 1.11.27
8
+
9
+ - fix: session watchdog false-positives, sync drain, and terminal leaks, audit npm
10
+
7
11
  ## 1.11.26
8
12
 
9
13
  - fix(client): resume the interrupted session on manual quota resume
@@ -1821,13 +1821,18 @@ app.delete('/:id/events/:eventId', (c) => {
1821
1821
  const db = getDb();
1822
1822
  // Capture the session BEFORE deleting: since v36 there is no AFTER DELETE
1823
1823
  // trigger, so the metrics of that session must be recomputed once, here.
1824
- const row = db
1825
- .prepare('SELECT session_id FROM ws_events WHERE id = ? AND workspace_id = ?')
1826
- .get(eventId, workspaceId);
1827
- db.prepare('DELETE FROM ws_events WHERE id = ? AND workspace_id = ?').run(eventId, workspaceId);
1828
- if (row?.session_id) {
1829
- workspaceService.recomputeSessionMetrics(workspaceId, row.session_id);
1830
- }
1824
+ // Transactional for the same reason deleteSession is: a crash between the
1825
+ // delete and the recompute must not leave stale metrics behind.
1826
+ const transaction = db.transaction(() => {
1827
+ const row = db
1828
+ .prepare('SELECT session_id FROM ws_events WHERE id = ? AND workspace_id = ?')
1829
+ .get(eventId, workspaceId);
1830
+ db.prepare('DELETE FROM ws_events WHERE id = ? AND workspace_id = ?').run(eventId, workspaceId);
1831
+ if (row?.session_id) {
1832
+ workspaceService.recomputeSessionMetrics(workspaceId, row.session_id);
1833
+ }
1834
+ });
1835
+ transaction();
1831
1836
  return c.json({ ok: true });
1832
1837
  }
1833
1838
  catch (err) {
@@ -1992,6 +1997,10 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
1992
1997
  return c.json({ error: message }, 500);
1993
1998
  }
1994
1999
  });
2000
+ // Cap how many recent rows one search may scan: the LIKE below cannot use an
2001
+ // index past the workspace_id prefix, and better-sqlite3 is synchronous — an
2002
+ // unbounded scan of a months-old workspace blocks every request and WS emit.
2003
+ const HISTORY_SEARCH_SCAN_CAP = 20_000;
1995
2004
  // GET /api/workspaces/:id/history-search — full-workspace persisted conversation search.
1996
2005
  app.get('/:id/history-search', (c) => {
1997
2006
  try {
@@ -2004,10 +2013,13 @@ app.get('/:id/history-search', (c) => {
2004
2013
  const limit = Math.min(parseInt(c.req.query('limit') ?? '30', 10) || 30, 100);
2005
2014
  const needle = `%${query.replace(/[%_]/g, '\\$&')}%`;
2006
2015
  const rows = getDb()
2007
- .prepare(`SELECT id, session_id, type, payload, created_at FROM ws_events
2008
- WHERE workspace_id = ? AND payload LIKE ? ESCAPE '\\'
2009
- ORDER BY rowid DESC LIMIT ?`)
2010
- .all(id, needle, limit * 4);
2016
+ .prepare(`SELECT id, session_id, type, payload, created_at FROM (
2017
+ SELECT rowid AS rid, id, session_id, type, payload, created_at FROM ws_events
2018
+ WHERE workspace_id = ? ORDER BY rowid DESC LIMIT ?
2019
+ )
2020
+ WHERE payload LIKE ? ESCAPE '\\'
2021
+ ORDER BY rid DESC LIMIT ?`)
2022
+ .all(id, HISTORY_SEARCH_SCAN_CAP, needle, limit * 4);
2011
2023
  const normalized = rows.flatMap((row) => {
2012
2024
  const result = readableHistorySearchResult(row.type, row.payload, query);
2013
2025
  if (!result)
@@ -2866,6 +2878,11 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
2866
2878
  }
2867
2879
  // Delete workspace from DB (cascades to tasks, sessions, events)
2868
2880
  workspaceService.deleteWorkspace(workspace.id);
2881
+ // Announce the deletion so any other tab with this workspace open (or a
2882
+ // live terminal attached to it) can react — mirrors workspace:archived's
2883
+ // cross-tab-sync purpose. Ephemeral: a deleted workspace has no future
2884
+ // ws_events history to replay, so persisting this would be pointless.
2885
+ wsService.emitEphemeral(workspace.id, 'workspace:deleted', { workspaceId: workspace.id });
2869
2886
  return warnings;
2870
2887
  }
2871
2888
  // DELETE /api/workspaces/archived — bulk-delete every archived workspace.
@@ -17,7 +17,7 @@ import { buildStopHookOutput } from './stop-hook.js';
17
17
  * post-result drain watchdog force-emits `session:ended` so the orchestrator
18
18
  * and auto-loop are not frozen forever.
19
19
  */
20
- const RESULT_DRAIN_TIMEOUT_MS = 15_000;
20
+ export const RESULT_DRAIN_TIMEOUT_MS = 15_000;
21
21
  /**
22
22
  * Deadline for a stream that stops reporting ANY activity. The former
23
23
  * text-only guard was re-armed solely on a message with text and no tool call
@@ -26,12 +26,23 @@ const RESULT_DRAIN_TIMEOUT_MS = 15_000;
26
26
  * forever with zero trace. Exported so tests never hard-code the value.
27
27
  */
28
28
  export const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 120_000;
29
+ /**
30
+ * Idle ceiling while a FOREGROUND tool call is in flight (tool:call seen, no
31
+ * tool:result yet). A long Bash run — a test suite, a CI-poll sleep — emits no
32
+ * SDK message until its tool_result, so the short deadline would kill a
33
+ * healthy session mid-tool (the "random session stops" bug). A genuinely dead
34
+ * stream is still reaped once this longer ceiling elapses.
35
+ */
36
+ export const CLAUDE_TOOL_IDLE_TIMEOUT_MS = 30 * 60_000;
37
+ /** Ceiling on a context compaction: past this, assume it wedged and let the
38
+ * liveness deadline take over instead of pausing forever. */
39
+ export const COMPACTION_STALL_TIMEOUT_MS = 10 * 60_000;
29
40
  // Safety net for a subagent whose terminal `task_notification` carries a
30
41
  // status outside the mapper's known-terminal set (SDK schema drift) — it
31
42
  // never clears `activeSubagentToolCallIds`, which otherwise blocks
32
43
  // `inputStream.close()`/the result-drain watchdog forever. Generous window:
33
44
  // legitimate subagents can run for several minutes.
34
- const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
45
+ export const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
35
46
  const MAX_PENDING_USER_MESSAGES = 20;
36
47
  function toMcpServersMap(specs) {
37
48
  if (!specs || specs.length === 0)
@@ -106,6 +117,9 @@ export function createClaudeCodeEngine() {
106
117
  // UI/tool ids can differ from the SDK task id. Key by the stable UI id
107
118
  // so a terminal notification that omits task_id still clears the task.
108
119
  const activeSubagentTaskIds = new Map();
120
+ // Foreground tool calls currently awaiting their tool_result. Cleared on
121
+ // every `result` message: a turn cannot end with a tool still in flight.
122
+ const pendingToolCallIds = new Set();
109
123
  // Pending canUseTool callbacks, keyed by SDK ctx.toolUseID.
110
124
  const pendingResolvers = new Map();
111
125
  const isInteractive = options.agentPermissionMode === 'interactive';
@@ -283,16 +297,74 @@ export function createClaudeCodeEngine() {
283
297
  const turnLiveness = createTurnLiveness({
284
298
  timeoutMs: CLAUDE_STREAM_IDLE_TIMEOUT_MS,
285
299
  onTimeout() {
286
- console.warn(`[claude-engine] SDK stream reported no activity for ${CLAUDE_STREAM_IDLE_TIMEOUT_MS}ms — forcing session:ended`);
300
+ // The armed deadline tracks whether a foreground tool call is in
301
+ // flight (see `turnLiveness.setTimeoutMs` below) — log the duration
302
+ // that actually elapsed, not always the shorter baseline, so the
303
+ // watchdog's own diagnostic message stays trustworthy.
304
+ const timeoutMs = pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS;
305
+ console.warn(`[claude-engine] SDK stream reported no activity for ${timeoutMs}ms — forcing session:ended`);
287
306
  if (userInterrupted)
288
307
  emitSessionEnded('killed', null);
289
308
  else if (mapperState.sawErrorResult)
290
309
  emitSessionEnded('error', null);
291
- else
310
+ else {
311
+ safeEmit({
312
+ kind: 'error',
313
+ category: 'other',
314
+ message: 'Session force-ended by the liveness watchdog: no SDK activity within the deadline. If the agent was legitimately busy, this is a bug worth reporting.',
315
+ });
292
316
  emitSessionEnded('watchdog', null);
317
+ }
293
318
  abortController.abort();
294
319
  },
295
320
  });
321
+ // The single source of truth for "should the idle deadline be paused
322
+ // right now" — a background subagent, a pending permission/question
323
+ // card, or an in-progress compaction can each legitimately keep the
324
+ // stream quiet for minutes; their own dedicated watchdogs own those
325
+ // windows. Every call site that changes one of these three conditions
326
+ // must re-evaluate through this helper, not duplicate the check.
327
+ const reevaluateLivenessPause = () => {
328
+ if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
329
+ turnLiveness.pause();
330
+ else
331
+ turnLiveness.resume();
332
+ };
333
+ // Backstop for a compaction that never reports completion (stuck
334
+ // `session:compacting` with `active: true` forever): `isCompacting`
335
+ // pauses `turnLiveness` indefinitely, and a wedged-but-alive generator
336
+ // never trips `isAlive()`'s own sweep either. Past this ceiling, assume
337
+ // it wedged and resume the liveness deadline so the existing tool-aware
338
+ // / short deadline can reap it, mirroring `armSubagentStallWatchdog`.
339
+ let compactionStallTimer;
340
+ const clearCompactionStallTimer = () => {
341
+ if (!compactionStallTimer)
342
+ return;
343
+ clearTimeout(compactionStallTimer);
344
+ compactionStallTimer = undefined;
345
+ };
346
+ const armCompactionStallTimer = () => {
347
+ clearCompactionStallTimer();
348
+ compactionStallTimer = setTimeout(() => {
349
+ compactionStallTimer = undefined;
350
+ if (!isCompacting)
351
+ return;
352
+ console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — resuming the liveness deadline.`);
353
+ isCompacting = false;
354
+ reevaluateLivenessPause();
355
+ if (activeSubagentTaskIds.size > 0 && !subagentStallTimer) {
356
+ // Resume declined because a subagent is still tracked — but the
357
+ // subagent stall net is only armed from the `result` branch, which
358
+ // a fully wedged generator may never reach. Arm it here as a
359
+ // second stage so this combination can't hang forever. The
360
+ // `!subagentStallTimer` guard matters: arming re-sets the
361
+ // deadline, and a net already armed by a prior `result` must not
362
+ // be extended by this backstop (it is not new activity).
363
+ armSubagentStallWatchdog();
364
+ }
365
+ }, COMPACTION_STALL_TIMEOUT_MS);
366
+ compactionStallTimer.unref?.();
367
+ };
296
368
  const armResultDrainWatchdog = () => {
297
369
  if (resultDrainTimer)
298
370
  return;
@@ -302,8 +374,14 @@ export function createClaudeCodeEngine() {
302
374
  emitSessionEnded('killed', null);
303
375
  else if (mapperState.sawErrorResult)
304
376
  emitSessionEnded('error', null);
305
- else
377
+ else {
378
+ safeEmit({
379
+ kind: 'error',
380
+ category: 'other',
381
+ message: 'Session force-ended: the SDK generator stayed open after its final result (drain watchdog).',
382
+ });
306
383
  emitSessionEnded('watchdog', null);
384
+ }
307
385
  abortController.abort();
308
386
  }, RESULT_DRAIN_TIMEOUT_MS);
309
387
  resultDrainTimer.unref?.();
@@ -369,12 +447,25 @@ export function createClaudeCodeEngine() {
369
447
  activeSubagentTaskIds.delete(ev.toolCallId);
370
448
  }
371
449
  for (const ev of events) {
372
- if (ev.kind === 'session:compacting')
450
+ if (ev.kind === 'session:compacting') {
373
451
  isCompacting = ev.active;
452
+ if (ev.active)
453
+ armCompactionStallTimer();
454
+ else
455
+ clearCompactionStallTimer();
456
+ }
374
457
  // Older SDKs can emit only compact_boundary, without a trailing
375
458
  // status update. This boundary marks compaction as complete.
376
- else if (ev.kind === 'session:compacted')
459
+ else if (ev.kind === 'session:compacted') {
377
460
  isCompacting = false;
461
+ clearCompactionStallTimer();
462
+ }
463
+ }
464
+ for (const ev of events) {
465
+ if (ev.kind === 'tool:call')
466
+ pendingToolCallIds.add(ev.toolCallId);
467
+ else if (ev.kind === 'tool:result')
468
+ pendingToolCallIds.delete(ev.toolCallId);
378
469
  }
379
470
  // The stall watchdog is only armed while we're waiting purely on
380
471
  // background subagents (no further SDK turn expected unless one
@@ -398,11 +489,12 @@ export function createClaudeCodeEngine() {
398
489
  // A background subagent or a pending permission card can stay
399
490
  // legitimately quiet for minutes; their own dedicated watchdogs
400
491
  // own those windows, so suspend the turn deadline meanwhile.
401
- if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
402
- turnLiveness.pause();
403
- else
404
- turnLiveness.resume();
492
+ // A FOREGROUND tool call in flight keeps the deadline armed but
493
+ // stretched to the tool-aware ceiling.
494
+ turnLiveness.setTimeoutMs(pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS);
495
+ reevaluateLivenessPause();
405
496
  if (msg.type === 'result') {
497
+ pendingToolCallIds.clear();
406
498
  completedResponses++;
407
499
  emitTurnCompletedIfSettled();
408
500
  // A queued forced message starts the next response on this same SDK stream.
@@ -457,6 +549,7 @@ export function createClaudeCodeEngine() {
457
549
  clearResultDrainWatchdog();
458
550
  turnLiveness.stop();
459
551
  clearSubagentStallWatchdog();
552
+ clearCompactionStallTimer();
460
553
  // Drain any callback still pending (SDK terminated while awaiting an
461
554
  // answer). canUseTool's abort path covers signalled stops; this
462
555
  // covers natural iterator completion.
@@ -541,10 +634,11 @@ export function createClaudeCodeEngine() {
541
634
  if (!resolver)
542
635
  return false;
543
636
  pendingResolvers.delete(toolCallId);
544
- // Only resume the deadline once every outstanding card is answered —
545
- // a sibling request may still be waiting on a human.
546
- if (pendingResolvers.size === 0)
547
- turnLiveness.resume();
637
+ // Re-evaluate rather than unconditionally resuming: a sibling
638
+ // request, a still-active subagent, or an in-progress compaction
639
+ // may each still have their own legitimate reason to keep the
640
+ // deadline paused.
641
+ reevaluateLivenessPause();
548
642
  if (response.kind === 'question') {
549
643
  // Echo the original questions array + answers so the SDK
550
644
  // reconstructs the AskUserQuestion tool input.
@@ -67,14 +67,11 @@ export function handleServerRequest(args) {
67
67
  return true;
68
68
  }
69
69
  if (method === 'item/permissions/requestApproval') {
70
- register(callId, { requestId, kind: 'permissions', payload: p });
71
- emit({
72
- kind: 'session:user-input-requested',
73
- requestKind: 'permission',
74
- toolCallId: callId,
75
- toolName: 'Permissions',
76
- payload: p,
77
- });
70
+ // Kōbō doesn't model granular permission grants: the only answer we can
71
+ // send is the empty grant (Codex then falls back to the turn policy), so
72
+ // an Allow/Deny card would be a lie — "Allow" and "Deny" produced the
73
+ // same decline. Answer immediately and keep the session moving.
74
+ respond?.(requestId, { permissions: {}, scope: 'turn' });
78
75
  return true;
79
76
  }
80
77
  return false; // unknown method
@@ -87,12 +84,6 @@ export function handleServerRequest(args) {
87
84
  * (and the matching `FileChangeApprovalDecision`): `'accept' | 'acceptForSession' | 'decline' | 'cancel'`.
88
85
  * NOT `'approve' / 'reject'` — those would be silently rejected as unknown
89
86
  * variants, which breaks the strict and interactive permission modes.
90
- *
91
- * `PermissionsRequestApprovalResponse` has a completely different shape:
92
- * `{ permissions, scope, strictAutoReview? }` — no `decision` field. Since
93
- * Kōbō doesn't yet model permission grants, we deny the request by sending
94
- * an empty permissions response. A future iteration could add a UI for
95
- * granular permission grants.
96
87
  */
97
88
  export function buildResponseForResolve(pending, response) {
98
89
  if (pending.kind === 'command' || pending.kind === 'file_change') {
@@ -100,12 +91,6 @@ export function buildResponseForResolve(pending, response) {
100
91
  return { decision: 'accept' };
101
92
  return { decision: 'decline' };
102
93
  }
103
- if (pending.kind === 'permissions') {
104
- // Codex's PermissionsRequestApprovalResponse shape — not { decision }.
105
- // We don't yet model granular permission grants, so deny by returning an
106
- // empty permissions object; Codex falls back to the existing turn policy.
107
- return { permissions: {}, scope: 'turn' };
108
- }
109
94
  if (pending.kind === 'user_input') {
110
95
  if (response.kind === 'question') {
111
96
  const answers = {};
@@ -957,7 +957,16 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
957
957
  // `reason` is authoritative (with the SDK engine `exitCode` is often null,
958
958
  // so reason='error'+exitCode=null would otherwise map wrongly to 'completed').
959
959
  // `resumeFailed` is benign: stale id cleared, next iteration starts fresh.
960
- const isErrorOutcome = !resumeFailed && (reason === 'error' || (exitCode !== null && exitCode !== 0));
960
+ // 'watchdog' is a forced kill, never a success. For an auto-loop workspace,
961
+ // handleTransientAutoLoopFailure (called above, before onSessionEnded) runs
962
+ // synchronously up to its first await and writes status 'quota' as its very
963
+ // first statement — that write lands before `currentWorkspace` is read above
964
+ // (line ~1144), so `preserveQuotaBackoff` is already true and this function
965
+ // returns early at the `if (preserveQuotaBackoff) return true` branch above.
966
+ // This computation is therefore never reached at all for that path, not
967
+ // merely short-circuited afterwards — it only runs on session ends that
968
+ // don't go through the auto-loop transient-failure path.
969
+ const isErrorOutcome = !resumeFailed && (reason === 'error' || reason === 'watchdog' || (exitCode !== null && exitCode !== 0));
961
970
  const targetStatus = isErrorOutcome ? 'error' : 'completed';
962
971
  // Skip the transition when the workspace is already in a terminal state.
963
972
  // This happens when stopAgent (or an equivalent caller) synchronously
@@ -1801,6 +1810,10 @@ async function handleQuota(workspaceId, _agentSessionId) {
1801
1810
  // 'agent:quota-backoff' WS emit. Hand off everything to it.
1802
1811
  quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
1803
1812
  }
1813
+ /** First retry after a transient failure (watchdog recovery, HTTP 500). The
1814
+ * quota ladder's 15-minute floor is tuned for real quota windows — a forced
1815
+ * kill or a server blip deserves a much faster first attempt. */
1816
+ const TRANSIENT_FIRST_RETRY_MS = 2 * 60_000;
1804
1817
  /**
1805
1818
  * Use the same persisted retry path for temporary upstream failures (HTTP 500
1806
1819
  * and overloads) while deliberately ignoring quota-reset data: a server error
@@ -1826,8 +1839,9 @@ async function handleTransientAutoLoopFailure(workspaceId) {
1826
1839
  return;
1827
1840
  }
1828
1841
  const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
1842
+ const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
1829
1843
  retryCounts.set(workspaceId, retryCount + 1);
1830
- quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
1844
+ quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
1831
1845
  }
1832
1846
  /** @internal test-only — re-export of `handleQuota` for direct testing. */
1833
1847
  export const _handleQuota = handleQuota;
@@ -19,6 +19,7 @@ export function createStreamingBatcher(emit, options = {}) {
19
19
  return;
20
20
  }
21
21
  const timer = setTimeout(() => flush(event.messageId), windowMs);
22
+ timer.unref?.();
22
23
  pending.set(event.messageId, { text: event.text, timer });
23
24
  return;
24
25
  }
@@ -11,6 +11,7 @@ export function createTurnLiveness(input) {
11
11
  let active = false;
12
12
  let paused = false;
13
13
  let timedOut = false;
14
+ let currentTimeoutMs = input.timeoutMs;
14
15
  const arm = () => {
15
16
  if (!active || paused || timedOut)
16
17
  return;
@@ -22,7 +23,8 @@ export function createTurnLiveness(input) {
22
23
  return;
23
24
  timedOut = true;
24
25
  input.onTimeout();
25
- }, input.timeoutMs);
26
+ }, currentTimeoutMs);
27
+ timer.unref?.();
26
28
  };
27
29
  return {
28
30
  start() {
@@ -53,5 +55,14 @@ export function createTurnLiveness(input) {
53
55
  clearTimeout(timer);
54
56
  timer = undefined;
55
57
  },
58
+ setTimeoutMs(ms) {
59
+ if (ms === currentTimeoutMs)
60
+ return;
61
+ currentTimeoutMs = ms;
62
+ // Re-arm only if a deadline is currently pending; a paused/stopped
63
+ // liveness picks the new duration up on its next arm().
64
+ if (timer)
65
+ arm();
66
+ },
56
67
  };
57
68
  }
@@ -1,7 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { realpathSync } from 'node:fs';
3
- import * as path from 'node:path';
4
2
  import { getFileContent, writeFileInWorktree } from '../utils/git-ops.js';
3
+ import { resolvePathInside } from '../utils/safe-path.js';
5
4
  const MAX_FILE_BYTES = 1024 * 1024;
6
5
  export function shaOf(content) {
7
6
  return createHash('sha256').update(content, 'utf8').digest('hex');
@@ -12,7 +11,7 @@ export function shaOf(content) {
12
11
  * escapes the worktree (including via symlinks), or when content exceeds 1 MB.
13
12
  */
14
13
  export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha) {
15
- const absPath = resolveSafe(worktreePath, relativePath);
14
+ const absPath = resolvePathInside(worktreePath, relativePath);
16
15
  if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
17
16
  throw new Error(`File too large (max ${MAX_FILE_BYTES / 1024 / 1024} MB)`);
18
17
  }
@@ -24,46 +23,3 @@ export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha)
24
23
  writeFileInWorktree(absPath, content);
25
24
  return { status: 'saved' };
26
25
  }
27
- function resolveSafe(worktreePath, relativePath) {
28
- const abs = path.resolve(worktreePath, relativePath);
29
- const lexicalRoot = path.resolve(worktreePath);
30
- // Lexical containment check FIRST: a path that escapes the worktree via `..`
31
- // (or an absolute path) must be reported as an escape deterministically —
32
- // independent of whether the traversal happens to land on a directory that
33
- // exists on disk. Without this, `../../etc/passwd` surfaces as "parent
34
- // directory does not exist" instead of "escapes the worktree" whenever the
35
- // worktree sits deep enough that the `..` chain lands on a missing dir.
36
- if (abs !== lexicalRoot && !abs.startsWith(lexicalRoot + path.sep)) {
37
- throw new Error(`Path '${relativePath}' escapes the worktree`);
38
- }
39
- const root = realpathSync(lexicalRoot);
40
- const rootWithSep = root + path.sep;
41
- // Resolve the parent's realpath (parent must exist; if it doesn't, the path
42
- // is invalid anyway and we surface that). Then join the lexical basename so
43
- // a non-existent leaf doesn't trigger ENOENT.
44
- let realParent;
45
- try {
46
- realParent = realpathSync(path.dirname(abs));
47
- }
48
- catch {
49
- throw new Error(`Path '${relativePath}' is invalid (parent directory does not exist)`);
50
- }
51
- const realAbs = path.join(realParent, path.basename(abs));
52
- if (realAbs !== root && !realAbs.startsWith(rootWithSep)) {
53
- throw new Error(`Path '${relativePath}' escapes the worktree`);
54
- }
55
- // If the leaf exists and is itself a symlink, follow it and re-check
56
- // containment so we never write through a symlink that escapes the worktree.
57
- try {
58
- const leafReal = realpathSync(realAbs);
59
- if (leafReal !== root && !leafReal.startsWith(rootWithSep)) {
60
- throw new Error(`Path '${relativePath}' escapes the worktree`);
61
- }
62
- return leafReal;
63
- }
64
- catch (err) {
65
- if (err.code === 'ENOENT')
66
- return realAbs;
67
- throw err;
68
- }
69
- }
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { getGlobalSettings } from '../../settings-service.js';
4
+ import { parseCliJson } from '../parse-cli-json.js';
4
5
  import { deriveReadyToMerge } from '../types.js';
5
6
  const execFileAsync = promisify(execFile);
6
7
  function record(value) {
@@ -233,14 +234,14 @@ async function resolveTarget(repoPath) {
233
234
  if (bktEnv().BKT_TOKEN)
234
235
  return { ...target, context: null };
235
236
  const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], bktOptions(repoPath));
236
- const contexts = record(JSON.parse(contextOutput)).contexts;
237
+ const contexts = record(parseCliJson(contextOutput, 'bkt context list')).contexts;
237
238
  const existing = (Array.isArray(contexts) ? contexts : [])
238
239
  .map(record)
239
240
  .find((context) => string(context.workspace) === target.workspace && string(context.default_repo) === target.repo);
240
241
  if (existing && string(existing.name))
241
242
  return { ...target, context: string(existing.name) };
242
243
  const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
243
- const hosts = record(JSON.parse(authOutput)).hosts;
244
+ const hosts = record(parseCliJson(authOutput, 'bkt auth status')).hosts;
244
245
  const host = (Array.isArray(hosts) ? hosts : []).map(record).find((item) => string(item.kind) === 'cloud');
245
246
  const hostKey = string(host?.key);
246
247
  if (!hostKey)
@@ -255,7 +256,7 @@ function targetArgs(target) {
255
256
  async function fetchCi(repoPath, number, target) {
256
257
  try {
257
258
  const { stdout } = await execFileAsync('bkt', bktArgs(target, ['pr', 'checks', String(number), ...targetArgs(target), '--json']), bktOptions(repoPath));
258
- const statuses = record(JSON.parse(stdout)).statuses;
259
+ const statuses = record(parseCliJson(stdout, 'bkt pr checks')).statuses;
259
260
  const checks = (Array.isArray(statuses) ? statuses : []).map(mapCheck);
260
261
  const rollup = checks.length === 0
261
262
  ? null
@@ -286,7 +287,7 @@ export const bitbucketProvider = {
286
287
  async isAvailable(repoPath) {
287
288
  try {
288
289
  const { stdout } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
289
- const status = record(JSON.parse(stdout));
290
+ const status = record(parseCliJson(stdout, 'bkt auth status'));
290
291
  const hosts = status.hosts;
291
292
  if ((Array.isArray(hosts) && hosts.length > 0) || bktEnv().BKT_TOKEN)
292
293
  return { available: true };
@@ -304,7 +305,7 @@ export const bitbucketProvider = {
304
305
  // branch deletion, so do not stop at OPEN requests.
305
306
  for (const state of ['OPEN', 'MERGED', 'DECLINED']) {
306
307
  const { stdout: listOutput } = await execFileAsync('bkt', bktArgs(target, ['pr', 'list', '--state', state, '--limit', '50', ...targetArgs(target), '--json']), bktOptions(repoPath));
307
- const pullRequests = record(JSON.parse(listOutput)).pull_requests;
308
+ const pullRequests = record(parseCliJson(listOutput, 'bkt pr list')).pull_requests;
308
309
  match = (Array.isArray(pullRequests) ? pullRequests : [])
309
310
  .map(record)
310
311
  .find((pullRequest) => branch(pullRequest, 'source', 'fromRef') === branchName);
@@ -314,7 +315,7 @@ export const bitbucketProvider = {
314
315
  if (!match)
315
316
  return null;
316
317
  const { stdout: viewOutput } = await execFileAsync('bkt', bktArgs(target, ['pr', 'view', String(number(match.id)), ...targetArgs(target), '--json']), bktOptions(repoPath));
317
- const raw = record(record(JSON.parse(viewOutput)).pull_request);
318
+ const raw = record(record(parseCliJson(viewOutput, 'bkt pr view')).pull_request);
318
319
  if (!number(raw.id))
319
320
  return null;
320
321
  const ci = await fetchCi(repoPath, number(raw.id), target);
@@ -340,7 +341,7 @@ export const bitbucketProvider = {
340
341
  ...targetArgs(target),
341
342
  '--json',
342
343
  ]), bktOptions(repoPath));
343
- const raw = record(JSON.parse(stdout));
344
+ const raw = record(parseCliJson(stdout, 'bkt pr create'));
344
345
  const prNumber = number(raw.id);
345
346
  const prUrl = string(raw.url);
346
347
  if (!prNumber || !prUrl)
@@ -363,7 +364,7 @@ export const bitbucketProvider = {
363
364
  if (opts.cursor)
364
365
  args.push('--page', opts.cursor);
365
366
  const { stdout } = await execFileAsync('bkt', bktArgs(target, args), bktOptions(repoPath));
366
- const page = mapBitbucketPage(JSON.parse(stdout || '{}'));
367
+ const page = mapBitbucketPage(parseCliJson(stdout || '{}', 'bkt pr list'));
367
368
  if (opts.filter === 'all' && !opts.search?.trim())
368
369
  return page;
369
370
  const currentUser = getGlobalSettings().bitbucketUsername || undefined;
@@ -1,6 +1,7 @@
1
1
  // src/server/services/forge/github/provider.ts
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
+ import { parseCliJson } from '../parse-cli-json.js';
4
5
  import { deriveReadyToMerge } from '../types.js';
5
6
  const execFileAsync = promisify(execFile);
6
7
  const CLI_TIMEOUT_MS = 30_000;
@@ -174,7 +175,7 @@ export const githubProvider = {
174
175
  const raw = stdout.trim();
175
176
  if (!raw)
176
177
  return null;
177
- return mapGhPrToSnapshot(JSON.parse(raw));
178
+ return mapGhPrToSnapshot(parseCliJson(raw, 'gh pr view'));
178
179
  }
179
180
  catch {
180
181
  return null;
@@ -198,7 +199,7 @@ export const githubProvider = {
198
199
  },
199
200
  async listPullRequests(repoPath, opts) {
200
201
  const { stdout: repoJson } = await execFileAsync('gh', ['repo', 'view', '--json', 'nameWithOwner'], cliOptions(repoPath));
201
- const nameWithOwner = String(JSON.parse(repoJson).nameWithOwner ?? '');
202
+ const nameWithOwner = String(parseCliJson(repoJson, 'gh repo view').nameWithOwner ?? '');
202
203
  if (!nameWithOwner)
203
204
  throw new Error('Could not determine the GitHub repository for this path');
204
205
  const args = [
@@ -214,6 +215,6 @@ export const githubProvider = {
214
215
  if (opts.cursor)
215
216
  args.push('-f', `after=${opts.cursor}`);
216
217
  const { stdout } = await execFileAsync('gh', args, cliOptions(repoPath));
217
- return mapGithubSearchPage(JSON.parse(stdout));
218
+ return mapGithubSearchPage(parseCliJson(stdout, 'gh api graphql (list PRs)'));
218
219
  },
219
220
  };
@@ -1,6 +1,7 @@
1
1
  // src/server/services/forge/gitlab/provider.ts
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
+ import { parseCliJson } from '../parse-cli-json.js';
4
5
  import { deriveReadyToMerge } from '../types.js';
5
6
  const execFileAsync = promisify(execFile);
6
7
  const CLI_TIMEOUT_MS = 30_000;
@@ -108,7 +109,7 @@ async function fetchGlabCi(repoPath, branch) {
108
109
  const raw = stdout.trim();
109
110
  if (!raw)
110
111
  return { rollup: null, checks: [] };
111
- const pipeline = JSON.parse(raw);
112
+ const pipeline = parseCliJson(raw, 'glab ci get');
112
113
  return {
113
114
  rollup: mapPipelineRollup(pipeline.status),
114
115
  checks: (pipeline.jobs ?? []).map(mapGlabJobToCheck),
@@ -192,7 +193,7 @@ export const gitlabProvider = {
192
193
  const raw = stdout.trim();
193
194
  if (!raw)
194
195
  return null;
195
- const snapshot = mapGlabMrToSnapshot(JSON.parse(raw));
196
+ const snapshot = mapGlabMrToSnapshot(parseCliJson(raw, 'glab mr view'));
196
197
  snapshot.ci = await fetchGlabCi(repoPath, branch);
197
198
  snapshot.readyToMerge = deriveReadyToMerge(snapshot);
198
199
  return snapshot;
@@ -231,6 +232,6 @@ export const gitlabProvider = {
231
232
  },
232
233
  async listPullRequests(repoPath, opts) {
233
234
  const { stdout } = await execFileAsync('glab', buildGitlabListArgs(opts), cliOptions(repoPath));
234
- return mapGitlabPage(JSON.parse(stdout || '[]'), opts);
235
+ return mapGitlabPage(parseCliJson(stdout || '[]', 'glab mr list'), opts);
235
236
  },
236
237
  };
@@ -0,0 +1,14 @@
1
+ /** JSON.parse with a message a user can act on: a truncated or non-JSON CLI
2
+ * response otherwise surfaces as a raw "Unexpected token…" 500. */
3
+ export function parseCliJson(raw, context) {
4
+ try {
5
+ return JSON.parse(raw);
6
+ }
7
+ catch (err) {
8
+ // Keep the diagnostic trail: the user-facing message stays terse, but the
9
+ // original parse error and a sample of the offending output land in the
10
+ // server log so "unparsable output" reports can actually be investigated.
11
+ console.error(`[forge] ${context} returned unparsable output:`, err, raw.slice(0, 200));
12
+ throw new Error(`${context} returned unparsable output`);
13
+ }
14
+ }