@loicngr/kobo 1.11.35 → 1.11.37

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 (102) hide show
  1. package/AGENTS.md +15 -0
  2. package/CHANGELOG.md +9 -0
  3. package/dist/server/index.js +11 -24
  4. package/dist/server/routes/dev-server.js +2 -1
  5. package/dist/server/routes/pull-requests.js +5 -4
  6. package/dist/server/routes/workspaces.js +122 -91
  7. package/dist/server/services/agent/engines/claude-code/engine.js +26 -0
  8. package/dist/server/services/agent/engines/claude-code/event-mapper.js +10 -10
  9. package/dist/server/services/agent/engines/codex/client.js +1 -0
  10. package/dist/server/services/agent/engines/codex/engine.js +47 -10
  11. package/dist/server/services/agent/engines/codex/event-mapper.js +5 -1
  12. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
  13. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
  14. package/dist/server/services/agent/orchestrator.js +175 -68
  15. package/dist/server/services/agent/session-controller.js +12 -2
  16. package/dist/server/services/auto-loop-service.js +71 -6
  17. package/dist/server/services/change-source-branch-service.js +36 -4
  18. package/dist/server/services/cron-service.js +19 -0
  19. package/dist/server/services/db-backup-service.js +30 -0
  20. package/dist/server/services/dev-server-service.js +167 -38
  21. package/dist/server/services/image-service.js +25 -28
  22. package/dist/server/services/pr-checkout-service.js +34 -5
  23. package/dist/server/services/quota-backoff-service.js +25 -8
  24. package/dist/server/services/wakeup-service.js +37 -3
  25. package/dist/server/services/worktree-purge-service.js +3 -7
  26. package/dist/server/utils/agent-stop-result.js +12 -0
  27. package/dist/server/utils/git-ops.js +8 -1
  28. package/dist/server/utils/git-worktree-fingerprint.js +86 -0
  29. package/dist/server/utils/safe-path.js +21 -0
  30. package/dist/server/utils/script-runner.js +32 -9
  31. package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
  32. package/package.json +1 -1
  33. package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-BDyFBoxd.css} +1 -1
  34. package/src/client/dist/pwa/assets/ActivityFeed-C_-hxIo8.js +8 -0
  35. package/src/client/dist/pwa/assets/{ChangelogPage-DVLWWvvz.js → ChangelogPage-CBlGJ4zT.js} +1 -1
  36. package/src/client/dist/pwa/assets/{ComparisonPanel-LZrvk6a6.js → ComparisonPanel-DZAE2X9j.js} +1 -1
  37. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
  38. package/src/client/dist/pwa/assets/CreatePage-ArmtcF4d.js +2 -0
  39. package/src/client/dist/pwa/assets/{DashboardPage-0odIhseb.js → DashboardPage-eQTCXcdz.js} +1 -1
  40. package/src/client/dist/pwa/assets/{DiffViewer-NWB2uQkz.js → DiffViewer-CaT7vuSr.js} +3 -3
  41. package/src/client/dist/pwa/assets/{DocumentsPanel-3yApHgbl.js → DocumentsPanel-A0isCTaI.js} +1 -1
  42. package/src/client/dist/pwa/assets/{DrawerToggleButton-C_bNtZsE.js → DrawerToggleButton-T70fh8dp.js} +1 -1
  43. package/src/client/dist/pwa/assets/{GitPanel-BMjWwiAX.js → GitPanel-CAvk0G7h.js} +3 -3
  44. package/src/client/dist/pwa/assets/{HealthPage-Vyr1YG2o.js → HealthPage-D8Mr0V6w.js} +1 -1
  45. package/src/client/dist/pwa/assets/{MainLayout-s8_9FPE2.js → MainLayout-x6Es4Da-.js} +2 -2
  46. package/src/client/dist/pwa/assets/{QBtnDropdown-BxuXEBp5.js → QBtnDropdown-CQqzzlFI.js} +1 -1
  47. package/src/client/dist/pwa/assets/{QDrawer-BdlDiU6a.js → QDrawer-Cwj2qh0r.js} +1 -1
  48. package/src/client/dist/pwa/assets/{QSelect-BrSemS3o.js → QSelect-BSPtuXJg.js} +1 -1
  49. package/src/client/dist/pwa/assets/{QTooltip-CLCFHW2D.js → QTooltip-DC_kDKku.js} +1 -1
  50. package/src/client/dist/pwa/assets/{SchedulePanel-F9bdAq6K.js → SchedulePanel-9aJSoz6Y.js} +1 -1
  51. package/src/client/dist/pwa/assets/{SearchPage-CBCefYqc.js → SearchPage-BjNvtE_U.js} +1 -1
  52. package/src/client/dist/pwa/assets/{SettingsPage-BPgTHmKj.js → SettingsPage-CvLnT73V.js} +1 -1
  53. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DDs2nuoT.js → SplitWorkspacePage-DTao0DHr.js} +1 -1
  54. package/src/client/dist/pwa/assets/{TerminalPanel-CulE0wTU.js → TerminalPanel-BrKIW-bJ.js} +1 -1
  55. package/src/client/dist/pwa/assets/{TouchPan-DUgqs2cW.js → TouchPan-_n1AQgoo.js} +1 -1
  56. package/src/client/dist/pwa/assets/{TourReplayButton-CdHpGBdr.js → TourReplayButton-CJF3nVvn.js} +1 -1
  57. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-W5SvqzXO.js → WorkspaceAttentionLabels-CxesUiIm.js} +1 -1
  58. package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-DoXtwpjV.css} +1 -1
  59. package/src/client/dist/pwa/assets/WorkspacePage-MxBE0mr3.js +4 -0
  60. package/src/client/dist/pwa/assets/{browser-CnuqDTyc.js → browser-DGWpkvN3.js} +1 -1
  61. package/src/client/dist/pwa/assets/{build-path-tree-JodIO7-I.js → build-path-tree-Ba1dSLgi.js} +1 -1
  62. package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-DVK21RB6.js} +1 -1
  63. package/src/client/dist/pwa/assets/{documents-3ZBK1pXE.js → documents-BMHzjW_g.js} +1 -1
  64. package/src/client/dist/pwa/assets/{editor.api-DZ9WO_J7.js → editor.api-CYTuNwqu.js} +1 -1
  65. package/src/client/dist/pwa/assets/{efforts-LrtJPx-j.js → efforts-BPlRSHXp.js} +1 -1
  66. package/src/client/dist/pwa/assets/{engineFeatures-DmnHj_uD.js → engineFeatures-DVB2rklO.js} +1 -1
  67. package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-C8cQwDZM.js} +1 -1
  68. package/src/client/dist/pwa/assets/{expand-template-Dv4JC2zU.js → expand-template-Cj82YE-O.js} +1 -1
  69. package/src/client/dist/pwa/assets/{format-CHjfjuhr.js → format-Dk7O3LhA.js} +1 -1
  70. package/src/client/dist/pwa/assets/{formatters-BsgH8CoH.js → formatters-TGjvs7Ca.js} +1 -1
  71. package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-BVOMZ2Bl.js} +1 -1
  72. package/src/client/dist/pwa/assets/{freemarker2-Dla55SIR.js → freemarker2-39lO_dWH.js} +1 -1
  73. package/src/client/dist/pwa/assets/{handlebars-DtuuZy7D.js → handlebars-M_0V82cD.js} +1 -1
  74. package/src/client/dist/pwa/assets/{html-CKYOCR8W.js → html-yILqYDsl.js} +1 -1
  75. package/src/client/dist/pwa/assets/i18n-2UeMfd5m.js +1 -0
  76. package/src/client/dist/pwa/assets/{index-CIP08MpU.js → index-BJU5dnkF.js} +2 -2
  77. package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-CxkAoOj4.js} +1 -1
  78. package/src/client/dist/pwa/assets/{javascript-BxzTnsIQ.js → javascript-DrW-amDc.js} +1 -1
  79. package/src/client/dist/pwa/assets/{liquid-C8BfPFUy.js → liquid-D_mXmUoL.js} +1 -1
  80. package/src/client/dist/pwa/assets/{mdx-DZm_YXkC.js → mdx-E8O3lvR5.js} +1 -1
  81. package/src/client/dist/pwa/assets/{monaco-contributions-HDbRHiNR.js → monaco-contributions-C2K3sl_V.js} +1 -1
  82. package/src/client/dist/pwa/assets/{monaco.contribution-DRW-851R.js → monaco.contribution-DLhHQIJn.js} +2 -2
  83. package/src/client/dist/pwa/assets/{network-auth-Ce9hsryo.js → network-auth-BUwWtnui.js} +1 -1
  84. package/src/client/dist/pwa/assets/{notify-theme-OBpMEBvP.js → notify-theme-1mOpY2vN.js} +1 -1
  85. package/src/client/dist/pwa/assets/{pending-diff-open-CVQxWyzP.js → pending-diff-open-BL7dKRqF.js} +1 -1
  86. package/src/client/dist/pwa/assets/{permissionModes-CVFHS2ec.js → permissionModes-BtC3_P-P.js} +1 -1
  87. package/src/client/dist/pwa/assets/{project-color-mcpQp2o6.js → project-color-BFBFZWeG.js} +1 -1
  88. package/src/client/dist/pwa/assets/{python-CO5iQN74.js → python-DoZAYim6.js} +1 -1
  89. package/src/client/dist/pwa/assets/{razor-BC7D8LMf.js → razor-9bZ30g2y.js} +1 -1
  90. package/src/client/dist/pwa/assets/{render-chat-markdown-Cb15kvox.js → render-chat-markdown-7EtWNjG0.js} +1 -1
  91. package/src/client/dist/pwa/assets/{typescript-BCHCSNpD.js → typescript-C1CBfL3L.js} +1 -1
  92. package/src/client/dist/pwa/assets/{use-tours-DhLhglwr.js → use-tours-BJ81clv6.js} +1 -1
  93. package/src/client/dist/pwa/assets/{workspace-templates-CoWrOEac.js → workspace-templates-r1-bipjM.js} +1 -1
  94. package/src/client/dist/pwa/assets/{xml-y9M3NAIZ.js → xml-COuu5isK.js} +1 -1
  95. package/src/client/dist/pwa/assets/{yaml-CthlToPo.js → yaml-ChZ7WqaJ.js} +1 -1
  96. package/src/client/dist/pwa/index.html +1 -1
  97. package/src/client/dist/pwa/sw.js +1 -1
  98. package/src/client/dist/pwa/assets/ActivityFeed-YRbIRutK.js +0 -8
  99. package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
  100. package/src/client/dist/pwa/assets/CreatePage-gFrn4xl9.js +0 -2
  101. package/src/client/dist/pwa/assets/WorkspacePage-B6O7j57u.js +0 -4
  102. package/src/client/dist/pwa/assets/i18n-DTfKQUMf.js +0 -1
