@oh-my-pi/pi-coding-agent 15.9.3 → 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 (142) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/keybindings.d.ts +4 -1
  4. package/dist/types/config/settings-schema.d.ts +24 -5
  5. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  6. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  7. package/dist/types/eval/backend.d.ts +6 -6
  8. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  9. package/dist/types/eval/idle-timeout.d.ts +16 -14
  10. package/dist/types/eval/js/executor.d.ts +3 -3
  11. package/dist/types/eval/py/executor.d.ts +2 -2
  12. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +16 -0
  14. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/error-banner.d.ts +11 -0
  17. package/dist/types/modes/components/model-selector.d.ts +1 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/image-references.d.ts +17 -0
  24. package/dist/types/modes/interactive-mode.d.ts +8 -1
  25. package/dist/types/modes/types.d.ts +8 -1
  26. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  27. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  28. package/dist/types/session/blob-store.d.ts +12 -11
  29. package/dist/types/session/session-manager.d.ts +5 -3
  30. package/dist/types/system-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/eval-render.d.ts +8 -0
  35. package/dist/types/tools/render-utils.d.ts +25 -0
  36. package/dist/types/tui/code-cell.d.ts +6 -0
  37. package/dist/types/tui/hyperlink.d.ts +12 -0
  38. package/dist/types/tui/output-block.d.ts +11 -0
  39. package/dist/types/web/search/render.d.ts +1 -2
  40. package/package.json +9 -9
  41. package/src/autoresearch/dashboard.ts +11 -21
  42. package/src/cli/classify-install-target.ts +31 -5
  43. package/src/cli/claude-trace-cli.ts +13 -1
  44. package/src/cli/plugin-cli.ts +45 -0
  45. package/src/cli/web-search-cli.ts +0 -1
  46. package/src/config/keybindings.ts +58 -1
  47. package/src/config/model-registry.ts +54 -4
  48. package/src/config/settings-schema.ts +25 -5
  49. package/src/debug/raw-sse.ts +18 -4
  50. package/src/edit/file-snapshot-store.ts +1 -1
  51. package/src/edit/index.ts +1 -1
  52. package/src/edit/renderer.ts +7 -7
  53. package/src/edit/streaming.ts +1 -1
  54. package/src/eval/__tests__/agent-bridge.test.ts +100 -27
  55. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  56. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  57. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  58. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  59. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  60. package/src/eval/agent-bridge.ts +4 -5
  61. package/src/eval/backend.ts +6 -6
  62. package/src/eval/bridge-timeout.ts +44 -0
  63. package/src/eval/idle-timeout.ts +33 -15
  64. package/src/eval/js/executor.ts +10 -10
  65. package/src/eval/llm-bridge.ts +4 -5
  66. package/src/eval/py/executor.ts +6 -6
  67. package/src/eval/py/kernel.ts +11 -1
  68. package/src/eval/py/spawn-options.ts +126 -0
  69. package/src/eval/py/tool-bridge.ts +43 -5
  70. package/src/export/ttsr.ts +9 -0
  71. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  72. package/src/extensibility/extensions/runner.ts +2 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/lsp/client.ts +80 -2
  75. package/src/lsp/index.ts +38 -4
  76. package/src/lsp/render.ts +3 -3
  77. package/src/main.ts +8 -2
  78. package/src/modes/components/agent-dashboard.ts +13 -4
  79. package/src/modes/components/assistant-message.ts +44 -1
  80. package/src/modes/components/copy-selector.ts +249 -0
  81. package/src/modes/components/custom-editor.ts +14 -2
  82. package/src/modes/components/error-banner.ts +33 -0
  83. package/src/modes/components/extensions/extension-list.ts +17 -8
  84. package/src/modes/components/history-search.ts +19 -11
  85. package/src/modes/components/model-selector.ts +125 -29
  86. package/src/modes/components/oauth-selector.ts +28 -12
  87. package/src/modes/components/session-observer-overlay.ts +13 -15
  88. package/src/modes/components/session-selector.ts +24 -13
  89. package/src/modes/components/tool-execution.ts +71 -13
  90. package/src/modes/components/transcript-container.ts +93 -32
  91. package/src/modes/components/tree-selector.ts +19 -7
  92. package/src/modes/components/user-message-selector.ts +25 -14
  93. package/src/modes/components/user-message.ts +9 -2
  94. package/src/modes/controllers/command-controller.ts +0 -116
  95. package/src/modes/controllers/event-controller.ts +67 -12
  96. package/src/modes/controllers/input-controller.ts +33 -1
  97. package/src/modes/controllers/selector-controller.ts +38 -1
  98. package/src/modes/image-references.ts +111 -0
  99. package/src/modes/interactive-mode.ts +52 -17
  100. package/src/modes/theme/theme.ts +46 -10
  101. package/src/modes/types.ts +11 -2
  102. package/src/modes/utils/copy-targets.ts +254 -0
  103. package/src/modes/utils/ui-helpers.ts +23 -2
  104. package/src/prompts/ci-green-request.md +5 -3
  105. package/src/prompts/system/project-prompt.md +1 -0
  106. package/src/prompts/tools/ast-edit.md +1 -1
  107. package/src/prompts/tools/ast-grep.md +1 -1
  108. package/src/prompts/tools/read.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/sdk.ts +17 -9
  111. package/src/session/agent-session.ts +43 -14
  112. package/src/session/blob-store.ts +96 -9
  113. package/src/session/session-manager.ts +19 -10
  114. package/src/slash-commands/builtin-registry.ts +3 -11
  115. package/src/system-prompt.ts +4 -0
  116. package/src/task/render.ts +38 -11
  117. package/src/tiny/title-client.ts +7 -1
  118. package/src/tool-discovery/mode.ts +24 -0
  119. package/src/tools/archive-reader.ts +339 -31
  120. package/src/tools/bash.ts +18 -8
  121. package/src/tools/browser/render.ts +5 -4
  122. package/src/tools/debug.ts +3 -3
  123. package/src/tools/eval-render.ts +24 -9
  124. package/src/tools/eval.ts +14 -19
  125. package/src/tools/fetch.ts +34 -14
  126. package/src/tools/gh.ts +65 -11
  127. package/src/tools/index.ts +6 -8
  128. package/src/tools/read.ts +65 -19
  129. package/src/tools/render-utils.ts +46 -0
  130. package/src/tools/search-tool-bm25.ts +4 -6
  131. package/src/tools/search.ts +60 -11
  132. package/src/tools/ssh.ts +21 -8
  133. package/src/tools/write.ts +17 -8
  134. package/src/tui/code-cell.ts +19 -4
  135. package/src/tui/hyperlink.ts +42 -7
  136. package/src/tui/output-block.ts +14 -0
  137. package/src/web/search/index.ts +2 -2
  138. package/src/web/search/render.ts +23 -55
  139. package/dist/types/eval/heartbeat.d.ts +0 -45
  140. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  141. package/src/eval/heartbeat.ts +0 -74
  142. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
