jeopi-cli 16.4.1 → 16.4.3

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 (125) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cli.js +3179 -3140
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/update-cli.d.ts +4 -1
  6. package/dist/types/cli/usage-error.d.ts +4 -0
  7. package/dist/types/commit/agentic/agent.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +7 -1
  9. package/dist/types/config/settings-schema.d.ts +3 -3
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  11. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  14. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  15. package/dist/types/internal-urls/types.d.ts +7 -0
  16. package/dist/types/irc/bus.d.ts +4 -0
  17. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  18. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  19. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  20. package/dist/types/modes/components/welcome.d.ts +4 -0
  21. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  22. package/dist/types/modes/orchestrate.d.ts +1 -1
  23. package/dist/types/modes/ultrathink.d.ts +1 -1
  24. package/dist/types/modes/workflow.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +5 -0
  26. package/dist/types/session/auth-broker-config.d.ts +2 -2
  27. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  28. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  29. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  32. package/dist/types/tools/browser/run-output.d.ts +25 -0
  33. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  34. package/dist/types/utils/changelog.d.ts +35 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +13 -0
  37. package/src/advisor/__tests__/config.test.ts +36 -0
  38. package/src/advisor/config.ts +11 -8
  39. package/src/autolearn/controller.ts +14 -2
  40. package/src/cli/args.ts +12 -0
  41. package/src/cli/flag-tables.ts +21 -7
  42. package/src/cli/update-cli.ts +63 -13
  43. package/src/cli/usage-error.ts +7 -0
  44. package/src/commands/acp.ts +11 -2
  45. package/src/commands/launch.ts +12 -3
  46. package/src/commit/agentic/agent.ts +4 -0
  47. package/src/commit/agentic/index.ts +46 -21
  48. package/src/config/model-resolver.ts +24 -6
  49. package/src/config/settings-schema.ts +3 -3
  50. package/src/edit/index.ts +10 -76
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  53. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  54. package/src/eval/executor-base.ts +84 -7
  55. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  56. package/src/eval/py/tool-bridge.ts +13 -14
  57. package/src/exec/non-interactive-env.ts +0 -1
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/custom-tools/loader.ts +70 -37
  60. package/src/extensibility/custom-tools/types.ts +3 -0
  61. package/src/extensibility/extensions/runner.ts +3 -0
  62. package/src/extensibility/extensions/types.ts +3 -0
  63. package/src/extensibility/plugins/manager.ts +21 -0
  64. package/src/internal-urls/docs-index.generated.txt +1 -1
  65. package/src/internal-urls/memory-protocol.ts +13 -8
  66. package/src/internal-urls/skill-protocol.ts +1 -1
  67. package/src/internal-urls/types.ts +7 -0
  68. package/src/irc/bus.ts +48 -18
  69. package/src/lsp/client.ts +7 -1
  70. package/src/lsp/deferred-diagnostics.ts +66 -0
  71. package/src/main.ts +13 -13
  72. package/src/mcp/tool-bridge.ts +57 -6
  73. package/src/mcp/transports/stdio.test.ts +45 -1
  74. package/src/mcp/transports/stdio.ts +6 -3
  75. package/src/modes/acp/acp-agent.ts +73 -2
  76. package/src/modes/acp/acp-event-mapper.ts +5 -0
  77. package/src/modes/acp/acp-mode.ts +11 -0
  78. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  79. package/src/modes/components/advisor-config.ts +15 -7
  80. package/src/modes/components/move-overlay.ts +2 -3
  81. package/src/modes/components/plan-review-overlay.ts +7 -0
  82. package/src/modes/components/tips.txt +1 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +8 -10
  85. package/src/modes/controllers/event-controller.ts +17 -30
  86. package/src/modes/controllers/input-controller.ts +9 -6
  87. package/src/modes/controllers/selector-controller.ts +3 -2
  88. package/src/modes/controllers/tan-command-controller.ts +40 -1
  89. package/src/modes/interactive-mode.ts +13 -0
  90. package/src/modes/magic-keyword-boundary.ts +23 -0
  91. package/src/modes/orchestrate.ts +6 -5
  92. package/src/modes/print-mode.ts +9 -0
  93. package/src/modes/ultrathink.ts +6 -5
  94. package/src/modes/workflow.ts +5 -4
  95. package/src/prompts/system/eager-task.md +2 -2
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/browser.md +5 -5
  98. package/src/sdk.ts +3 -0
  99. package/src/session/agent-session.ts +237 -50
  100. package/src/session/auth-broker-config.ts +4 -4
  101. package/src/session/snapcompact-inline.ts +3 -19
  102. package/src/session/streaming-output.ts +40 -12
  103. package/src/slash-commands/builtin-registry.ts +8 -8
  104. package/src/task/executor.ts +8 -0
  105. package/src/tools/bash-skill-urls.ts +7 -4
  106. package/src/tools/bash.ts +30 -22
  107. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  108. package/src/tools/browser/cmux/rpc.ts +50 -0
  109. package/src/tools/browser/launch.ts +8 -4
  110. package/src/tools/browser/run-cancellation.ts +44 -5
  111. package/src/tools/browser/run-output.ts +76 -0
  112. package/src/tools/browser/tab-worker.ts +280 -133
  113. package/src/tools/eval-render.ts +16 -14
  114. package/src/tools/glob.ts +20 -6
  115. package/src/tools/grep.ts +1 -0
  116. package/src/tools/image-gen.ts +3 -4
  117. package/src/tools/irc.ts +27 -13
  118. package/src/tools/read.ts +14 -7
  119. package/src/tools/write.ts +22 -5
  120. package/src/tools/yield.ts +29 -1
  121. package/src/utils/changelog.ts +106 -9
  122. package/src/utils/git.ts +0 -1
  123. package/src/utils/title-generator.ts +68 -7
  124. package/src/web/search/index.ts +14 -6
  125. package/src/web/search/providers/perplexity.ts +18 -2