@@ -255,6 +255,7 @@ export function createClaudeCodeEngine() {
255
255
  let iteratorRunning = false;
256
256
  let userInterrupted = false;
257
257
  let completedResponses = 0;
258
+ let waitingForBackground = false;
258
259
  let turnCompletedEmittedForResponse = 0;
259
260
  // `result` is the Claude Agent SDK's per-turn completion signal. Keep
260
261
  // it distinct from iterator completion: the SDK may still drain
@@ -433,6 +434,11 @@ export function createClaudeCodeEngine() {
433
434
  turnLiveness.start();
434
435
  try {
435
436
  for await (const msg of q) {
437
+ // A parent continuation owns the foreground again. Background
438
+ // progress notifications alone do not end the between-turn wait.
439
+ if (msg.type === 'assistant' || msg.type === 'user' || msg.type === 'stream_event') {
440
+ waitingForBackground = false;
441
+ }
436
442
  // This SDK message proves that any drain condition armed by a
437
443
  // *previous* message is no longer current. A drain armed while
438
444
  // processing this message remains active if the generator then
@@ -495,6 +501,7 @@ export function createClaudeCodeEngine() {
495
501
  turnLiveness.setTimeoutMs(pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS);
496
502
  reevaluateLivenessPause();
497
503
  if (msg.type === 'result') {
504
+ waitingForBackground = false;
498
505
  pendingToolCallIds.clear();
499
506
  completedResponses++;
500
507
  emitTurnCompletedIfSettled();
@@ -506,6 +513,7 @@ export function createClaudeCodeEngine() {
506
513
  armResultDrainWatchdog();
507
514
  }
508
515
  else {
516
+ waitingForBackground = true;
509
517
  armSubagentStallWatchdog();
510
518
  }
511
519
  }
@@ -583,6 +591,24 @@ export function createClaudeCodeEngine() {
583
591
  throw new Error(`Claude ${AGENT_NO_LONGER_RUNNING_TEXT}`);
584
592
  inputStream.send(text);
585
593
  },
594
+ sendWakeupIfWaiting(text) {
595
+ if (!iteratorRunning ||
596
+ abortController.signal.aborted ||
597
+ mapperState.sawErrorResult ||
598
+ !waitingForBackground ||
599
+ activeSubagentTaskIds.size === 0 ||
600
+ pendingToolCallIds.size > 0 ||
601
+ pendingResolvers.size > 0 ||
602
+ isCompacting ||
603
+ inputStream.hasUnansweredInput(completedResponses))
604
+ return false;
605
+ inputStream.send(text);
606
+ waitingForBackground = false;
607
+ // The wakeup starts another turn on this stream; its result will
608
+ // re-arm the stall deadline if background work is still outstanding.
609
+ clearSubagentStallWatchdog();
610
+ return true;
611
+ },
586
612
  interrupt() {
587
613
  userInterrupted = true;
588
614
  // The SDK ends an interrupted run by emitting a `result` with
@@ -63,20 +63,20 @@ function normalizeRateLimitInfo(info) {
63
63
  }
64
64
  // ── Public API ────────────────────────────────────────────────────────────────
65
65
  /**
66
- * Canonical "out of quota" surfaces from the Claude SDK and CLI. Centralised
67
- * so the three call-sites stay in sync:
68
- * - `result` events with an error subtype (`parsed.error` / `parsed.result`)
69
- * - assistant `message:text` blocks (the SDK occasionally streams the user-
70
- * visible quota notice as plain assistant text instead of a structured
71
- * error result; see workspace `-GyiAYM7X4xTWyZbcHGiR` session #25 for the
72
- * repro that motivated this path)
73
- * - CLI stderr in `engine.ts`
66
+ * Quota keywords for error results and CLI stderr. Assistant prose uses
67
+ * the narrower ASSISTANT_QUOTA_NOTICE below, because summaries may mention
68
+ * a previous quota failure while the current run is working normally.
74
69
  *
75
70
  * Patterns are kept loose on purpose to absorb minor wording drift between
76
71
  * Anthropic's surfaces (`rate_limit_exceeded`, `Claude AI usage limit
77
72
  * reached`, `You're out of extra usage`, `quota exceeded`).
78
73
  */
79
74
  export const QUOTA_PATTERN = /\b429\b|out of extra usage|rate[_ ]limit|usage limit|quota exceeded/i;
75
+ /** Only canonical, standalone notices qualify on the assistant prose surface.
76
+ * Broad quota keywords remain appropriate for structured errors, not summaries
77
+ * of a previous attempt, a sub-agent failure, or application HTTP handling.
78
+ */
79
+ const ASSISTANT_QUOTA_NOTICE = /^(?:you(?:'|’)re out of extra usage|you(?:'|’)ve hit your (?:session|weekly|usage) limit|claude ai usage limit reached|usage limit reached|quota exceeded|rate limit exceeded)(?:[.!]?(?:[ \t]*[·—-]?[ \t]*resets?\b[^\r\n]*)?)?[.!]?$/i;
80
80
  export function createMapperState() {
81
81
  return {
82
82
  sessionStartedEmitted: false,
@@ -274,8 +274,8 @@ export function mapSdkMessage(msg, state) {
274
274
  // Last-resort fallback: some SDK runs surface the quota notice as
275
275
  // plain assistant text without setting `assistant.error` or a
276
276
  // `result.error`. The structured signals above cover modern SDK
277
- // versions; this regex absorbs older or drifted wordings.
278
- if (QUOTA_PATTERN.test(text)) {
277
+ // versions; only a standalone notice qualifies on this prose surface.
278
+ if (ASSISTANT_QUOTA_NOTICE.test(text.trim())) {
279
279
  tryEmitQuotaError(state, events, text);
280
280
  }
281
281
  }
@@ -15,6 +15,7 @@ export function createAppServerClient(opts) {
15
15
  onNotification: opts.onNotification ?? (() => { }),
16
16
  onServerRequest: opts.onServerRequest ?? (() => { }),
17
17
  onError: opts.onError,
18
+ onDisconnect: opts.onDisconnect,
18
19
  });
19
20
  return {
20
21
  peer,
@@ -11,6 +11,7 @@ import { buildResponseForResolve, handleServerRequest } from './server-requests.
11
11
  import { spawnAppServer } from './spawn.js';
12
12
  /** Long enough for normal tool work, short enough to recover a lost turn event. */
13
13
  export const CODEX_TURN_IDLE_TIMEOUT_MS = 120_000;
14
+ export const CODEX_TOOL_IDLE_TIMEOUT_MS = 30 * 60_000;
14
15
  export const CODEX_GRACEFUL_INTERRUPT_TIMEOUT_MS = 3_000;
15
16
  /** Grace given to a SIGTERM before the child is killed outright. */
16
17
  const CODEX_FORCE_KILL_TIMEOUT_MS = 3_000;
@@ -24,6 +25,35 @@ export const CODEX_SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
24
25
  function wait(ms) {
25
26
  return new Promise((resolve) => setTimeout(resolve, ms));
26
27
  }
28
+ /** A sent signal is only a request; resolve true exclusively on confirmed exit. */
29
+ function signalAndWaitForExit(child, signal, timeoutMs) {
30
+ if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined)
31
+ return Promise.resolve(true);
32
+ return new Promise((resolve, reject) => {
33
+ const cleanup = () => {
34
+ clearTimeout(timer);
35
+ child.removeListener('exit', onExit);
36
+ };
37
+ const onExit = () => {
38
+ cleanup();
39
+ resolve(true);
40
+ };
41
+ const timer = setTimeout(() => {
42
+ cleanup();
43
+ resolve(false);
44
+ }, timeoutMs);
45
+ timer.unref?.();
46
+ // Install before signalling so an immediate exit cannot be missed.
47
+ child.once('exit', onExit);
48
+ try {
49
+ child.kill(signal);
50
+ }
51
+ catch (err) {
52
+ cleanup();
53
+ reject(err);
54
+ }
55
+ });
56
+ }
27
57
  class CodexTurnTimeoutError extends Error {
28
58
  constructor() {
29
59
  super('Codex stopped reporting activity for this turn');
@@ -77,7 +107,17 @@ export function createCodexEngine() {
77
107
  }
78
108
  };
79
109
  const streamingBatcher = createStreamingBatcher(emitDirect);
80
- const safeEmit = (ev) => streamingBatcher.push(ev);
110
+ const pendingToolCalls = new Set();
111
+ const safeEmit = (ev) => {
112
+ if (ev.kind === 'tool:call')
113
+ pendingToolCalls.add(ev.toolCallId);
114
+ if (ev.kind === 'tool:result')
115
+ pendingToolCalls.delete(ev.toolCallId);
116
+ if (ev.kind === 'tool:call' || ev.kind === 'tool:result') {
117
+ turnLiveness.setTimeoutMs(pendingToolCalls.size ? CODEX_TOOL_IDLE_TIMEOUT_MS : CODEX_TURN_IDLE_TIMEOUT_MS);
118
+ }
119
+ streamingBatcher.push(ev);
120
+ };
81
121
  let rejectChildFailure;
82
122
  const childFailurePromise = new Promise((_resolve, reject) => {
83
123
  rejectChildFailure = reject;
@@ -324,6 +364,7 @@ export function createCodexEngine() {
324
364
  autoApprove: (toolName, payload) => isWorkspacePermissionAllowed(options.workspaceId, { engine: 'codex', toolName, payload }),
325
365
  });
326
366
  },
367
+ onDisconnect: rejectChildFailure,
327
368
  onError(err) {
328
369
  console.error('[codex] JSON-RPC transport error:', err);
329
370
  rejectTurnDone(err);
@@ -428,6 +469,7 @@ export function createCodexEngine() {
428
469
  }
429
470
  }
430
471
  pendingByCallId.clear();
472
+ pendingToolCalls.clear();
431
473
  client.close();
432
474
  try {
433
475
  child.kill('SIGTERM');
@@ -515,15 +557,10 @@ export function createCodexEngine() {
515
557
  // stop, the archive, the delete and Kōbō's own shutdown, holding the
516
558
  // worktree and its share of the model quota, with nothing left in
517
559
  // this process tracking it.
518
- if (child.exitCode === null && child.signalCode === null) {
519
- child.kill('SIGTERM');
520
- const exited = await Promise.race([
521
- new Promise((resolve) => child.once('exit', () => resolve(true))),
522
- wait(CODEX_FORCE_KILL_TIMEOUT_MS).then(() => false),
523
- ]);
524
- if (!exited) {
525
- console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
526
- child.kill('SIGKILL');
560
+ if (!(await signalAndWaitForExit(child, 'SIGTERM', CODEX_FORCE_KILL_TIMEOUT_MS))) {
561
+ console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
562
+ if (!(await signalAndWaitForExit(child, 'SIGKILL', CODEX_FORCE_KILL_TIMEOUT_MS))) {
563
+ throw new Error('Codex app-server did not exit after SIGKILL');
527
564
  }
528
565
  }
529
566
  },
@@ -302,13 +302,17 @@ export function handleItemCompleted(item, state) {
302
302
  }
303
303
  if (item.type === 'plan') {
304
304
  // Codex's `plan` is a markdown blob, not a structured list — parse bullets.
305
+ const toolCallId = scopedId(state, item.id);
305
306
  events.push({
306
307
  kind: 'tool:call',
307
308
  messageId: '',
308
- toolCallId: scopedId(state, item.id),
309
+ toolCallId,
309
310
  name: 'TodoWrite',
310
311
  input: { todos: parseCodexPlanText(item.text) },
311
312
  });
313
+ // The plan has already completed. Close the synthetic update so both the
314
+ // tool card and the engine's idle watchdog see no outstanding operation.
315
+ events.push({ kind: 'tool:result', toolCallId, output: null, isError: false });
312
316
  return events;
313
317
  }
314
318
  if (item.type === 'error') {
@@ -19,6 +19,7 @@ export class JsonRpcTimeoutError extends Error {
19
19
  }
20
20
  export function createJsonRpcPeer(opts) {
21
21
  let nextId = 1;
22
+ let closedError;
22
23
  const pending = new Map();
23
24
  /** Remove a pending slot and clear its deadline in one place. */
24
25
  const takePending = (id) => {
@@ -30,10 +31,21 @@ export function createJsonRpcPeer(opts) {
30
31
  pending.delete(id);
31
32
  return slot;
32
33
  };
34
+ const rejectPending = (err) => {
35
+ if (closedError)
36
+ return;
37
+ closedError = err;
38
+ for (const id of pending.keys())
39
+ takePending(id)?.reject(err);
40
+ };
33
41
  const transport = createJsonRpcTransport({
34
42
  stdin: opts.stdin,
35
43
  stdout: opts.stdout,
36
44
  onError: opts.onError ?? (() => { }),
45
+ onDisconnect(err) {
46
+ rejectPending(err);
47
+ opts.onDisconnect?.(err);
48
+ },
37
49
  onMessage(msg) {
38
50
  // Response to one of our requests
39
51
  if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) {
@@ -61,6 +73,8 @@ export function createJsonRpcPeer(opts) {
61
73
  });
62
74
  return {
63
75
  request(method, params, timeoutMs) {
76
+ if (closedError)
77
+ return Promise.reject(closedError);
64
78
  const id = nextId++;
65
79
  const deadlineMs = timeoutMs ?? opts.defaultRequestTimeoutMs ?? DEFAULT_JSONRPC_REQUEST_TIMEOUT_MS;
66
80
  return new Promise((resolve, reject) => {
@@ -73,7 +87,12 @@ export function createJsonRpcPeer(opts) {
73
87
  slot.timer.unref?.();
74
88
  }
75
89
  pending.set(id, slot);
76
- transport.send({ jsonrpc: '2.0', id, method, params });
90
+ try {
91
+ transport.send({ jsonrpc: '2.0', id, method, params });
92
+ }
93
+ catch (err) {
94
+ takePending(id)?.reject(err instanceof Error ? err : new Error(String(err)));
95
+ }
77
96
  });
78
97
  },
79
98
  notify(method, params) {
@@ -86,14 +105,8 @@ export function createJsonRpcPeer(opts) {
86
105
  transport.send({ jsonrpc: '2.0', id, error: { code, message } });
87
106
  },
88
107
  close() {
108
+ rejectPending(new Error('peer closed'));
89
109
  transport.close();
90
- const err = new Error('peer closed');
91
- for (const slot of pending.values()) {
92
- if (slot.timer)
93
- clearTimeout(slot.timer);
94
- slot.reject(err);
95
- }
96
- pending.clear();
97
110
  },
98
111
  };
99
112
  }
@@ -1,5 +1,17 @@
1
1
  export function createJsonRpcTransport(opts) {
2
2
  let buffer = '';
3
+ let closed = false;
4
+ const fail = (err) => {
5
+ if (closed)
6
+ return;
7
+ closed = true;
8
+ opts.onDisconnect?.(err);
9
+ opts.onError(err);
10
+ };
11
+ // Keep listeners installed through teardown: late pipe errors must stay handled.
12
+ opts.stdin.on('error', fail);
13
+ opts.stdout.on('error', fail);
14
+ opts.stdout.on('end', () => fail(new Error('JSON-RPC transport closed')));
3
15
  opts.stdout.setEncoding?.('utf-8');
4
16
  opts.stdout.on('data', (chunk) => {
5
17
  buffer += chunk;
@@ -19,13 +31,28 @@ export function createJsonRpcTransport(opts) {
19
31
  }
20
32
  }
21
33
  });
22
- opts.stdout.on('error', opts.onError);
23
34
  return {
24
35
  send(msg) {
25
- opts.stdin.write(`${JSON.stringify(msg)}\n`);
36
+ if (closed)
37
+ throw new Error('JSON-RPC transport closed');
38
+ try {
39
+ opts.stdin.write(`${JSON.stringify(msg)}\n`);
40
+ }
41
+ catch (err) {
42
+ fail(err instanceof Error ? err : new Error(String(err)));
43
+ throw err;
44
+ }
26
45
  },
27
46
  close() {
28
- opts.stdin.end();
47
+ if (closed)
48
+ return;
49
+ closed = true;
50
+ try {
51
+ opts.stdin.end();
52
+ }
53
+ catch {
54
+ /* Already disconnected. */
55
+ }
29
56
  },
30
57
  };
31
58
  }