@@ -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
  */
@@ -38,6 +38,45 @@ const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
38
38
  // "socket connection was closed unexpectedly").
39
39
  const DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
40
40
 
41
+ const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
42
+ const OLLAMA_HOST_DEFAULT_PORT = "11434";
43
+
44
+ function normalizeOllamaHostEnv(value: string | undefined): string | undefined {
45
+ const trimmed = value?.trim();
46
+ if (!trimmed) return undefined;
47
+ const candidate = trimmed.includes("://")
48
+ ? trimmed
49
+ : trimmed.startsWith("//")
50
+ ? `http:${trimmed}`
51
+ : trimmed.startsWith(":")
52
+ ? `http://127.0.0.1${trimmed}`
53
+ : `http://${trimmed}`;
54
+ try {
55
+ const parsed = new URL(candidate);
56
+ if (!parsed.hostname || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
57
+ return undefined;
58
+ }
59
+ if (!parsed.port && parsed.protocol === "http:") {
60
+ parsed.port = OLLAMA_HOST_DEFAULT_PORT;
61
+ }
62
+ return `${parsed.protocol}//${parsed.host}`;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function getImplicitOllamaBaseUrl(): string {
69
+ const baseUrl = Bun.env.OLLAMA_BASE_URL?.trim();
70
+ return baseUrl || normalizeOllamaHostEnv(Bun.env.OLLAMA_HOST) || DEFAULT_OLLAMA_BASE_URL;
71
+ }
72
+
73
+ function getOllamaContextLengthOverride(): number | undefined {
74
+ const value = Bun.env.OLLAMA_CONTEXT_LENGTH?.trim();
75
+ if (!value) return undefined;
76
+ const parsed = Number(value);
77
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
78
+ }
79
+
41
80
  // Anthropic-safe variant of the discovery cap. The Anthropic stream converter
42
81
  // in `packages/ai/src/providers/anthropic.ts` derives the request limit as
43
82
  // `(model.maxTokens / 3) | 0`, so the 32K default would surface as 10,922
@@ -1220,7 +1259,18 @@ export class ModelRegistry {
1220
1259
  return models;
1221
1260
  }
1222
1261
 
1223
- return models.map(model => (model.api === "openai-completions" ? { ...model, api: "openai-responses" } : model));
1262
+ const contextLengthOverride = getOllamaContextLengthOverride();
1263
+ return models.map(model => {
1264
+ const normalized = model.api === "openai-completions" ? { ...model, api: "openai-responses" as const } : model;
1265
+ if (contextLengthOverride === undefined) {
1266
+ return normalized;
1267
+ }
1268
+ return {
1269
+ ...normalized,
1270
+ contextWindow: contextLengthOverride,
1271
+ maxTokens: Math.min(contextLengthOverride, DISCOVERY_DEFAULT_MAX_TOKENS),
1272
+ };
1273
+ });
1224
1274
  }
1225
1275
 
1226
1276
  #addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
@@ -1229,7 +1279,7 @@ export class ModelRegistry {
1229
1279
  this.#discoverableProviders.push({
1230
1280
  provider: "ollama",
1231
1281
  api: "openai-responses",
1232
- baseUrl: Bun.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434",
1282
+ baseUrl: getImplicitOllamaBaseUrl(),
1233
1283
  discovery: { type: "ollama" },
1234
1284
  optional: true,
1235
1285
  });
