@oh-my-pi/pi-coding-agent 15.9.5 → 15.9.67

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 (98) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/config/keybindings.d.ts +4 -1
  3. package/dist/types/config/settings-schema.d.ts +11 -1
  4. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  5. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  6. package/dist/types/eval/backend.d.ts +6 -6
  7. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  8. package/dist/types/eval/idle-timeout.d.ts +16 -14
  9. package/dist/types/eval/js/executor.d.ts +3 -3
  10. package/dist/types/eval/py/executor.d.ts +2 -2
  11. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  13. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  14. package/dist/types/modes/components/model-selector.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +1 -1
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  20. package/dist/types/tools/eval-render.d.ts +8 -0
  21. package/dist/types/tools/render-utils.d.ts +25 -0
  22. package/dist/types/tui/code-cell.d.ts +6 -0
  23. package/dist/types/tui/output-block.d.ts +11 -0
  24. package/package.json +9 -9
  25. package/src/autoresearch/dashboard.ts +11 -21
  26. package/src/cli/claude-trace-cli.ts +13 -1
  27. package/src/config/keybindings.ts +58 -1
  28. package/src/config/settings-schema.ts +11 -1
  29. package/src/debug/raw-sse.ts +18 -4
  30. package/src/edit/file-snapshot-store.ts +1 -1
  31. package/src/edit/index.ts +1 -1
  32. package/src/edit/renderer.ts +7 -7
  33. package/src/edit/streaming.ts +1 -1
  34. package/src/eval/__tests__/agent-bridge.test.ts +28 -27
  35. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  36. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  37. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  38. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  39. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  40. package/src/eval/agent-bridge.ts +4 -5
  41. package/src/eval/backend.ts +6 -6
  42. package/src/eval/bridge-timeout.ts +44 -0
  43. package/src/eval/idle-timeout.ts +33 -15
  44. package/src/eval/js/executor.ts +10 -10
  45. package/src/eval/llm-bridge.ts +4 -5
  46. package/src/eval/py/executor.ts +6 -6
  47. package/src/eval/py/kernel.ts +11 -1
  48. package/src/eval/py/spawn-options.ts +126 -0
  49. package/src/export/ttsr.ts +9 -0
  50. package/src/extensibility/extensions/runner.ts +2 -0
  51. package/src/internal-urls/docs-index.generated.ts +6 -5
  52. package/src/lsp/client.ts +80 -2
  53. package/src/lsp/index.ts +38 -4
  54. package/src/lsp/render.ts +3 -3
  55. package/src/main.ts +1 -1
  56. package/src/modes/components/agent-dashboard.ts +13 -4
  57. package/src/modes/components/assistant-message.ts +22 -1
  58. package/src/modes/components/copy-selector.ts +249 -0
  59. package/src/modes/components/extensions/extension-list.ts +17 -8
  60. package/src/modes/components/history-search.ts +19 -11
  61. package/src/modes/components/model-selector.ts +125 -29
  62. package/src/modes/components/oauth-selector.ts +28 -12
  63. package/src/modes/components/session-observer-overlay.ts +13 -15
  64. package/src/modes/components/session-selector.ts +24 -13
  65. package/src/modes/components/tool-execution.ts +27 -13
  66. package/src/modes/components/tree-selector.ts +19 -7
  67. package/src/modes/components/user-message-selector.ts +25 -14
  68. package/src/modes/controllers/command-controller.ts +0 -116
  69. package/src/modes/controllers/event-controller.ts +26 -10
  70. package/src/modes/controllers/selector-controller.ts +38 -1
  71. package/src/modes/interactive-mode.ts +4 -4
  72. package/src/modes/theme/theme.ts +46 -10
  73. package/src/modes/types.ts +1 -1
  74. package/src/modes/utils/copy-targets.ts +254 -0
  75. package/src/prompts/tools/ast-edit.md +1 -1
  76. package/src/prompts/tools/ast-grep.md +1 -1
  77. package/src/prompts/tools/read.md +1 -1
  78. package/src/prompts/tools/search.md +1 -1
  79. package/src/session/agent-session.ts +6 -2
  80. package/src/slash-commands/builtin-registry.ts +3 -11
  81. package/src/task/render.ts +38 -11
  82. package/src/tools/bash.ts +18 -8
  83. package/src/tools/browser/render.ts +5 -4
  84. package/src/tools/debug.ts +3 -3
  85. package/src/tools/eval-render.ts +24 -9
  86. package/src/tools/eval.ts +14 -19
  87. package/src/tools/fetch.ts +5 -5
  88. package/src/tools/read.ts +7 -7
  89. package/src/tools/render-utils.ts +46 -0
  90. package/src/tools/ssh.ts +21 -8
  91. package/src/tools/write.ts +17 -8
  92. package/src/tui/code-cell.ts +19 -4
  93. package/src/tui/output-block.ts +14 -0
  94. package/src/web/search/render.ts +3 -3
  95. package/dist/types/eval/heartbeat.d.ts +0 -45
  96. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  97. package/src/eval/heartbeat.ts +0 -74
  98. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
