jeopi-cli 16.4.2 → 16.4.4

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 (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3033 -2991
  3. package/dist/types/advisor/advise-tool.d.ts +5 -1
  4. package/dist/types/advisor/config.d.ts +4 -3
  5. package/dist/types/advisor/runtime.d.ts +15 -0
  6. package/dist/types/cli/args.d.ts +2 -0
  7. package/dist/types/cli/update-cli.d.ts +4 -1
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/commit/agentic/agent.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +7 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +3 -13
  14. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  19. package/dist/types/internal-urls/types.d.ts +7 -0
  20. package/dist/types/irc/bus.d.ts +4 -0
  21. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  22. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  23. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  24. package/dist/types/modes/components/welcome.d.ts +4 -0
  25. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  26. package/dist/types/modes/orchestrate.d.ts +1 -1
  27. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  29. package/dist/types/modes/ultrathink.d.ts +1 -1
  30. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  31. package/dist/types/modes/workflow.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +6 -0
  33. package/dist/types/session/auth-broker-config.d.ts +2 -2
  34. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  35. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  36. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  37. package/dist/types/tools/browser/launch.d.ts +1 -0
  38. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  39. package/dist/types/tools/browser/run-output.d.ts +25 -0
  40. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  41. package/dist/types/utils/changelog.d.ts +35 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +13 -0
  44. package/src/advisor/__tests__/config.test.ts +36 -0
  45. package/src/advisor/advise-tool.ts +6 -1
  46. package/src/advisor/config.ts +11 -8
  47. package/src/advisor/runtime.ts +127 -0
  48. package/src/autolearn/controller.ts +15 -3
  49. package/src/cli/args.ts +12 -0
  50. package/src/cli/flag-tables.ts +21 -7
  51. package/src/cli/update-cli.ts +63 -13
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/commands/acp.ts +11 -2
  54. package/src/commands/launch.ts +12 -3
  55. package/src/commit/agentic/agent.ts +4 -0
  56. package/src/commit/agentic/index.ts +46 -21
  57. package/src/config/config-file.ts +30 -12
  58. package/src/config/model-registry.ts +7 -7
  59. package/src/config/model-resolver.ts +24 -6
  60. package/src/config/models-config.ts +1 -1
  61. package/src/config/settings-schema.ts +3 -15
  62. package/src/edit/index.ts +10 -76
  63. package/src/edit/renderer.ts +13 -10
  64. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  65. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  66. package/src/eval/executor-base.ts +84 -7
  67. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  68. package/src/eval/py/tool-bridge.ts +13 -14
  69. package/src/exec/non-interactive-env.ts +0 -1
  70. package/src/export/ttsr.ts +0 -3
  71. package/src/extensibility/custom-tools/loader.ts +70 -37
  72. package/src/extensibility/custom-tools/types.ts +3 -0
  73. package/src/extensibility/extensions/runner.ts +3 -0
  74. package/src/extensibility/extensions/types.ts +3 -0
  75. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  76. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  77. package/src/extensibility/plugins/manager.ts +21 -0
  78. package/src/internal-urls/docs-index.generated.txt +1 -1
  79. package/src/internal-urls/memory-protocol.ts +13 -8
  80. package/src/internal-urls/skill-protocol.ts +1 -1
  81. package/src/internal-urls/types.ts +7 -0
  82. package/src/irc/bus.ts +48 -18
  83. package/src/lsp/client.ts +7 -1
  84. package/src/lsp/deferred-diagnostics.ts +66 -0
  85. package/src/main.ts +13 -13
  86. package/src/mcp/tool-bridge.ts +57 -6
  87. package/src/mcp/transports/stdio.test.ts +45 -1
  88. package/src/mcp/transports/stdio.ts +6 -3
  89. package/src/modes/acp/acp-agent.ts +73 -2
  90. package/src/modes/acp/acp-event-mapper.ts +5 -0
  91. package/src/modes/acp/acp-mode.ts +11 -0
  92. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  93. package/src/modes/components/advisor-config.ts +15 -7
  94. package/src/modes/components/chat-transcript-builder.ts +31 -11
  95. package/src/modes/components/move-overlay.ts +2 -3
  96. package/src/modes/components/plan-review-overlay.ts +7 -0
  97. package/src/modes/components/tips.txt +1 -1
  98. package/src/modes/components/welcome.ts +13 -14
  99. package/src/modes/controllers/command-controller.ts +8 -10
  100. package/src/modes/controllers/event-controller.ts +58 -42
  101. package/src/modes/controllers/input-controller.ts +9 -6
  102. package/src/modes/controllers/selector-controller.ts +3 -2
  103. package/src/modes/controllers/tan-command-controller.ts +40 -1
  104. package/src/modes/interactive-mode.ts +13 -0
  105. package/src/modes/magic-keyword-boundary.ts +23 -0
  106. package/src/modes/orchestrate.ts +6 -5
  107. package/src/modes/print-mode.ts +9 -0
  108. package/src/modes/rpc/host-tools.ts +13 -0
  109. package/src/modes/rpc/rpc-mode.ts +130 -56
  110. package/src/modes/ultrathink.ts +6 -5
  111. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  112. package/src/modes/utils/ui-helpers.ts +16 -1
  113. package/src/modes/workflow.ts +5 -4
  114. package/src/prompts/system/eager-task.md +2 -2
  115. package/src/prompts/system/tan-context-switch.md +17 -0
  116. package/src/prompts/tools/browser.md +5 -5
  117. package/src/sdk.ts +3 -0
  118. package/src/session/agent-session.ts +267 -36
  119. package/src/session/auth-broker-config.ts +4 -4
  120. package/src/session/snapcompact-inline.ts +3 -19
  121. package/src/session/streaming-output.ts +40 -12
  122. package/src/slash-commands/builtin-registry.ts +8 -8
  123. package/src/task/executor.ts +8 -0
  124. package/src/task/render.ts +51 -44
  125. package/src/tools/bash-skill-urls.ts +7 -4
  126. package/src/tools/bash.ts +30 -33
  127. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  128. package/src/tools/browser/cmux/rpc.ts +50 -0
  129. package/src/tools/browser/launch.ts +8 -4
  130. package/src/tools/browser/run-cancellation.ts +44 -5
  131. package/src/tools/browser/run-output.ts +76 -0
  132. package/src/tools/browser/tab-worker.ts +280 -133
  133. package/src/tools/eval-render.ts +16 -14
  134. package/src/tools/glob.ts +20 -6
  135. package/src/tools/grep.ts +1 -0
  136. package/src/tools/image-gen.ts +3 -4
  137. package/src/tools/irc.ts +27 -13
  138. package/src/tools/path-utils.ts +6 -1
  139. package/src/tools/read.ts +14 -7
  140. package/src/tools/write.ts +22 -5
  141. package/src/tools/yield.ts +29 -1
  142. package/src/utils/changelog.ts +106 -9
  143. package/src/utils/git.ts +0 -1
  144. package/src/utils/title-generator.ts +72 -7
  145. package/src/web/search/index.ts +14 -6
  146. package/src/web/search/providers/perplexity.ts +18 -2
  147. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  148. package/src/tools/bash-command-fixup.ts +0 -37
@@ -1262,7 +1262,7 @@ export const SETTINGS_SCHEMA = {
1262
1262
  textVerbosity: {
1263
1263
  type: "enum",
1264
1264
  values: ["low", "medium", "high"] as const,
1265
- default: "high",
1265
+ default: "medium",
1266
1266
  ui: {
1267
1267
  tab: "model",
1268
1268
  group: "Sampling",
@@ -1270,8 +1270,8 @@ export const SETTINGS_SCHEMA = {
1270
1270
  description: "OpenAI Responses and Codex response verbosity (low, medium, or high)",
1271
1271
  options: [
1272
1272
  { value: "low", label: "Low", description: "Prefer concise responses" },
1273
- { value: "medium", label: "Medium", description: "Balance brevity and detail" },
1274
- { value: "high", label: "High", description: "Prefer detailed responses (default)" },
1273
+ { value: "medium", label: "Medium", description: "Balance brevity and detail (default)" },
1274
+ { value: "high", label: "High", description: "Prefer detailed responses" },
1275
1275
  ],
1276
1276
  },
1277
1277
  },
@@ -3189,18 +3189,6 @@ export const SETTINGS_SCHEMA = {
3189
3189
  },
3190
3190
  "bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES },
3191
3191
 
3192
- "bash.stripTrailingHeadTail": {
3193
- type: "boolean",
3194
- default: true,
3195
- ui: {
3196
- tab: "shell",
3197
- group: "Bash",
3198
- label: "Strip head/tail Pipes",
3199
- description:
3200
- "Silently drop trailing `| head`/`| tail` pipes from single-line bash commands. Output is already truncated automatically.",
3201
- },
3202
- },
3203
-
3204
3192
  // Shell output minimizer
3205
3193
  "shellMinimizer.enabled": {
3206
3194
  type: "boolean",
package/src/edit/index.ts CHANGED
@@ -4,19 +4,13 @@ import { MismatchError as HashlineMismatchError } from "jeopi-hashline";
4
4
  import hashlineGrammar from "jeopi-hashline/grammar.lark" with { type: "text" };
5
5
  import hashlineDescription from "jeopi-hashline/prompt.md" with { type: "text" };
6
6
  import { prompt } from "jeopi-utils";
7
- import {
8
- createLspWritethrough,
9
- type FileDiagnosticsResult,
10
- flushLspWritethroughBatch,
11
- type WritethroughCallback,
12
- type WritethroughDeferredHandle,
13
- writethroughNoop,
14
- } from "../lsp";
7
+ import { createLspWritethrough, flushLspWritethroughBatch, type WritethroughCallback, writethroughNoop } from "../lsp";
8
+ import { DeferredDiagnostics } from "../lsp/deferred-diagnostics";
15
9
  import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
16
10
  import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
17
11
  import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
18
12
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
19
- import type { DeferredDiagnosticsEntry, ToolSession } from "../tools";
13
+ import type { ToolSession } from "../tools";
20
14
  import { truncateForPrompt } from "../tools/approval";
21
15
  import { isInternalUrlPath } from "../tools/path-utils";
22
16
  import { withPathWriteLock } from "../tools/path-write-lock";
@@ -385,12 +379,7 @@ export class EditTool implements AgentTool<TInput> {
385
379
  readonly #fuzzyThreshold: number;
386
380
  readonly #writethrough: WritethroughCallback;
387
381
  readonly #editMode?: EditMode;
388
- readonly #dedupDiagnostics: boolean;
389
- readonly #pendingDeferredFetches = new Map<string, AbortController>();
390
- /** Fallback per-path mutation counter used only when the session does not expose
391
- * a shared one. Prefer `session.bumpFileMutationVersion` so write (and any other
392
- * tool) mutating the same file also invalidates pending late-diagnostics. */
393
- readonly #editVersionByPath = new Map<string, number>();
382
+ readonly #deferredDiagnostics: DeferredDiagnostics;
394
383
 
395
384
  constructor(private readonly session: ToolSession) {
396
385
  const {
@@ -402,10 +391,11 @@ export class EditTool implements AgentTool<TInput> {
402
391
  this.#editMode = resolveConfiguredEditMode(envEditVariant);
403
392
  this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
404
393
  this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
405
- this.#dedupDiagnostics =
394
+ const deduplicateDiagnostics =
406
395
  (session.enableLsp ?? true) &&
407
396
  session.settings.get("lsp.diagnosticsOnEdit") &&
408
397
  session.settings.get("lsp.diagnosticsDeduplicate");
398
+ this.#deferredDiagnostics = new DeferredDiagnostics(session, deduplicateDiagnostics);
409
399
  this.#writethrough = withPathWriteLock(createEditWritethrough(session));
410
400
  }
411
401
 
@@ -550,7 +540,7 @@ export class EditTool implements AgentTool<TInput> {
550
540
  // doubles as the documented full-file overwrite (patch.md <avoid>).
551
541
  allowCreateOverwrite: true,
552
542
  writethrough: tool.#writethrough,
553
- beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
543
+ beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
554
544
  }),
555
545
  );
556
546
  return executeSinglePathEntries(path, runs, batchRequest, onUpdate, tool.session.cwd, signal);
@@ -589,7 +579,7 @@ export class EditTool implements AgentTool<TInput> {
589
579
  allowFuzzy: tool.#allowFuzzy,
590
580
  fuzzyThreshold: tool.#fuzzyThreshold,
591
581
  writethrough: tool.#writethrough,
592
- beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
582
+ beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
593
583
  }),
594
584
  };
595
585
  });
@@ -613,7 +603,7 @@ export class EditTool implements AgentTool<TInput> {
613
603
  signal,
614
604
  batchRequest,
615
605
  writethrough: tool.#writethrough,
616
- beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
606
+ beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
617
607
  });