@@ -1993,12 +2043,12 @@ export class ModelRegistry {
1993
2043
  }
1994
2044
  }
1995
2045
  #normalizeOllamaBaseUrl(baseUrl?: string): string {
1996
- const raw = baseUrl || "http://127.0.0.1:11434";
2046
+ const raw = baseUrl || DEFAULT_OLLAMA_BASE_URL;
1997
2047
  try {
1998
2048
  const parsed = new URL(raw);
1999
2049
  return `${parsed.protocol}//${parsed.host}`;
2000
2050
  } catch {
2001
- return "http://127.0.0.1:11434";
2051
+ return DEFAULT_OLLAMA_BASE_URL;
2002
2052
  }
2003
2053
  }
2004
2054
 
@@ -635,7 +635,7 @@ export const SETTINGS_SCHEMA = {
635
635
  tab: "appearance",
636
636
  label: "Terminal Hyperlinks",
637
637
  description:
638
- "Wrap file paths in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
638
+ "Wrap paths and URLs in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
639
639
  },
640
640
  },
641
641
  // Display rendering
@@ -722,6 +722,16 @@ export const SETTINGS_SCHEMA = {
722
722
  },
723
723
  },
724
724
 
725
+ includeModelInPrompt: {
726
+ type: "boolean",
727
+ default: true,
728
+ ui: {
729
+ tab: "model",
730
+ label: "Include Model In Prompt",
731
+ description: "Surface the active model identifier in the system prompt so the agent knows which model it is",
732
+ },
733
+ },
734
+
725
735
  // Sampling
726
736
  temperature: {
727
737
  type: "number",
@@ -892,6 +902,15 @@ export const SETTINGS_SCHEMA = {
892
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).",
893
903
  },
894
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
+ },
895
914
  "retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