@@ -380,6 +380,18 @@ function isMessagesRequest(message: ParsedHttpMessage): boolean {
380
380
  return pathNameFromRequestTarget(message.path ?? "") === "/v1/messages";
381
381
  }
382
382
 
383
+ // Claude Code fires a background warmup/classification call on its small fast
384
+ // model (a haiku variant, ANTHROPIC_SMALL_FAST_MODEL) before sending the user's
385
+ // real message. Skip it so the capture lands on the actual prompt.
386
+ function isBackgroundModelRequest(message: ParsedHttpMessage): boolean {
387
+ try {
388
+ const parsed = JSON.parse(decodeBody(message.headers, message.body)) as { model?: unknown };
389
+ return typeof parsed.model === "string" && parsed.model.toLowerCase().includes("haiku");
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
394
+
383
395
  function decodeBody(headers: readonly HeaderEntry[], body: Buffer): string {
384
396
  const encoding = headerValue(headers, "content-encoding")?.toLowerCase().trim();
385
397
  try {
@@ -636,7 +648,7 @@ export class ClaudeMessagesProxy {
636
648
  upstreamTls.write(data);
637
649
  const messages = requestParser.push(data);
638
650
  for (const message of messages) {
639
- if (!isMessagesRequest(message)) {
651
+ if (!isMessagesRequest(message) || isBackgroundModelRequest(message)) {
640
652
  responseQueue.push(null);
641
653
  continue;
642
654
  }
@@ -119,7 +119,10 @@ export const KEYBINDINGS = {
119
119
  description: "Open external editor",
120
120
  },
121
121
  "app.message.followUp": {
122
- defaultKeys: "ctrl+enter",
122
+ // Ctrl+Enter is preserved for terminals that deliver it (Kitty/iTerm2/WezTerm/Ghostty),
123
+ // but Windows Terminal does not emit a distinct event for Ctrl+Enter — Ctrl+Q is listed
124
+ // first so the default binding works there without remapping (#1903).
125
+ defaultKeys: ["ctrl+q", "ctrl+enter"],
123
126
  description: "Send follow-up message",
124
127
  },
125
128
  "app.message.dequeue": {
@@ -439,16 +442,51 @@ function migrateKeybindingsConfigFile(agentDir: string): void {
439
442
  loadKeybindingsConfig(readPath, writeBackPath);
440
443
  }
441
444
 
445
+ const FOLLOW_UP_KEYBINDING: AppKeybinding = "app.message.followUp";
446
+ const WINDOWS_FOLLOW_UP_FALLBACK_KEY: KeyId = "ctrl+q";
447
+
448
+ function keyListIncludes(keys: KeyId | KeyId[] | undefined, target: KeyId): boolean {
449
+ if (keys === undefined) return false;
450
+ const keyList = Array.isArray(keys) ? keys : [keys];
451
+ for (const key of keyList) {
452
+ if (key.toLowerCase() === target) return true;
453
+ }
454
+ return false;
455
+ }
456
+
457
+ function userBindingClaimsKey(config: KeybindingsConfig, target: KeyId, except: Keybinding): boolean {
458
+ for (const [keybinding, keys] of Object.entries(config)) {
459
+ if (!(keybinding in KEYBINDINGS)) continue;
460
+ if (keybinding === except) continue;
461
+ if (keyListIncludes(keys, target)) return true;
462
+ }
463
+ return false;
464
+ }
465
+
466
+ function removeKey(keys: KeyId[], target: KeyId): KeyId[] {
467
+ return keys.filter(key => key !== target);
468
+ }
469
+
470
+ function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
471
+ if (keys.length === 1) {
472
+ const key = keys[0];
473
+ if (key !== undefined) return key;
474
+ }
475
+ return [...keys];
476
+ }
477
+
442
478
  /**
443
479
  * Manages all keybindings (app + TUI).
444
480
  * Extends the TUI KeybindingsManager with app-specific functionality.
445
481
  */
446
482
  export class KeybindingsManager extends TuiKeybindingsManager {
447
483
  #configPath: string | undefined;
484
+ #userBindings: KeybindingsConfig;
448
485
 
449
486
  constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
450
487
  super(KEYBINDINGS, userBindings);
451
488
  this.#configPath = configPath;
489
+ this.#userBindings = userBindings;
452
490
  }
453
491
 
454
492
  /**
@@ -480,6 +518,25 @@ export class KeybindingsManager extends TuiKeybindingsManager {
480
518
  this.setUserBindings(config);
481
519
  }
482
520
 
521
+ setUserBindings(userBindings: KeybindingsConfig): void {
522
+ this.#userBindings = userBindings;
523
+ super.setUserBindings(userBindings);
524
+ }
525
+
526
+ getKeys(keybinding: Keybinding): KeyId[] {
527
+ const keys = super.getKeys(keybinding);
528
+ if (keybinding !== FOLLOW_UP_KEYBINDING) return keys;
529
+ if (this.#userBindings[FOLLOW_UP_KEYBINDING] !== undefined) return keys;
530
+ if (!userBindingClaimsKey(this.#userBindings, WINDOWS_FOLLOW_UP_FALLBACK_KEY, FOLLOW_UP_KEYBINDING)) return keys;
531
+ return removeKey(keys, WINDOWS_FOLLOW_UP_FALLBACK_KEY);
532
+ }
533
+
534
+ getResolvedBindings(): KeybindingsConfig {
535
+ const resolved = super.getResolvedBindings();
536
+ resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING));
537
+ return resolved;
538
+ }
539
+
483
540
  /**
484
541
  * Get the effective resolved bindings (defaults + user overrides).
485
542
  */
@@ -902,6 +902,15 @@ export const SETTINGS_SCHEMA = {
902
902
  "Maximum wait between retries, in ms. When the provider asks us to wait longer than this and no credential or model fallback succeeds, the request fails fast instead of sleeping (e.g. 3-hour Anthropic rate-limit windows).",
903
903
  },
904
904
  },
905
+ "retry.modelFallback": {
906
+ type: "boolean",
907
+ default: true,
908
+ ui: {
909
+ tab: "model",
910
+ label: "Retry Model Fallback",
911
+ description: "Allow retry recovery to switch to configured fallback models",
912
+ },
913
+ },
905
914
  "retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
906
915
  "retry.fallbackRevertPolicy": {
907
916
  type: "enum",
@@ -1855,7 +1864,7 @@ export const SETTINGS_SCHEMA = {
1855
1864
  tab: "editing",
1856
1865
  label: "Hash Lines",
1857
1866
  description:
1858
- "Include snapshot-tag headers and line numbers in read output for hashline edit mode (PATH#tag plus LINE:content)",
1867
+ "Include snapshot-tag headers and line numbers in read output for hashline edit mode ([PATH#TAG] plus LINE:content)",
1859
1868
  },
1860
1869
  },
1861
1870
 
@@ -3307,6 +3316,7 @@ export interface RetrySettings {
3307
3316
  maxRetries: number;
3308
3317
  baseDelayMs: number;
3309
3318
  maxDelayMs: number;
3319
+ modelFallback: boolean;
3310
3320
  }
3311
3321
 
3312
3322
  export interface MemoriesSettings {
@@ -1,4 +1,12 @@
1
- import { type Component, matchesKey, padding, replaceTabs, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
1
+ import {
2
+ type Component,
3
+ matchesKey,
4
+ padding,
5
+ replaceTabs,
6
+ ScrollView,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@oh-my-pi/pi-tui";
2
10
  import { sanitizeText } from "@oh-my-pi/pi-utils";
3
11
  import { theme } from "../modes/theme/theme";
4
12
  import { copyToClipboard } from "../utils/clipboard";
@@ -146,14 +154,20 @@ export class RawSseViewerComponent implements Component {
146
154
  const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
147
155
  const bodyHeight = this.#bodyHeight();
148
156
  const rawLines = this.#renderRawLines(innerWidth);
149
- const body = rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight);
150
- while (body.length < bodyHeight) body.push("");
157
+ const sv = new ScrollView(rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight), {
158
+ height: bodyHeight,
159
+ scrollbar: "auto",
160
+ totalRows: rawLines.length,
161
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
162
+ });
163
+ sv.setScrollOffset(this.#scrollOffset);
164
+ const bodyRows = sv.render(innerWidth);
151
165
 
152
166
  return [
153
167
  this.#frameTop(innerWidth),
154
168
  this.#frameLine(this.#summaryText(), innerWidth),
155
169
  this.#frameSeparator(innerWidth),
156
- ...body.map(line => this.#frameLine(line, innerWidth)),
170
+ ...bodyRows.map(line => this.#frameLine(line, innerWidth)),
157
171
  this.#frameLine(this.#statusText(), innerWidth),
158
172
  this.#frameBottom(innerWidth),
159
173
  ];
@@ -14,7 +14,7 @@ import { normalizeToLF } from "./normalize";
14
14
  /**
15
15
  * Upper bound on the file size we snapshot. A section tag is a content hash of
16
16
  * the *whole* file, so minting one means holding the full normalized text in
17
- * the store. Files above this cap emit no path#tag` header — line-anchored
17
+ * the store. Files above this cap emit no `[path#tag]` header — line-anchored
18
18
  * editing of multi-megabyte files is out of scope under the full-content model.
19
19
  */
20
20
  export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
package/src/edit/index.ts CHANGED
@@ -275,7 +275,7 @@ function extractApprovalPath(args: unknown): string {
275
275
  const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
276
276
  const input = typeof record.input === "string" ? record.input : undefined;
277
277
  if (input) {
278
- const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
278
+ const hashlineMatch = /^\[([^#\r\n]+)(?:#[0-9a-fA-F]{4})?\]/m.exec(input);
279
279
  if (hashlineMatch?.[1]) return hashlineMatch[1];
280
280
 
281
281
  const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
@@ -2,7 +2,7 @@
2
2
  * Edit tool renderer and LSP batching helpers.
3
3
  */
4
4
 
5
- import { HL_FILE_PREFIX } from "@oh-my-pi/hashline";
5
+ import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
6
6
  import type { Component } from "@oh-my-pi/pi-tui";
7
7
  import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
8
8
  import { sanitizeText } from "@oh-my-pi/pi-utils";
@@ -328,12 +328,12 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
328
328
  }
329
329
 
330
330
  function parseHashlineInputPreviewHeader(line: string): string | null {
331
- if (!line.startsWith(HL_FILE_PREFIX)) return null;
332
- // Mirror hashline/input.ts: strip every leading file marker so canonical
333
- // PATH` headers and stray `¶¶ PATH` / `¶¶¶PATH` runs render clean paths.
334
- let prefixEnd = 0;
335
- while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
336
- const body = line.slice(prefixEnd).trim();
331
+ const trimmed = line.trimEnd();
332
+ if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
333
+ // Keep streaming previews tolerant while the closing bracket is still
334
+ // being generated; the parser enforces the final `[path#TAG]` shape.
335
+ const bodyEnd = trimmed.endsWith(HL_FILE_SUFFIX) ? trimmed.length - HL_FILE_SUFFIX.length : trimmed.length;
336
+ const body = trimmed.slice(HL_FILE_PREFIX.length, bodyEnd).trim();
337
337
  const previewPath = normalizeHashlineInputPreviewPath(body);
338
338
  return previewPath.length > 0 ? previewPath : null;
339
339
  }
@@ -424,7 +424,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
424
424
  return previews.length > 0 ? previews : null;
425
425
  },
426
426
  renderStreamingFallback() {
427
- // Never leak raw hashline syntax (`64:`, `|payload`, path#hash`)
427
+ // Never leak raw hashline syntax (`64:`, `|payload`, `[path#hash]`)
428
428
  // to the user — the streaming preview already projects every
429
429
  // parseable op onto the real file via applyPartialTo, and an
430
430
  // unparseable trailing chunk renders as "no preview yet" rather
@@ -10,7 +10,7 @@ import { AgentOutputManager } from "../../task/output-manager";
10
10
  import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
11
11
  import type { ToolSession } from "../../tools";
12
12
  import { EVAL_AGENT_MAX_DEPTH, runEvalAgent } from "../agent-bridge";
13
- import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs } from "../heartbeat";
13
+ import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
14
14
  import { IdleTimeout } from "../idle-timeout";
15
15
  import { disposeAllVmContexts } from "../js/context-manager";
16
16
  import { executeJs } from "../js/executor";
@@ -236,7 +236,6 @@ describe("runEvalAgent", () => {
236
236
  describe("agent() through eval runtimes", () => {
237
237
  afterEach(() => {
238
238
  vi.restoreAllMocks();
239
- setBridgeHeartbeatIntervalMs();
240
239
  });
241
240
 
242
241
  afterAll(async () => {
@@ -560,24 +559,20 @@ describe("agent() through eval runtimes", () => {
560
559
  expect(displayAgentEvents.length).toBe(2);
561
560
  });
562
561
 
563
- it("keeps the idle watchdog armed while a quiet agent() runs past the budget", async () => {
564
- using tempDir = TempDir.createSync("@omp-eval-agent-heartbeat-");
565
- const { session } = makeEvalSession(tempDir, "js-agent-heartbeat");
562
+ it("pauses the idle watchdog while a quiet agent() runs past the budget", async () => {
563
+ using tempDir = TempDir.createSync("@omp-eval-agent-timeout-pause-");
564
+ const { session } = makeEvalSession(tempDir, "js-agent-timeout-pause");
566
565
  mockAgents();
567
- // Heartbeat cadence well under the idle budget so a working-but-silent
568
- // subagent re-arms the watchdog several times before it could expire.
569
- setBridgeHeartbeatIntervalMs(15);
570
566
 
571
- // runSubprocess runs far past the budget and emits NO progress of its own
572
- // the only thing standing between the subagent and a spurious idle abort
573
- // is the heartbeat keepalive the bridge pumps while it awaits.
567
+ // runSubprocess runs far past the eval timeout budget and emits NO progress
568
+ // of its own. The bridge pause must make that delegated time invisible to
569
+ // the watchdog.
574
570
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
575
571
  await Bun.sleep(200);
576
572
  return singleResult(options, { output: "done" });
577
573
  });
578
574
 
579
- // Mirror the eval tool's wiring: an IdleTimeout drives cancellation and
580
- // ONLY a bridge heartbeat re-arms it.
575
+ const ops: string[] = [];
581
576
  using idle = new IdleTimeout(60);
582
577
  const result = await runEvalAgent(
583
578
  { prompt: "investigate" },
@@ -585,25 +580,29 @@ describe("agent() through eval runtimes", () => {
585
580
  session,
586
581
  signal: idle.signal,
587
582
  emitStatus: event => {
588
- if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
583
+ ops.push(event.op);
584
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
585
+ if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
589
586
  },
590
587
  },
591
588
  );
592
589
 
593
- expect(idle.signal.aborted).toBe(false);
594
590
  expect(result.text).toBe("done");
591
+ expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
592
+ expect(idle.signal.aborted).toBe(false);
593
+
594
+ await Bun.sleep(90);
595
+ expect(idle.signal.aborted).toBe(true);
595
596
  });
596
597
 
597
- it("does not let agent() progress snapshots re-arm the watchdog without a heartbeat", async () => {
598
- using tempDir = TempDir.createSync("@omp-eval-agent-progress-no-rearm-");
599
- const { session } = makeEvalSession(tempDir, "js-agent-progress-no-rearm");
598
+ it("keeps timeout paused despite agent() progress snapshots", async () => {
599
+ using tempDir = TempDir.createSync("@omp-eval-agent-progress-timeout-pause-");
600
+ const { session } = makeEvalSession(tempDir, "js-agent-progress-timeout-pause");
600
601
  mockAgents();
601
- // Heartbeat slower than the budget: only the immediate beat at call start
602
- // fires, so after the budget elapses nothing re-arms the watchdog.
603
- setBridgeHeartbeatIntervalMs(10_000);
604
602
 
605
603
  // Stream frequent progress snapshots (op:"agent") for well past the budget.
606
- // Progress is rendered but MUST NOT count as activity — only heartbeats do.
604
+ // They render as status, but timeout accounting is controlled only by the
605
+ // bridge pause/resume events.
607
606
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
608
607
  for (let i = 0; i < 40; i++) {
609
608
  options.onProgress?.({
@@ -629,21 +628,23 @@ describe("agent() through eval runtimes", () => {
629
628
 
630
629
  const ops: string[] = [];
631
630
  using idle = new IdleTimeout(80);
632
- await runEvalAgent(
631
+ const result = await runEvalAgent(
633
632
  { prompt: "investigate" },
634
633
  {
635
634
  session,
636
635
  signal: idle.signal,
637
636
  emitStatus: event => {
638
637
  ops.push(event.op);
639
- if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
638
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
639
+ if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
640
640
  },
641
641
  },
642
642
  );
643
643
 
644
- // Progress streamed, but the watchdog still fired: agent snapshots never
645
- // re-armed it, and the lone start heartbeat lapsed before the call ended.
644
+ expect(result.text).toBe("done");
645
+ expect(ops[0]).toBe(EVAL_TIMEOUT_PAUSE_OP);
646
646
  expect(ops).toContain("agent");
647
- expect(idle.signal.aborted).toBe(true);
647
+ expect(ops.at(-1)).toBe(EVAL_TIMEOUT_RESUME_OP);
648
+ expect(idle.signal.aborted).toBe(false);
648
649
  });
649
650
  });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ EVAL_TIMEOUT_PAUSE_OP,
4
+ EVAL_TIMEOUT_RESUME_OP,
5
+ isEvalTimeoutControlEvent,
6
+ withBridgeTimeoutPause,
7
+ } from "../bridge-timeout";
8
+ import type { JsStatusEvent } from "../js/shared/types";
9
+
10
+ describe("withBridgeTimeoutPause", () => {
11
+ it("emits one pause before the operation and one resume after it settles", async () => {
12
+ const events: JsStatusEvent[] = [];
13
+
14
+ const value = await withBridgeTimeoutPause(
15
+ event => events.push(event),
16
+ async () => {
17
+ await Bun.sleep(80);
18
+ return "done";
19
+ },
20
+ );
21
+
22
+ expect(value).toBe("done");
23
+ expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
24
+
25
+ const settledCount = events.length;
26
+ await Bun.sleep(40);
27
+ expect(events.length).toBe(settledCount);
28
+ });
29
+
30
+ it("resumes timeout accounting even when the operation throws", async () => {
31
+ const events: JsStatusEvent[] = [];
32
+
33
+ await expect(
34
+ withBridgeTimeoutPause(
35
+ event => events.push(event),
36
+ async () => {
37
+ await Bun.sleep(20);
38
+ throw new Error("boom");
39
+ },
40
+ ),
41
+ ).rejects.toThrow("boom");
42
+
43
+ expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
44
+ });
45
+
46
+ it("runs the operation without emitting when no status sink is wired", async () => {
47
+ let ran = 0;
48
+
49
+ const value = await withBridgeTimeoutPause(undefined, async () => {
50
+ ran++;
51
+ await Bun.sleep(20);
52
+ return 42;
53
+ });
54
+
55
+ expect(value).toBe(42);
56
+ expect(ran).toBe(1);
57
+ });
58
+
59
+ it("identifies timeout-control events as non-renderable status", () => {
60
+ expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_PAUSE_OP })).toBe(true);
61
+ expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_RESUME_OP })).toBe(true);
62
+ expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
63
+ });
64
+ });
@@ -32,21 +32,34 @@ describe("IdleTimeout", () => {
32
32
  expect((idle.signal.reason as DOMException).name).toBe("TimeoutError");
33
33
  });
34
34
 
35
- it("re-arms on every bump and only fires after activity stops", async () => {
36
- using idle = new IdleTimeout(150);
37
- // Bump well past a single window; each bump must push the deadline forward
38
- // so the watchdog never trips while activity continues.
39
- for (let i = 0; i < 6; i++) {
40
- await Bun.sleep(40);
41
- idle.bump();
42
- }
35
+ it("ignores elapsed time while paused and resumes with a fresh window", async () => {
36
+ using idle = new IdleTimeout(80);
37
+ idle.pause();
38
+ await Bun.sleep(160);
43
39
  expect(idle.signal.aborted).toBe(false);
44
40
 
45
- // Activity stopped — the watchdog should now fire within roughly one window.
46
- const fired = await abortedWithin(idle.signal, 800);
41
+ idle.resume();
42
+ const firedEarly = await abortedWithin(idle.signal, 30);
43
+ expect(firedEarly).toBe(false);
44
+ const fired = await abortedWithin(idle.signal, 500);
47
45
  expect(fired).toBe(true);
48
46
  });
49
47
 
48
+ it("reference-counts overlapping pauses", async () => {
49
+ using idle = new IdleTimeout(60);
50
+ idle.pause();
51
+ idle.pause();
52
+ await Bun.sleep(120);
53
+ expect(idle.signal.aborted).toBe(false);
54
+
55
+ idle.resume();
56
+ await Bun.sleep(90);
57
+ expect(idle.signal.aborted).toBe(false);
58
+
59
+ idle.resume();
60
+ const fired = await abortedWithin(idle.signal, 500);
61
+ expect(fired).toBe(true);
62
+ });
50
63
  it("never fires after dispose()", async () => {
51
64
  const idle = new IdleTimeout(30);
52
65
  idle.dispose();
@@ -55,12 +68,13 @@ describe("IdleTimeout", () => {
55
68
  expect(idle.signal.aborted).toBe(false);
56
69
  });
57
70
 
58
- it("ignores bump() after the watchdog has already fired", async () => {
71
+ it("ignores pause/resume after the watchdog has already fired", async () => {
59
72
  using idle = new IdleTimeout(30);
60
73
  await abortedWithin(idle.signal, 500);
61
74
  expect(idle.signal.aborted).toBe(true);
62
75
  // Late activity must not un-abort or rearm a settled watchdog.
63
- idle.bump();
76
+ idle.pause();
77
+ idle.resume();
64
78
  expect(idle.signal.aborted).toBe(true);
65
79
  });
66
80
  });
@@ -0,0 +1,103 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ __resetWindowsConsoleProbeCache,
4
+ consoleAttachedViaTTY,
5
+ hostHasInheritableConsole,
6
+ shouldHideKernelWindow,
7
+ } from "../py/spawn-options";
8
+
9
+ /**
10
+ * `shouldHideKernelWindow` decides whether the long-lived Python kernel
11
+ * subprocess is spawned with `windowsHide: true`. On Windows, Bun maps that
12
+ * option to `CREATE_NO_WINDOW`, which detaches the child from any inherited
13
+ * console — breaking both (a) `LoadLibraryExW` for NumPy/pandas native
14
+ * extensions and (b) SIGINT delivery via `GenerateConsoleCtrlEvent`. See
15
+ * issue #1960. The tests below pin the three layered concerns the PR review
16
+ * surfaced:
17
+ *
18
+ * 1. `shouldHideKernelWindow` — pure predicate over a single boolean.
19
+ * 2. `consoleAttachedViaTTY` — the TTY-OR fallback used when the Win32 FFI
20
+ * probe is unavailable; covers the partial-redirection cases.
21
+ * 3. `hostHasInheritableConsole` — the integration boundary. Off-Windows it
22
+ * short-circuits to the TTY fallback; on Windows it is expected to
23
+ * consult `kernel32!GetConsoleWindow()` first, which is the authoritative
24
+ * signal even for the all-stdio-redirected case.
25
+ */
26
+ describe("shouldHideKernelWindow", () => {
27
+ it("inherits the host console on Windows when one is attached", () => {
28
+ // Reporter's repro: omp launched in Windows Terminal, host has a
29
+ // console, kernel must inherit so `import pandas` doesn't deadlock in
30
+ // `_multiarray_umath` and SIGINT can recover the cell.
31
+ expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: true })).toBe(false);
32
+ });
33
+
34
+ it("hides on Windows only when the host has no console at all (true service / daemon)", () => {
35
+ // CREATE_NO_WINDOW here suppresses the console window Windows would
36
+ // otherwise auto-allocate for the console-app Python kernel.
37
+ expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: false })).toBe(true);
38
+ });
39
+
40
+ it("never sets windowsHide off-Windows (the option is a Win32-only flag)", () => {
41
+ // On POSIX `windowsHide` is a no-op; the predicate must return false
42
+ // everywhere off-Windows so the spawn site matches pre-fix behavior.
43
+ expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: true })).toBe(false);
44
+ expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: false })).toBe(false);
45
+ expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: true })).toBe(false);
46
+ expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: false })).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe("consoleAttachedViaTTY (FFI fallback heuristic)", () => {
51
+ // The OR of three TTY signals correctly classifies the realistic shell
52
+ // redirection scenarios that motivated widening the check beyond stdout
53
+ // in the first review pass (PR #1961). The all-three-redirected case
54
+ // (false here) is the gap that the Win32 FFI probe in
55
+ // `hostHasInheritableConsole` is meant to close — this fallback is best-
56
+ // effort.
57
+
58
+ it("treats a fully interactive launch as console-attached", () => {
59
+ expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
60
+ });
61
+
62
+ it("treats `omp -p '...' > out.txt` (stdout-only redirect) as console-attached", () => {
63
+ // The reviewer's first-pass repro: stdout off the terminal, stdin
64
+ // and stderr still attached. OR keeps the console.
65
+ expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: false, stderrIsTTY: true })).toBe(true);
66
+ });
67
+
68
+ it("treats stdin-only redirects (`< in.txt`) as console-attached", () => {
69
+ expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
70
+ });
71
+
72
+ it("treats stderr-only redirects (`2> err.log`) as console-attached", () => {
73
+ expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: false })).toBe(true);
74
+ });
75
+
76
+ it("returns false only when none of stdin/stdout/stderr is a TTY", () => {
77
+ // This is the gap: a real Windows Terminal session with all three
78
+ // streams redirected (`omp ... < in > out 2> err`) lands here.
79
+ // `hostHasInheritableConsole` uses the Win32 FFI probe to recover
80
+ // the right answer in that scenario; this helper is the fallback.
81
+ expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: false, stderrIsTTY: false })).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe("hostHasInheritableConsole", () => {
86
+ afterEach(() => {
87
+ __resetWindowsConsoleProbeCache();
88
+ });
89
+
90
+ if (process.platform !== "win32") {
91
+ it("matches the TTY-OR fallback off-Windows", () => {
92
+ // Off-Windows, `windowsHide` is a no-op anyway, but we still
93
+ // expose `hostHasInheritableConsole` symmetrically. Confirm it
94
+ // degrades to the same OR the call site would compute by hand.
95
+ const tty = consoleAttachedViaTTY({
96
+ stdinIsTTY: !!process.stdin.isTTY,
97
+ stdoutIsTTY: !!process.stdout.isTTY,
98
+ stderrIsTTY: !!process.stderr.isTTY,
99
+ });
100
+ expect(hostHasInheritableConsole()).toBe(tty);
101
+ });
102
+ }
103
+ });
@@ -8,7 +8,7 @@ import type { ModelRegistry } from "../../config/model-registry";
8
8
  import { Settings } from "../../config/settings";
9
9
  import type { ToolSession } from "../../tools";
10
10
  import { ToolError } from "../../tools/tool-errors";
11
- import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs } from "../heartbeat";
11
+ import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
12
12
  import { IdleTimeout } from "../idle-timeout";
13
13
  import { disposeAllVmContexts } from "../js/context-manager";
14
14
  import { executeJs } from "../js/executor";
@@ -99,7 +99,6 @@ function assistant(opts: {
99
99
  describe("runEvalLlm", () => {
100
100
  afterEach(() => {
101
101
  vi.restoreAllMocks();
102
- setBridgeHeartbeatIntervalMs();
103
102
  });
104
103
 
105
104
  it("resolves each tier to its expected model", async () => {
@@ -217,31 +216,32 @@ describe("runEvalLlm", () => {
217
216
  );
218
217
  });
219
218
 
220
- it("keeps the idle watchdog armed while a slow llm() request is in flight", async () => {
221
- // A oneshot completion emits no status until it returns; a slow request
222
- // must not look like a stalled cell. The bridge pumps a heartbeat while it
223
- // awaits, re-arming the watchdog through emitStatus.
224
- setBridgeHeartbeatIntervalMs(15);
219
+ it("pauses the idle watchdog while a slow llm() request is in flight", async () => {
220
+ // A oneshot completion emits no status until it returns; delegated model
221
+ // time must be invisible to the eval timeout budget.
225
222
  vi.spyOn(ai, "completeSimple").mockImplementation(async () => {
226
223
  await Bun.sleep(200);
227
224
  return assistant({ text: "the answer" });
228
225
  });
229
226
 
227
+ const ops: string[] = [];
230
228
  using idle = new IdleTimeout(60);
231
229
  const result = await runEvalLlm(
232
230
  { prompt: "q", model: "smol" },
233
231
  {
234
232
  session: makeSession(),
235
233
  signal: idle.signal,
236
- // Mirror the eval tool: only a bridge heartbeat re-arms the watchdog.
237
234
  emitStatus: event => {
238
- if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
235
+ ops.push(event.op);
236
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
237
+ if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
239
238
  },
240
239
  },
241
240
  );
242
241
 
243
- expect(idle.signal.aborted).toBe(false);
244
242
  expect(result.text).toBe("the answer");
243
+ expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "llm"]);
244
+ expect(idle.signal.aborted).toBe(false);
245
245
  });
246
246
  });
247
247