618
608
  },
619
609
  },
@@ -639,7 +629,7 @@ export class EditTool implements AgentTool<TInput> {
639
629
  allowFuzzy: tool.#allowFuzzy,
640
630
  fuzzyThreshold: tool.#fuzzyThreshold,
641
631
  writethrough: tool.#writethrough,
642
- beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
632
+ beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
643
633
  }),
644
634
  );
645
635
  return executeSinglePathEntries(path, runs, batchRequest, onUpdate, tool.session.cwd, signal);
@@ -647,60 +637,4 @@ export class EditTool implements AgentTool<TInput> {
647
637
  },
648
638
  }[this.mode];
649
639
  }
650
-
651
- #beginDeferredDiagnosticsForPath(path: string): WritethroughDeferredHandle {
652
- const existingDeferred = this.#pendingDeferredFetches.get(path);
653
- if (existingDeferred) {
654
- existingDeferred.abort();
655
- this.#pendingDeferredFetches.delete(path);
656
- }
657
-
658
- const deferredController = new AbortController();
659
- const editVersion = this.#bumpFileVersion(path);
660
- return {
661
- onDeferredDiagnostics: (lateDiagnostics: FileDiagnosticsResult) => {
662
- this.#pendingDeferredFetches.delete(path);
663
- this.#injectLateDiagnostics(path, lateDiagnostics, editVersion);
664
- },
665
- signal: deferredController.signal,
666
- finalize: (diagnostics: FileDiagnosticsResult | undefined) => {
667
- if (!diagnostics) {
668
- this.#pendingDeferredFetches.set(path, deferredController);
669
- } else {
670
- deferredController.abort();
671
- }
672
- },
673
- };
674
- }
675
-
676
- #injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult, editVersion: number): void {
677
- const effective = this.#dedupDiagnostics
678
- ? getDiagnosticsLedger(this.session).reduce(path, diagnostics)
679
- : diagnostics;
680
- if (this.#dedupDiagnostics && effective.messages.length === 0) return;
681
-
682
- const entry: DeferredDiagnosticsEntry = {
683
- path,
684
- summary: effective.summary ?? "",
685
- messages: effective.messages ?? [],
686
- errored: effective.errored,
687
- // Drop at flush time if a later edit to the same file superseded this fetch.
688
- isStale: () => this.#fileVersion(path) !== editVersion,
689
- };
690
- this.session.queueDeferredDiagnostics?.(entry);
691
- }
692
-
693
- /** Bump the file's mutation counter (session-global when available). */
694
- #bumpFileVersion(path: string): number {
695
- if (this.session.bumpFileMutationVersion) return this.session.bumpFileMutationVersion(path);
696
- const next = (this.#editVersionByPath.get(path) ?? 0) + 1;
697
- this.#editVersionByPath.set(path, next);
698
- return next;
699
- }
700
-
701
- /** Read the file's current mutation counter (session-global when available). */
702
- #fileVersion(path: string): number {
703
- if (this.session.getFileMutationVersion) return this.session.getFileMutationVersion(path);
704
- return this.#editVersionByPath.get(path) ?? 0;
705
- }
706
640
  }