896
915
  "retry.fallbackRevertPolicy": {
897
916
  type: "enum",
@@ -1845,7 +1864,7 @@ export const SETTINGS_SCHEMA = {
1845
1864
  tab: "editing",
1846
1865
  label: "Hash Lines",
1847
1866
  description:
1848
- "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)",
1849
1868
  },
1850
1869
  },
1851
1870
 
@@ -2483,13 +2502,13 @@ export const SETTINGS_SCHEMA = {
2483
2502
  // Tool Discovery
2484
2503
  "tools.discoveryMode": {
2485
2504
  type: "enum",
2486
- values: ["off", "mcp-only", "all"] as const,
2487
- default: "off",
2505
+ values: ["auto", "off", "mcp-only", "all"] as const,
2506
+ default: "auto",
2488
2507
  ui: {
2489
2508
  tab: "tools",
2490
2509
  label: "Tool Discovery",
2491
2510
  description:
2492
- "Hide tools behind a search tool to save tokens. 'mcp-only' hides MCP tools; 'all' hides all non-essential built-ins too.",
2511
+ "Hide tools behind a search tool to save tokens. 'auto' hides MCP tools once the tool set has more than 40 tools; 'mcp-only' always hides MCP tools; 'all' hides all non-essential built-ins too.",
2493
2512
  },
2494
2513
  },
2495
2514
 
@@ -3297,6 +3316,7 @@ export interface RetrySettings {
3297
3316
  maxRetries: number;
3298
3317
  baseDelayMs: number;
3299
3318
  maxDelayMs: number;
3319
+ modelFallback: boolean;
3300
3320
  }
3301
3321
 
3302
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 () => {
@@ -397,6 +396,78 @@ describe("agent() through eval runtimes", () => {
397
396
  expect(maxInFlight).toBeLessThanOrEqual(2);
398
397
  });
399
398
 
399
+ it("interrupting a Python parallel() fan-out settles the kernel cleanly and preserves session state", async () => {
400
+ using tempDir = TempDir.createSync("@omp-eval-agent-py-interrupt-");
401
+ const settings = Settings.isolated({
402
+ "async.enabled": false,
403
+ "task.isolation.mode": "none",
404
+ "task.enableLsp": true,
405
+ "task.maxConcurrency": 6,
406
+ });
407
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
408
+ mockAgents();
409
+ // Subagents that ignore the abort for far longer than the kernel's SIGINT
410
+ // escalation window. Each kernel worker thread blocks in a synchronous
411
+ // `urllib` bridge call, joined by `parallel()`'s ThreadPoolExecutor exit.
412
+ // The host must respond the instant the cell aborts so the kernel can
413
+ // unwind via KeyboardInterrupt instead of being hard-killed (which used to
414
+ // surface "[kernel] Python kernel shutdown" and lose all session state).
415
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
416
+ await Bun.sleep(9000); // deliberately ignores options.signal
417
+ return singleResult(options, { output: options.assignment ?? "" });
418
+ });
419
+
420
+ // Seed persistent session state and confirm the kernel is reusable.
421
+ const seed = await executePython("PREP_MARKER = 4242", {
422
+ cwd: tempDir.path(),
423
+ sessionId,
424
+ sessionFile,
425
+ kernelMode: "session",
426
+ toolSession: session,
427
+ });
428
+ if (seed.exitCode === undefined && seed.cancelled) {
429
+ expect(seed.output).toBe("");
430
+ return; // kernel unavailable in this environment
431
+ }
432
+ expect(seed.exitCode).toBe(0);
433
+
434
+ const ac = new AbortController();
435
+ // Abort ~1s in, after the worker threads are blocked in their bridge calls.
436
+ setTimeout(() => ac.abort(new Error("external interrupt")), 1000);
437
+
438
+ const start = Date.now();
439
+ const result = await executePython(
440
+ "import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
441
+ {
442
+ cwd: tempDir.path(),
443
+ sessionId,
444
+ sessionFile,
445
+ kernelMode: "session",
446
+ toolSession: session,
447
+ idleTimeoutMs: 60_000,
448
+ signal: ac.signal,
449
+ },
450
+ );
451
+ const elapsed = Date.now() - start;
452
+
453
+ // Cancelled, but cleanly: no hard-kill, settled well within the kernel's 5s
454
+ // SIGINT escalation window rather than ~6s after it.
455
+ expect(result.cancelled).toBe(true);
456
+ expect(result.output).not.toContain("Python kernel shutdown");
457
+ expect(elapsed).toBeLessThan(4000);
458
+
459
+ // The persistent kernel survived the interrupt: prior state is intact.
460
+ const after = await executePython("print(PREP_MARKER)", {
461
+ cwd: tempDir.path(),
462
+ sessionId,
463
+ sessionFile,
464
+ kernelMode: "session",
465
+ toolSession: session,
466
+ });
467
+ expect(after.exitCode).toBe(0);
468
+ expect(after.output.trim()).toBe("4242");
469
+ }, 30_000);
470
+
400
471
  it("streams enriched agent progress through onStatus before the cell finishes", async () => {
401
472
  using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
402
473
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
@@ -488,24 +559,20 @@ describe("agent() through eval runtimes", () => {
488
559
  expect(displayAgentEvents.length).toBe(2);
489
560
  });
490
561
 
491
- it("keeps the idle watchdog armed while a quiet agent() runs past the budget", async () => {
492
- using tempDir = TempDir.createSync("@omp-eval-agent-heartbeat-");
493
- 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");
494
565
  mockAgents();
495
- // Heartbeat cadence well under the idle budget so a working-but-silent
496
- // subagent re-arms the watchdog several times before it could expire.
497
- setBridgeHeartbeatIntervalMs(15);
498
566
 
499
- // runSubprocess runs far past the budget and emits NO progress of its own
500
- // the only thing standing between the subagent and a spurious idle abort
501
- // 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.
502
570
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
503
571
  await Bun.sleep(200);
504
572
  return singleResult(options, { output: "done" });
505
573
  });
506
574
 
507
- // Mirror the eval tool's wiring: an IdleTimeout drives cancellation and
508
- // ONLY a bridge heartbeat re-arms it.
575
+ const ops: string[] = [];
509
576
  using idle = new IdleTimeout(60);
510
577
  const result = await runEvalAgent(
511
578
  { prompt: "investigate" },
@@ -513,25 +580,29 @@ describe("agent() through eval runtimes", () => {
513
580
  session,
514
581
  signal: idle.signal,
515
582
  emitStatus: event => {
516
- 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();
517
586
  },
518
587
  },
519
588
  );
520
589
 
521
- expect(idle.signal.aborted).toBe(false);
522
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);
523
596
  });