@@ -1149,20 +1149,32 @@ export function resolveModelOverride(
1149
1149
  modelPatterns: string[],
1150
1150
  modelRegistry: ModelLookupRegistry,
1151
1151
  settings?: Settings,
1152
- ): { model?: Model<Api>; thinkingLevel?: ConfiguredThinkingLevel; explicitThinkingLevel: boolean } {
1152
+ ): {
1153
+ model?: Model<Api>;
1154
+ thinkingLevel?: ConfiguredThinkingLevel;
1155
+ explicitThinkingLevel: boolean;
1156
+ warning?: string;
1157
+ } {
1153
1158
  if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
1154
1159
  const availableModels = modelRegistry.getAvailable();
1155
1160
  const matchPreferences = getModelMatchPreferences(settings);
1161
+ let warning: string | undefined;
1156
1162
  for (const pattern of modelPatterns) {
1157
- const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
1163
+ const {
1164
+ model,
1165
+ thinkingLevel,
1166
+ explicitThinkingLevel,
1167
+ warning: patternWarning,
1168
+ } = resolveModelRoleValue(pattern, availableModels, {
1158
1169
  settings,
1159
1170
  matchPreferences,
1160
1171
  });
1161
1172
  if (model) {
1162
- return { model, thinkingLevel, explicitThinkingLevel };
1173
+ return { model, thinkingLevel, explicitThinkingLevel, warning: patternWarning };
1163
1174
  }
1175
+ if (!warning && patternWarning) warning = patternWarning;
1164
1176
  }
1165
- return { explicitThinkingLevel: false };
1177
+ return { explicitThinkingLevel: false, warning };
1166
1178
  }
1167
1179
 
1168
1180
  /**
@@ -1176,6 +1188,10 @@ export function resolveModelOverride(
1176
1188
  * `modelRoles.task` pointing at an unqualified id whose only available
1177
1189
  * provider variant has no configured credentials — see #985).
1178
1190
  *
1191
+ * `sessionId` is forwarded to `getApiKey` so that session-sticky OAuth
1192
+ * credentials resolve correctly during the pre-flight auth check. Without it,
1193
+ * providers with multiple OAuth accounts may return `undefined` even though
1194
+ * the credential is usable once the subagent session starts — see #5325.
1179
1195
  * Keyless-by-design providers (llama.cpp, ollama, lm-studio) advertise the
1180
1196
  * `kNoAuth` sentinel from `getApiKey` to signal that they do not require
1181
1197
  * credentials. Those are treated as authenticated here so an explicitly
@@ -1191,18 +1207,20 @@ export async function resolveModelOverrideWithAuthFallback(
1191
1207
  parentActiveModelPattern: string | undefined,
1192
1208
  modelRegistry: ModelLookupRegistry & Pick<ModelRegistry, "getApiKey">,
1193
1209
  settings?: Settings,
1210
+ sessionId?: string,
1194
1211
  ): Promise<{
1195
1212
  model?: Model<Api>;
1196
1213
  thinkingLevel?: ConfiguredThinkingLevel;
1197
1214
  explicitThinkingLevel: boolean;
1198
1215
  authFallbackUsed: boolean;
1216
+ warning?: string;
1199
1217
  }> {
1200
1218
  const primary = resolveModelOverride(modelPatterns, modelRegistry, settings);
1201
1219
  if (!primary.model || !parentActiveModelPattern) {
1202
1220
  return { ...primary, authFallbackUsed: false };
1203
1221
  }
1204
1222
 
1205
- const primaryKey = await modelRegistry.getApiKey(primary.model);
1223
+ const primaryKey = await modelRegistry.getApiKey(primary.model, sessionId);
1206
1224
  if (primaryKey === kNoAuth || isAuthenticated(primaryKey)) {
1207
1225
  return { ...primary, authFallbackUsed: false };
1208
1226
  }
@@ -1214,7 +1232,7 @@ export async function resolveModelOverrideWithAuthFallback(
1214
1232
  if (modelsAreEqual(fallback.model, primary.model)) {
1215
1233
  return { ...primary, authFallbackUsed: false };
1216
1234
  }
1217
- const fallbackKey = await modelRegistry.getApiKey(fallback.model);
1235
+ const fallbackKey = await modelRegistry.getApiKey(fallback.model, sessionId);
1218
1236
  if (!isAuthenticated(fallbackKey)) {
1219
1237
  return { ...primary, authFallbackUsed: false };
1220
1238
  }
@@ -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
  },
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;