@@ -419,22 +419,25 @@ function formatStreamingDiff(
419
419
  cache?: RenderedStringCache,
420
420
  ): string {
421
421
  if (!diff) return "";
422
- // Clamp the collapsed tail to the viewport so a tall or fast-growing diff
423
- // cannot outgrow the live window. Otherwise its mutating tail scrolls above
424
- // the native-scrollback commit boundary and the engine re-commits a fresh
425
- // snapshot every streamed frame, stacking duplicate "… more lines above"
426
- // previews in history. The budget is VISUAL rows (a long wrapped line counts
422
+ // Clamp the tail to the viewport so a tall or fast-growing diff cannot
423
+ // outgrow the live window. Otherwise its mutating rows scroll above the
424
+ // native-scrollback commit boundary mid-stream and freeze into immutable
425
+ // history as a stale preview snapshot; the finalize repair then recommits
426
+ // the final render below it — a duplicated block on the tape. Collapsed
427
+ // gets a short fixed tail; expanded widens it to the viewport-sized window,
428
+ // never unbounded. The budget is VISUAL rows (a long wrapped line counts
427
429
  // for more than one) at the framed block's inner width (border only —
428
- // contentPaddingLeft is 0); only the visible suffix is syntax-colored, so the
429
- // cheap raw-line wrap walk keeps the per-chunk cost bounded. innerWidth/budget
430
- // are in the cache salt so a resize re-slices.
430
+ // contentPaddingLeft is 0); only the visible suffix is syntax-colored, so
431
+ // the cheap raw-line wrap walk keeps the per-chunk cost bounded.
432
+ // innerWidth/budget are in the cache salt so a resize re-slices.
431
433
  const innerWidth = Math.max(1, width - 2);
432
- const budget = expanded ? Number.POSITIVE_INFINITY : Math.min(EDIT_STREAMING_PREVIEW_LINES, previewWindowRows());
434
+ const budget = expanded ? previewWindowRows() : Math.min(EDIT_STREAMING_PREVIEW_LINES, previewWindowRows());
433
435
  let text = cachedRenderedString(cache, uiTheme, expanded, `${rawPath}:${innerWidth}:${budget}`, diff, () => {
434
436
  // "Cursor" tail window: pin the last rows to the bottom so freshly streamed
435
437
  // changes stay on screen. The whole-file diff is recomputed every chunk and
436
438
  // its Myers alignment is not monotonic in payload length, so a hunk-aware
437
- // window stutters as rows move between hunks. Expanded lifts the cap.
439
+ // window stutters as rows move between hunks. Expanded widens the window
440
+ // to the viewport; the full diff appears once the result finalizes.
438
441
  const allLines = diff.replace(/\n+$/u, "").split("\n");
439
442
  let visualUsed = 0;
440
443
  let cut = allLines.length;
@@ -603,21 +603,22 @@ describe("agent() through eval runtimes", () => {
603
603
  });
604
604
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
605
605
  mockAgents();
606
- // Subagents that ignore the abort for far longer than the kernel's SIGINT
607
- // escalation window. Each kernel worker thread blocks in a synchronous
608
- // `urllib` bridge call, joined by `parallel()`'s ThreadPoolExecutor exit.
609
- // The host must respond the instant the cell aborts so the kernel can
610
- // unwind via KeyboardInterrupt instead of being hard-killed (which used to
611
- // surface "[kernel] Python kernel shutdown" and lose all session state).
606
+ // Each kernel worker thread blocks in a synchronous `urllib` bridge call,
607
+ // joined by `parallel()`'s ThreadPoolExecutor exit. The host must keep
608
+ // those already-started calls attached until they settle, then interrupt
609
+ // the kernel before `parallel()` launches another wave.
612
610
  let inFlight = 0;
611
+ let completed = 0;
613
612
  let markSaturated: (() => void) | undefined;
614
613
  const saturated = new Promise<void>(resolve => {
615
614
  markSaturated = resolve;
616
615
  });
617
- vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
616
+ const releaseAgents = Promise.withResolvers<void>();
617
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
618
618
  // task.maxConcurrency=6 → six bridge calls block at once; signal then.
619
619
  if (++inFlight >= 6) markSaturated?.();
620
- await Bun.sleep(9000); // deliberately ignores options.signal
620
+ await releaseAgents.promise;
621
+ completed++;
621
622
  return singleResult(options, { output: options.assignment ?? "" });
622
623
  });
623
624
 
@@ -640,8 +641,7 @@ describe("agent() through eval runtimes", () => {
640
641
  // bridge calls (condition-driven) instead of waiting a fixed wall second.
641
642
  void saturated.then(() => ac.abort(new Error("external interrupt")));
642
643
 
643
- const start = Date.now();
644
- const result = await executePython(
644
+ const resultPromise = executePython(
645
645
  "import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
646
646
  {
647
647
  cwd: tempDir.path(),
@@ -653,13 +653,18 @@ describe("agent() through eval runtimes", () => {
653
653
  signal: ac.signal,
654
654
  },
655
655
  );
656
- const elapsed = Date.now() - start;
656
+ await saturated;
657
+ await Promise.resolve();
658
+ expect(completed).toBe(0);
659
+ releaseAgents.resolve();
660
+ const result = await resultPromise;
657
661
 
658
- // Cancelled, but cleanly: no hard-kill, settled well within the kernel's 5s
659
- // SIGINT escalation window rather than ~6s after it.
662
+ // Cancelled, but cleanly: no hard-kill, no orphaned bridge calls, and no
663
+ // second fan-out wave started after the deferred abort was delivered.
660
664
  expect(result.cancelled).toBe(true);
661
665
  expect(result.output).not.toContain("Python kernel shutdown");
662
- expect(elapsed).toBeLessThan(4000);
666
+ expect(completed).toBe(6);
667
+ expect(runSpy).toHaveBeenCalledTimes(6);
663
668
 
664
669
  // The persistent kernel survived the interrupt: prior state is intact.
665
670
  const after = await executePython("print(PREP_MARKER)", {
@@ -5,7 +5,9 @@ import {
5
5
  isEvalTimeoutControlEvent,
6
6
  withBridgeTimeoutPause,
7
7
  } from "../bridge-timeout";
8
+ import { executeWithKernelBase, type GenericKernel } from "../executor-base";
8
9
  import type { JsStatusEvent } from "../js/shared/types";
10
+ import type { KernelDisplayOutput } from "../py/display";
9
11
 
10
12
  describe("withBridgeTimeoutPause", () => {
11
13
  it("emits one pause before the operation and one resume after it settles", async () => {
@@ -62,3 +64,61 @@ describe("withBridgeTimeoutPause", () => {
62
64
  expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
63
65
  });
64
66
  });
67
+
68
+ class TestCancelledError extends Error {
69
+ readonly timedOut: boolean;
70
+
71
+ constructor(timedOut: boolean) {
72
+ super(timedOut ? "timed out" : "cancelled");
73
+ this.name = "TestCancelledError";
74
+ this.timedOut = timedOut;
75
+ }
76
+ }
77
+
78
+ it("defers external aborts until an in-flight bridge call resumes", async () => {
79
+ const abortController = new AbortController();
80
+ const release = Promise.withResolvers<void>();
81
+ let completed = false;
82
+ let kernelSignal: AbortSignal | undefined;
83
+ const kernel: GenericKernel<Record<string, string | null>> = {
84
+ async execute(_code, options) {
85
+ kernelSignal = options.signal;
86
+ options.onDisplay({
87
+ type: "status",
88
+ event: { op: EVAL_TIMEOUT_PAUSE_OP },
89
+ } satisfies KernelDisplayOutput);
90
+
91
+ abortController.abort(new Error("external interrupt"));
92
+ expect(kernelSignal?.aborted).toBe(false);
93
+
94
+ await release.promise;
95
+ completed = true;
96
+ options.onDisplay({
97
+ type: "status",
98
+ event: { op: EVAL_TIMEOUT_RESUME_OP },
99
+ } satisfies KernelDisplayOutput);
100
+ return { status: "ok", cancelled: false, timedOut: false };
101
+ },
102
+ };
103
+
104
+ const resultPromise = executeWithKernelBase({
105
+ kernel,
106
+ code: "agent('slow')",
107
+ options: { signal: abortController.signal },
108
+ runIdPrefix: "test",
109
+ errorLogLabel: "test",
110
+ cancelledErrorClass: TestCancelledError,
111
+ buildKernelEnvPatch: () => ({}),
112
+ formatKernelTimeoutAnnotation: () => "kernel timed out",
113
+ formatTimeoutAnnotation: () => "timed out",
114
+ });
115
+
116
+ await Promise.resolve();
117
+ expect(completed).toBe(false);
118
+
119
+ release.resolve();
120
+ const result = await resultPromise;
121
+ expect(completed).toBe(true);
122
+ expect(result.cancelled).toBe(true);
123
+ expect(result.exitCode).toBeUndefined();
124
+ });
@@ -3,7 +3,7 @@ import { Settings } from "../config/settings";
3
3
  import { OutputSink } from "../session/streaming-output";
4
4
  import type { ToolSession } from "../tools";
5
5
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
6
- import { isEvalTimeoutControlEvent } from "./bridge-timeout";
6
+ import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, isEvalTimeoutControlEvent } from "./bridge-timeout";
7
7
  import type { JsStatusEvent } from "./js/shared/types";
8
8
  import type { KernelDisplayOutput } from "./py/display";
9
9
  import { registerPyToolBridge } from "./py/tool-bridge";
@@ -158,6 +158,76 @@ export async function waitForPromiseWithCancellation<T>(
158
158
  return await resultPromise;
159
159
  }
160
160
 
161
+ interface BridgeAbortShield {
162
+ signal: AbortSignal | undefined;
163
+ abortRequested: boolean;
164
+ timedOut: boolean;
165
+ handleStatus?: (event: JsStatusEvent) => void;
166
+ dispose?: () => void;
167
+ }
168
+
169
+ function createBridgeAbortShield(source: AbortSignal | undefined): BridgeAbortShield {
170
+ const shield: BridgeAbortShield = {
171
+ signal: undefined,
172
+ abortRequested: false,
173
+ timedOut: false,
174
+ };
175
+ if (!source) return shield;
176
+
177
+ const controller = new AbortController();
178
+ let pauseDepth = 0;
179
+ let abortReason: unknown;
180
+ let removeAbortListener: (() => void) | undefined;
181
+
182
+ const requestAbort = (reason: unknown): void => {
183
+ shield.abortRequested = true;
184
+ shield.timedOut =
185
+ shield.timedOut ||
186
+ (typeof DOMException !== "undefined" && reason instanceof DOMException
187
+ ? reason.name === "TimeoutError"
188
+ : reason instanceof Error && reason.name === "TimeoutError");
189
+ abortReason = reason;
190
+ if (pauseDepth > 0 || controller.signal.aborted) return;
191
+ controller.abort(reason);
192
+ };
193
+
194
+ const onAbort = (): void => {
195
+ const reason = source.reason;
196
+ requestAbort(reason);
197
+ };
198
+
199
+ shield.signal = controller.signal;
200
+ shield.handleStatus = (event: JsStatusEvent): void => {
201
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP) {
202
+ pauseDepth++;
203
+ return;
204
+ }
205
+ if (event.op !== EVAL_TIMEOUT_RESUME_OP || pauseDepth === 0) return;
206
+ pauseDepth--;
207
+ if (shield.abortRequested && !controller.signal.aborted) controller.abort(abortReason);
208
+ };
209
+ shield.dispose = (): void => {
210
+ removeAbortListener?.();
211
+ removeAbortListener = undefined;
212
+ };
213
+
214
+ if (source.aborted) {
215
+ requestAbort(source.reason);
216
+ } else {
217
+ source.addEventListener("abort", onAbort, { once: true });
218
+ removeAbortListener = () => {
219
+ source.removeEventListener("abort", onAbort);
220
+ };
221
+ if (source.aborted) {
222
+ source.removeEventListener("abort", onAbort);
223
+ removeAbortListener = undefined;
224
+ requestAbort(source.reason);
225
+ }
226
+ }
227
+
228
+ return shield;
229
+ }
230
+
161
231
  export function createCancelledKernelResult(output: string): KernelExecutionResult {
162
232
  const outputBytes = Buffer.byteLength(output, "utf-8");
163
233
  const outputLines = output.length > 0 ? 1 : 0;
@@ -304,9 +374,11 @@ export async function executeWithKernelBase<
304
374
  const displayOutputs: KernelDisplayOutput[] = [];
305
375
  const deadlineMs = (resolveDeadlineMs ?? getExecutionDeadlineMs)(options);
306
376
  let executionTimeoutMs: number | undefined;
377
+ const abortShield = createBridgeAbortShield(options?.signal);
307
378
 
308
379
  const collectDisplay = (output: KernelDisplayOutput): void => {
309
380
  if (output.type === "status") {
381
+ abortShield.handleStatus?.(output.event);
310
382
  options?.onStatus?.(output.event);
311
383
  if (!isJulia && isEvalTimeoutControlEvent(output.event)) return;
312
384
  }
@@ -320,8 +392,11 @@ export async function executeWithKernelBase<
320
392
  options?.toolSession && options?.bridgeSessionId
321
393
  ? registerPyToolBridge(options.bridgeSessionId, runId, {
322
394
  toolSession: options.toolSession,
323
- signal: options.signal,
395
+ signal: abortShield.signal,
324
396
  emitStatus,
397
+ abortRequested: () => {
398
+ return abortShield.abortRequested;
399
+ },
325
400
  })
326
401
  : null;
327
402
 
@@ -338,14 +413,15 @@ export async function executeWithKernelBase<
338
413
  cwd: options?.cwd,
339
414
  env: buildKernelEnvPatch(options ?? ({} as TOptions)),
340
415
  id: runId,
341
- signal: options?.signal,
416
+ signal: abortShield.signal,
342
417
  timeoutMs: executionTimeoutMs,
343
418
  onChunk: text => sink.push(text),
344
419
  onDisplay: output => collectDisplay(output),
345
420
  });
346
421
 
347
- if (result.cancelled) {
348
- const annotation = result.timedOut
422
+ if (result.cancelled || abortShield.abortRequested) {
423
+ const timedOut = result.timedOut || abortShield.timedOut;
424
+ const annotation = timedOut
349
425
  ? formatKernelTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs, result.kernelKilled ?? false)
350
426
  : undefined;
351
427
  const dumped = await sink.dump(annotation);
@@ -397,8 +473,8 @@ export async function executeWithKernelBase<
397
473
  stdinRequested: false,
398
474
  };
399
475
  } catch (err) {
400
- if (isCancellationError(err, cancelledErrorClass) || options?.signal?.aborted) {
401
- const timedOut = isTimedOutCancellation(err, cancelledErrorClass, options?.signal);
476
+ if (isCancellationError(err, cancelledErrorClass) || abortShield.abortRequested || abortShield.signal?.aborted) {
477
+ const timedOut = abortShield.timedOut || isTimedOutCancellation(err, cancelledErrorClass, abortShield.signal);
402
478
  const dumped = await sink.dump(
403
479
  timedOut ? formatTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs) : undefined,
404
480
  );
@@ -421,5 +497,6 @@ export async function executeWithKernelBase<
421
497
  throw error;
422
498
  } finally {
423
499
  unregisterBridge?.();
500
+ abortShield.dispose?.();
424
501
  }
425
502
  }
@@ -48,6 +48,15 @@ type BabelClassDeclaration = {
48
48
  };
49
49
 
50
50
  type BabelLexicalDecl = BabelVariableDeclaration | BabelClassDeclaration;
51
+ type BabelFunctionDeclaration = {
52
+ type: "FunctionDeclaration";
53
+ start: number;
54
+ end: number;
55
+ id: { start: number; end: number; name: string } | null;
56
+ };
57
+
58
+ /** Top-level declarations whose bindings must survive the cell (demoted and/or published). */
59
+ type BabelPublishableDecl = BabelLexicalDecl | BabelFunctionDeclaration;
51
60
 
52
61
  type BabelExpressionStatement = {
53
62
  type: "ExpressionStatement";
@@ -322,7 +331,7 @@ function collectBindingNames(pattern: unknown, names: string[]): void {
322
331
  }
323
332
  }
324
333
 
325
- function getLexicalBindingNames(node: BabelLexicalDecl): string[] {
334
+ function getLexicalBindingNames(node: BabelPublishableDecl): string[] {
326
335
  const names: string[] = [];
327
336
  if (node.type === "VariableDeclaration") {
328
337
  for (const declaration of node.declarations ?? []) collectBindingNames(declaration.id, names);
@@ -348,40 +357,49 @@ function appendGlobalBindingPublish(source: string, names: readonly string[]): s
348
357
  * let { a, b } = obj; -> var { a, b } = obj;
349
358
  * class Foo extends Bar {} -> var Foo = class extends Bar {};
350
359
  *
351
- * When the source must run inside the async wrapper, demoted `var`s would normally become
352
- * function-scoped. In that mode we publish each top-level binding back to the wrapper's
353
- * lexical `this`, which is the worker global object.
360
+ * When the source must run inside the async wrapper (top-level `await`), demoted `var`s —
361
+ * and the user's own top-level `var` and `function` declarations — would be scoped to the
362
+ * wrapper function and die with the cell. In that mode we publish every top-level binding
363
+ * back to the wrapper's lexical `this`, which is the worker global object.
354
364
  *
355
- * Nested declarations (inside functions, blocks, classes) are left alone \u2014 they're
365
+ * Nested declarations (inside functions, blocks, classes) are left alone — they're
356
366
  * scoped to their enclosing function/block regardless of `var` vs `let`/`const`.
357
367
  */
358
368
  async function demoteTopLevelLexicals(code: string, options: { publishGlobals?: boolean } = {}): Promise<string> {
359
- if (!/\b(?:const|let|class)\b/.test(code)) return code;
369
+ const publishGlobals = options.publishGlobals === true;
370
+ const fastPath = publishGlobals ? /\b(?:const|let|class|var|function)\b/ : /\b(?:const|let|class)\b/;
371
+ if (!fastPath.test(code)) return code;
360
372
 
361
373
  const ast = await parseProgram(code);
362
374
  if (!ast) {
363
375
  return code;
364
376
  }
365
377
 
366
- const targets: BabelLexicalDecl[] = [];
378
+ const targets: Array<{ node: BabelPublishableDecl; demote: boolean }> = [];
367
379
  for (const node of ast.program.body) {
368
380
  if (node.type === "VariableDeclaration") {
369
381
  const decl = node as unknown as BabelVariableDeclaration;
370
- if (decl.kind === "const" || decl.kind === "let") targets.push(decl);
382
+ if (decl.kind === "const" || decl.kind === "let") targets.push({ node: decl, demote: true });
383
+ else if (publishGlobals) targets.push({ node: decl, demote: false });
371
384
  } else if (node.type === "ClassDeclaration") {
372
385
  const decl = node as unknown as BabelClassDeclaration;
373
- if (decl.id) targets.push(decl);
386
+ if (decl.id) targets.push({ node: decl, demote: true });
387
+ } else if (publishGlobals && node.type === "FunctionDeclaration") {
388
+ const decl = node as unknown as BabelFunctionDeclaration;
389
+ if (decl.id) targets.push({ node: decl, demote: false });
374
390
  }
375
391
  }
376
392
  if (targets.length === 0) return code;
377
393
 
378
- targets.sort((a, b) => b.start - a.start);
394
+ targets.sort((a, b) => b.node.start - a.node.start);
379
395
  let result = code;
380
- for (const node of targets) {
396
+ for (const { node, demote } of targets) {
381
397
  const segment = result.slice(node.start, node.end);
382
- const bindingNames = options.publishGlobals ? getLexicalBindingNames(node) : [];
398
+ const bindingNames = publishGlobals ? getLexicalBindingNames(node) : [];
383
399
  let replacement: string;
384
- if (node.type === "VariableDeclaration") {
400
+ if (!demote) {
401
+ replacement = segment;
402
+ } else if (node.type === "VariableDeclaration") {
385
403
  replacement = `var${segment.slice(node.kind.length)}`;
386
404
  } else {
387
405
  const id = node.id;
@@ -15,6 +15,7 @@ export interface PyToolBridgeEntry {
15
15
  toolSession: ToolSession;
16
16
  signal?: AbortSignal;
17
17
  emitStatus?: (event: JsStatusEvent) => void;
18
+ abortRequested?: () => boolean;
18
19
  }
19
20
 
20
21
  export interface PyToolBridgeInfo {
@@ -31,23 +32,21 @@ const registrations = new Map<string, PyToolBridgeEntry>();
31
32
  let serverPromise: Promise<BridgeServer> | null = null;
32
33
 
33
34
  /**
34
- * Forward a bridge call to {@link callSessionTool}, but resolve the HTTP request
35
- * the instant the cell's signal aborts instead of waiting for the tool/subagent
36
- * to fully tear down.
35
+ * Forward a bridge call to {@link callSessionTool} while respecting eval abort
36
+ * shielding.
37
37
  *
38
- * The kernel invokes this bridge with a *blocking* `urllib` request from a
39
- * worker thread (each `agent()` / `tool.*` call). When the cell is interrupted,
40
- * `parallel()`'s `ThreadPoolExecutor.__exit__` joins those worker threads
41
- * (`shutdown(wait=True)`), so they cannot unwind until their `urllib` call
42
- * returns — i.e. until this handler responds. A host-side `agent()` teardown
43
- * (aborting nested LLM streams + tools across a wide fan-out) routinely exceeds
44
- * the kernel's SIGINT escalation window, so the kernel was hard-killed and its
45
- * persistent state lost while the subagents were still winding down. Responding
46
- * immediately on abort lets the kernel raise through the blocked call and settle
47
- * cleanly (preserving state); the already-signaled call keeps tearing down in
48
- * the background, its eventual result/rejection swallowed.
38
+ * Python invokes this bridge with blocking `urllib` requests from worker threads
39
+ * (each `agent()` / `tool.*` call). The base executor defers the registered
40
+ * signal while a bridge call is already paused so in-flight subagents can finish
41
+ * and persist output instead of being orphaned. Once an abort has been requested,
42
+ * later bridge calls are rejected before starting; once the shielded signal
43
+ * finally aborts, this handler still resolves the HTTP request promptly so the
44
+ * kernel can unwind without being hard-killed.
49
45
  */
50
46
  async function callSessionToolPromptOnAbort(name: string, args: unknown, entry: PyToolBridgeEntry): Promise<unknown> {
47
+ if (entry.abortRequested?.()) {
48
+ throw new Error(`bridge call ${JSON.stringify(name)} aborted: eval cell was interrupted`);
49
+ }
51
50
  const call = callSessionTool(name, args, {
52
51
  session: entry.toolSession,
53
52
  signal: entry.signal,
@@ -15,7 +15,6 @@ export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
15
15
  LESS: "FRX",
16
16
  // Disable terminal features that can block the process.
17
17
  TERM: "dumb",
18
- GPG_TTY: "not a tty",
19
18
  NO_COLOR: "1",
20
19
  PYTHONUNBUFFERED: "1",
21
20
  // Disable editor and terminal credential prompts.
@@ -447,9 +447,6 @@ export class TtsrManager {
447
447
  strictness: AstMatchStrictness.Smart,
448
448
  limit: 1,
449
449
  });
450
- if (result.parseErrors && result.parseErrors.length > 0) {
451
- logger.debug("TTSR ast match reported parse errors", { parseErrors: result.parseErrors });
452
- }
453
450
  return result.totalMatches > 0;
454
451
  } catch (error) {
455
452
  logger.warn("TTSR ast match failed, treating as no match", {