524
597
 
525
- it("does not let agent() progress snapshots re-arm the watchdog without a heartbeat", async () => {
526
- using tempDir = TempDir.createSync("@omp-eval-agent-progress-no-rearm-");
527
- 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");
528
601
  mockAgents();
529
- // Heartbeat slower than the budget: only the immediate beat at call start
530
- // fires, so after the budget elapses nothing re-arms the watchdog.
531
- setBridgeHeartbeatIntervalMs(10_000);
532
602
 
533
603
  // Stream frequent progress snapshots (op:"agent") for well past the budget.
534
- // 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.
535
606
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
536
607
  for (let i = 0; i < 40; i++) {
537
608
  options.onProgress?.({
@@ -557,21 +628,23 @@ describe("agent() through eval runtimes", () => {
557
628
 
558
629
  const ops: string[] = [];
559
630
  using idle = new IdleTimeout(80);
560
- await runEvalAgent(
631
+ const result = await runEvalAgent(
561
632
  { prompt: "investigate" },
562
633
  {
563
634
  session,
564
635
  signal: idle.signal,
565
636
  emitStatus: event => {
566
637
  ops.push(event.op);
567
- 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();
568
640
  },
569
641
  },
570
642
  );
571
643
 
572
- // Progress streamed, but the watchdog still fired: agent snapshots never
573
- // 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);
574
646
  expect(ops).toContain("agent");
575
- expect(idle.signal.aborted).toBe(true);
647
+ expect(ops.at(-1)).toBe(EVAL_TIMEOUT_RESUME_OP);
648
+ expect(idle.signal.aborted).toBe(false);
576
649
  });
577
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
  });