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

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 (192) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +10 -2
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +43 -7
  22. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  23. package/dist/types/eval/backend.d.ts +6 -6
  24. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  25. package/dist/types/eval/idle-timeout.d.ts +16 -14
  26. package/dist/types/eval/js/executor.d.ts +3 -3
  27. package/dist/types/eval/py/executor.d.ts +2 -2
  28. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  29. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  30. package/dist/types/lsp/types.d.ts +10 -0
  31. package/dist/types/main.d.ts +3 -2
  32. package/dist/types/memory-backend/index.d.ts +2 -1
  33. package/dist/types/memory-backend/resolve.d.ts +1 -1
  34. package/dist/types/memory-backend/types.d.ts +1 -1
  35. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  36. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  37. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  38. package/dist/types/modes/components/model-selector.d.ts +1 -0
  39. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  42. package/dist/types/modes/index.d.ts +5 -4
  43. package/dist/types/modes/interactive-mode.d.ts +2 -2
  44. package/dist/types/modes/setup-version.d.ts +11 -0
  45. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  46. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  47. package/dist/types/modes/types.d.ts +2 -2
  48. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  49. package/dist/types/sdk.d.ts +1 -1
  50. package/dist/types/task/executor.d.ts +7 -0
  51. package/dist/types/telemetry-export.d.ts +1 -1
  52. package/dist/types/tools/eval-render.d.ts +1 -0
  53. package/dist/types/tools/fetch.d.ts +15 -7
  54. package/dist/types/tools/render-utils.d.ts +33 -0
  55. package/dist/types/tools/renderers.d.ts +16 -2
  56. package/dist/types/tools/search.d.ts +1 -1
  57. package/dist/types/tools/write.d.ts +2 -0
  58. package/dist/types/tui/code-cell.d.ts +6 -0
  59. package/dist/types/tui/output-block.d.ts +11 -0
  60. package/dist/types/web/scrapers/github.d.ts +22 -0
  61. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/scripts/dev-launch +42 -0
  65. package/scripts/dev-launch-preload.ts +19 -0
  66. package/src/autoresearch/dashboard.ts +11 -21
  67. package/src/cli/args.ts +2 -2
  68. package/src/cli/claude-trace-cli.ts +13 -1
  69. package/src/cli/gallery-cli.ts +223 -0
  70. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  71. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  72. package/src/cli/gallery-fixtures/edit.ts +194 -0
  73. package/src/cli/gallery-fixtures/fs.ts +153 -0
  74. package/src/cli/gallery-fixtures/index.ts +40 -0
  75. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  76. package/src/cli/gallery-fixtures/memory.ts +81 -0
  77. package/src/cli/gallery-fixtures/misc.ts +221 -0
  78. package/src/cli/gallery-fixtures/search.ts +213 -0
  79. package/src/cli/gallery-fixtures/shell.ts +167 -0
  80. package/src/cli/gallery-fixtures/types.ts +41 -0
  81. package/src/cli/gallery-fixtures/web.ts +158 -0
  82. package/src/cli/gallery-screenshot.ts +279 -0
  83. package/src/cli-commands.ts +1 -0
  84. package/src/commands/gallery.ts +52 -0
  85. package/src/commands/launch.ts +1 -1
  86. package/src/config/keybindings.ts +68 -2
  87. package/src/config/model-equivalence.ts +35 -12
  88. package/src/config/model-id-affixes.ts +39 -22
  89. package/src/config/model-registry.ts +16 -16
  90. package/src/config/settings-schema.ts +29 -6
  91. package/src/config/settings.ts +11 -0
  92. package/src/dap/client.ts +14 -16
  93. package/src/debug/raw-sse.ts +18 -4
  94. package/src/edit/file-snapshot-store.ts +1 -1
  95. package/src/edit/index.ts +1 -1
  96. package/src/edit/renderer.ts +43 -55
  97. package/src/edit/streaming.ts +1 -1
  98. package/src/eval/__tests__/agent-bridge.test.ts +102 -58
  99. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  100. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  101. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  102. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  103. package/src/eval/agent-bridge.ts +38 -12
  104. package/src/eval/backend.ts +6 -6
  105. package/src/eval/bridge-timeout.ts +44 -0
  106. package/src/eval/idle-timeout.ts +33 -15
  107. package/src/eval/js/executor.ts +10 -10
  108. package/src/eval/llm-bridge.ts +4 -5
  109. package/src/eval/py/executor.ts +6 -6
  110. package/src/eval/py/kernel.ts +11 -1
  111. package/src/eval/py/spawn-options.ts +126 -0
  112. package/src/export/ttsr.ts +9 -0
  113. package/src/extensibility/extensions/runner.ts +3 -0
  114. package/src/extensibility/plugins/doctor.ts +0 -1
  115. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  116. package/src/goals/tools/goal-tool.ts +2 -2
  117. package/src/internal-urls/docs-index.generated.ts +7 -6
  118. package/src/lsp/client.ts +179 -52
  119. package/src/lsp/index.ts +38 -4
  120. package/src/lsp/render.ts +3 -3
  121. package/src/lsp/types.ts +10 -0
  122. package/src/main.ts +47 -52
  123. package/src/memory-backend/index.ts +13 -1
  124. package/src/memory-backend/resolve.ts +3 -5
  125. package/src/memory-backend/types.ts +1 -1
  126. package/src/modes/components/agent-dashboard.ts +13 -4
  127. package/src/modes/components/assistant-message.ts +22 -1
  128. package/src/modes/components/copy-selector.ts +249 -0
  129. package/src/modes/components/custom-editor.ts +10 -1
  130. package/src/modes/components/extensions/extension-list.ts +17 -8
  131. package/src/modes/components/history-search.ts +19 -11
  132. package/src/modes/components/model-selector.ts +125 -29
  133. package/src/modes/components/oauth-selector.ts +28 -12
  134. package/src/modes/components/session-observer-overlay.ts +13 -15
  135. package/src/modes/components/session-selector.ts +24 -13
  136. package/src/modes/components/status-line.ts +3 -5
  137. package/src/modes/components/tool-execution.ts +83 -24
  138. package/src/modes/components/tree-selector.ts +19 -7
  139. package/src/modes/components/user-message-selector.ts +25 -14
  140. package/src/modes/controllers/command-controller.ts +13 -118
  141. package/src/modes/controllers/event-controller.ts +26 -10
  142. package/src/modes/controllers/input-controller.ts +11 -3
  143. package/src/modes/controllers/selector-controller.ts +40 -3
  144. package/src/modes/index.ts +5 -4
  145. package/src/modes/interactive-mode.ts +21 -7
  146. package/src/modes/setup-version.ts +11 -0
  147. package/src/modes/setup-wizard/index.ts +3 -2
  148. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  149. package/src/modes/theme/theme.ts +46 -10
  150. package/src/modes/types.ts +2 -2
  151. package/src/modes/utils/context-usage.ts +10 -6
  152. package/src/modes/utils/copy-targets.ts +254 -0
  153. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  154. package/src/prompts/tools/ast-edit.md +1 -1
  155. package/src/prompts/tools/ast-grep.md +1 -1
  156. package/src/prompts/tools/read.md +1 -1
  157. package/src/prompts/tools/search.md +1 -1
  158. package/src/sdk.ts +21 -23
  159. package/src/session/agent-session.ts +13 -9
  160. package/src/slash-commands/builtin-registry.ts +4 -12
  161. package/src/slash-commands/helpers/usage-report.ts +2 -0
  162. package/src/task/executor.ts +20 -2
  163. package/src/task/render.ts +37 -11
  164. package/src/telemetry-export.ts +25 -7
  165. package/src/tools/bash.ts +18 -8
  166. package/src/tools/browser/render.ts +5 -4
  167. package/src/tools/debug.ts +3 -3
  168. package/src/tools/eval-backends.ts +6 -17
  169. package/src/tools/eval-render.ts +28 -10
  170. package/src/tools/eval.ts +19 -23
  171. package/src/tools/fetch.ts +99 -89
  172. package/src/tools/read.ts +7 -7
  173. package/src/tools/render-utils.ts +63 -3
  174. package/src/tools/renderers.ts +16 -1
  175. package/src/tools/report-tool-issue.ts +1 -1
  176. package/src/tools/search.ts +173 -81
  177. package/src/tools/ssh.ts +21 -8
  178. package/src/tools/todo.ts +20 -7
  179. package/src/tools/write.ts +39 -9
  180. package/src/tui/code-cell.ts +19 -4
  181. package/src/tui/output-block.ts +14 -0
  182. package/src/web/scrapers/github.ts +255 -3
  183. package/src/web/scrapers/youtube.ts +3 -2
  184. package/src/web/search/providers/perplexity.ts +199 -51
  185. package/src/web/search/render.ts +42 -57
  186. package/src/web/search/types.ts +5 -1
  187. package/dist/types/eval/heartbeat.d.ts +0 -45
  188. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  189. package/src/eval/__tests__/shared-executors.test.ts +0 -609
  190. package/src/eval/heartbeat.ts +0 -74
  191. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
  192. /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
@@ -15,23 +15,16 @@ import {
15
15
  formatMoreItems,
16
16
  formatStatusIcon,
17
17
  getDomain,
18
- getPreviewLines,
19
18
  PREVIEW_LIMITS,
20
- TRUNCATE_LENGTHS,
19
+ replaceTabs,
21
20
  truncateToWidth,
22
21
  } from "../../tools/render-utils";
23
- import { renderStatusLine, renderTreeList } from "../../tui";
24
- import { CachedOutputBlock } from "../../tui/output-block";
22
+ import { renderStatusLine, renderTreeList, urlHyperlink } from "../../tui";
23
+ import { CachedOutputBlock, markFramedBlockComponent } from "../../tui/output-block";
25
24
  import { getSearchProviderLabel } from "./provider";
26
25
  import type { SearchResponse } from "./types";
27
26
 
28
- const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
29
- const MAX_SNIPPET_LINES = 2;
30
- const MAX_SNIPPET_LINE_LEN = TRUNCATE_LENGTHS.LINE;
31
27
  const MAX_COLLAPSED_ITEMS = PREVIEW_LIMITS.COLLAPSED_ITEMS;
32
- const MAX_QUERY_PREVIEW = 2;
33
- const MAX_QUERY_LEN = 90;
34
- const MAX_REQUEST_ID_LEN = 36;
35
28
 
36
29
  function renderFallbackText(contentText: string, expanded: boolean, theme: Theme): Component {
37
30
  const lines = contentText.split("\n").filter(line => line.trim());
@@ -66,6 +59,21 @@ export interface SearchRenderDetails {
66
59
  error?: string;
67
60
  }
68
61
 
62
+ /** Render a web search failure as a framed error panel, matching the success layout. */
63
+ function renderSearchErrorPanel(message: string, providerLabel: string | undefined, theme: Theme): Component {
64
+ const header = renderStatusLine({ icon: "error", title: "Web Search", description: providerLabel }, theme);
65
+ const body = theme.fg("error", `Error: ${replaceTabs(message)}`);
66
+ const outputBlock = new CachedOutputBlock();
67
+ return markFramedBlockComponent({
68
+ render(width: number): string[] {
69
+ return outputBlock.render({ header, state: "error", sections: [{ lines: [body] }], width }, theme);
70
+ },
71
+ invalidate() {
72
+ outputBlock.invalidate();
73
+ },
74
+ });
75
+ }
76
+
69
77
  /** Render web search result with tree-based layout */
70
78
  export function renderSearchResult(
71
79
  result: { content: Array<{ type: string; text?: string }>; details?: SearchRenderDetails },
@@ -78,9 +86,12 @@ export function renderSearchResult(
78
86
  ): Component {
79
87
  const details = result.details;
80
88
 
81
- // Handle error case
89
+ // Handle error case as a framed panel, matching the success layout.
82
90
  if (details?.error) {
83
- return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
91
+ const errorProvider = details.response?.provider;
92
+ const errorProviderLabel =
93
+ errorProvider && errorProvider !== "none" ? getSearchProviderLabel(errorProvider) : undefined;
94
+ return renderSearchErrorPanel(details.error, errorProviderLabel, theme);
84
95
  }
85
96
 
86
97
  const rawText = result.content?.find(block => block.type === "text")?.text?.trim() ?? "";
@@ -91,8 +102,6 @@ export function renderSearchResult(
91
102
 
92
103
  const sources = Array.isArray(response.sources) ? response.sources : [];
93
104
  const sourceCount = sources.length;
94
- const citations = Array.isArray(response.citations) ? response.citations : [];
95
- const citationCount = citations.length;
96
105
  const searchQueries = Array.isArray(response.searchQueries)
97
106
  ? response.searchQueries.filter(item => typeof item === "string")
98
107
  : [];
@@ -118,16 +127,11 @@ export function renderSearchResult(
118
127
  theme,
119
128
  );
120
129
 
121
- const metaLines: string[] = [];
122
- metaLines.push(`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerLabel)}`);
123
- if (response.authMode)
124
- metaLines.push(
125
- `${theme.fg("muted", "Auth:")} ${theme.fg("text", response.authMode === "oauth" ? "OAuth" : response.authMode === "api_key" ? "API key" : response.authMode)}`,
126
- );
127
- if (response.model) metaLines.push(`${theme.fg("muted", "Model:")} ${theme.fg("text", response.model)}`);
128
- metaLines.push(`${theme.fg("muted", "Sources:")} ${theme.fg("text", String(sourceCount))}`);
129
- if (citationCount > 0)
130
- metaLines.push(`${theme.fg("muted", "Citations:")} ${theme.fg("text", String(citationCount))}`);
130
+ const authShort =
131
+ response.authMode === "oauth" ? "OAuth" : response.authMode === "api_key" ? "API" : response.authMode;
132
+ let providerInfo = response.model ? `${response.model} @ ${providerLabel}` : providerLabel;
133
+ if (authShort) providerInfo += ` (${authShort})`;
134
+ const metaLines: string[] = [`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerInfo)}`];
131
135
  if (response.usage) {
132
136
  const usageParts: string[] = [];
133
137
  if (response.usage.inputTokens !== undefined) usageParts.push(`in ${response.usage.inputTokens}`);
@@ -137,22 +141,11 @@ export function renderSearchResult(
137
141
  if (usageParts.length > 0)
138
142
  metaLines.push(`${theme.fg("muted", "Usage:")} ${theme.fg("text", usageParts.join(theme.sep.dot))}`);
139
143
  }
140
- if (response.requestId) {
141
- metaLines.push(
142
- `${theme.fg("muted", "Request:")} ${theme.fg("text", truncateToWidth(response.requestId, MAX_REQUEST_ID_LEN))}`,
143
- );
144
- }
145
- if (searchQueries.length > 0) {
146
- const queriesPreview = searchQueries.slice(0, MAX_QUERY_PREVIEW);
147
- const queryList = queriesPreview.map(q => truncateToWidth(q, MAX_QUERY_LEN));
148
- const suffix = searchQueries.length > queriesPreview.length ? "…" : "";
149
- metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`);
150
- }
151
144
 
152
145
  const answerMarkdown = contentText ? new Markdown(contentText, 0, 0, getMarkdownTheme()) : undefined;
153
146
  const outputBlock = new CachedOutputBlock();
154
147
 
155
- return {
148
+ return markFramedBlockComponent({
156
149
  render(width: number): string[] {
157
150
  // Read mutable state at render time
158
151
  const { expanded } = options;
@@ -163,15 +156,15 @@ export function renderSearchResult(
163
156
  let answerLines: string[];
164
157
  if (renderedAnswer.length === 0) {
165
158
  answerLines = [theme.fg("muted", "No answer text returned")];
166
- } else if (expanded) {
167
- answerLines = renderedAnswer;
168
- } else {
169
- const collapsedCap = args?.maxAnswerLines ?? MAX_COLLAPSED_ANSWER_LINES;
170
- answerLines = renderedAnswer.slice(0, collapsedCap);
159
+ } else if (args?.maxAnswerLines !== undefined && !expanded) {
160
+ // CLI compact mode (`omp q`) caps the answer; the TUI passes no cap and shows it in full.
161
+ answerLines = renderedAnswer.slice(0, args.maxAnswerLines);
171
162
  const remaining = renderedAnswer.length - answerLines.length;
172
163
  if (remaining > 0) {
173
164
  answerLines.push(theme.fg("muted", formatMoreItems(remaining, "line")));
174
165
  }
166
+ } else {
167
+ answerLines = renderedAnswer;
175
168
  }
176
169
 
177
170
  const sourceTree = renderTreeList(
@@ -187,30 +180,22 @@ export function renderSearchResult(
187
180
  : typeof src.url === "string" && src.url.trim()
188
181
  ? src.url
189
182
  : "Untitled";
190
- const title = truncateToWidth(titleText, MAX_SNIPPET_LINE_LEN);
191
183
  const url = typeof src.url === "string" ? src.url : "";
192
184
  const domain = url ? getDomain(url) : "";
193
185
  const age =
194
186
  formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : "");
195
187
  const metaParts: string[] = [];
196
188
  if (domain) metaParts.push(theme.fg("dim", `(${domain})`));
197
- if (typeof src.author === "string" && src.author.trim())
198
- metaParts.push(theme.fg("muted", truncateToWidth(src.author.trim(), 40)));
199
189
  if (age) metaParts.push(theme.fg("muted", age));
200
190
  const metaSep = theme.fg("dim", theme.sep.dot);
201
191
  const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : "";
202
- const srcLines: string[] = [
203
- truncateToWidth(`${theme.fg("accent", title)}${metaSuffix}`, MAX_SNIPPET_LINE_LEN),
204
- ];
205
- const snippetText = typeof src.snippet === "string" ? src.snippet : "";
206
- if (snippetText.trim()) {
207
- const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_LEN);
208
- for (const snippetLine of snippetLines) {
209
- srcLines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`));
210
- }
211
- }
212
- if (url) srcLines.push(theme.fg("mdLinkUrl", truncateToWidth(url, MAX_SNIPPET_LINE_LEN)));
213
- return srcLines;
192
+ // One line per source: the title links to its URL, followed by domain · age.
193
+ // Reserve room for the box borders, the tree branch, and the meta suffix.
194
+ const lineBudget = Math.max(24, width - 6);
195
+ const titleBudget = Math.max(12, lineBudget - Bun.stringWidth(metaSuffix));
196
+ const title = theme.fg("accent", truncateToWidth(titleText, titleBudget));
197
+ const linkedTitle = url ? urlHyperlink(url, title) : title;
198
+ return [`${linkedTitle}${metaSuffix}`];
214
199
  },
215
200
  },
216
201
  theme,
@@ -246,7 +231,7 @@ export function renderSearchResult(
246
231
  invalidate() {
247
232
  outputBlock.invalidate();
248
233
  },
249
- };
234
+ });
250
235
  }
251
236
 
252
237
  /** Render web search call (query preview) */
@@ -33,7 +33,11 @@ export const SEARCH_PROVIDER_OPTIONS = [
33
33
  description: "Automatically uses the first configured web-search provider",
34
34
  },
35
35
  { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" },
36
- { value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_COOKIES or PERPLEXITY_API_KEY" },
36
+ {
37
+ value: "perplexity",
38
+ label: "Perplexity",
39
+ description: "Uses auth when configured; explicit selection falls back to anonymous search",
40
+ },
37
41
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
38
42
  { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
39
43
  { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
@@ -1,45 +0,0 @@
1
- /**
2
- * Keepalive for in-flight host-side eval bridge calls.
3
- *
4
- * The eval watchdog ({@link ../tools/eval IdleTimeout}) caps a cell's `timeout`
5
- * as a wall-clock budget on the cell's *own* work, but pauses that budget while
6
- * a host-side `agent()`/`parallel()` (via `runSubprocess`) or `llm()` (a single
7
- * completion) call is in flight. Those calls are the only thing that re-arms the
8
- * watchdog — and they can run for long stretches with **no** status of their own
9
- * (a subagent's time-to-first-token on a reasoning model, a long quiet nested
10
- * tool, or the entire body of a oneshot `llm()` call). Without a keepalive the
11
- * watchdog would mistake that delegated work for the cell stalling and abort it
12
- * mid-flight, killing the subagent.
13
- *
14
- * {@link withBridgeHeartbeat} bridges that gap by emitting a synthetic
15
- * {@link EVAL_HEARTBEAT_OP} status event immediately when the call begins and
16
- * then on a fixed cadence until it settles. The event rides the same
17
- * `emitStatus → onStatus` channel both runtimes already forward, so it re-arms
18
- * the watchdog without any new plumbing. The heartbeat is the *sole* signal that
19
- * extends the budget: consumers MUST treat it as a pure keepalive — bump the
20
- * watchdog and drop it (never persist or render it) — see the executor display
21
- * sinks and the eval tool's `onStatus` handler. Every other status event
22
- * (compute helpers, `log()`/`phase()`, tool results) counts against the budget.
23
- */
24
- import type { JsStatusEvent } from "./js/shared/types";
25
- /**
26
- * Synthetic status op emitted purely to keep the eval idle watchdog alive while
27
- * a host-side bridge call is in flight. Carries no payload.
28
- */
29
- export declare const EVAL_HEARTBEAT_OP = "heartbeat";
30
- /**
31
- * Test seam: override the heartbeat cadence so integration tests can exercise
32
- * the keepalive within a sub-second idle budget. Pass no value to restore the
33
- * production default.
34
- */
35
- export declare function setBridgeHeartbeatIntervalMs(ms?: number): void;
36
- /**
37
- * Run {@link operation}, pumping {@link EVAL_HEARTBEAT_OP} status events through
38
- * {@link emitStatus} — one immediately, then on a fixed cadence — until it
39
- * settles. The immediate beat pauses the watchdog the instant the call begins,
40
- * so a bridge call that starts close to the budget edge (after the cell already
41
- * spent most of it computing) is not aborted before the first interval tick. A
42
- * no-op wrapper when no `emitStatus` sink is wired (the heartbeat would reach
43
- * nobody).
44
- */
45
- export declare function withBridgeHeartbeat<T>(emitStatus: ((event: JsStatusEvent) => void) | undefined, operation: () => Promise<T>): Promise<T>;
@@ -1,84 +0,0 @@
1
- import { afterEach, describe, expect, it } from "bun:test";
2
- import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs, withBridgeHeartbeat } from "../heartbeat";
3
- import type { JsStatusEvent } from "../js/shared/types";
4
-
5
- describe("withBridgeHeartbeat", () => {
6
- afterEach(() => {
7
- setBridgeHeartbeatIntervalMs();
8
- });
9
-
10
- it("pumps heartbeat events on cadence while the operation is pending, then stops", async () => {
11
- setBridgeHeartbeatIntervalMs(20);
12
- const events: JsStatusEvent[] = [];
13
-
14
- const value = await withBridgeHeartbeat(
15
- event => events.push(event),
16
- async () => {
17
- await Bun.sleep(130);
18
- return "done";
19
- },
20
- );
21
-
22
- expect(value).toBe("done");
23
- // ~6 ticks fit in 130ms at a 20ms cadence; assert it ticked repeatedly
24
- // without pinning the exact count (scheduler jitter).
25
- expect(events.length).toBeGreaterThanOrEqual(3);
26
- expect(events.every(event => event.op === EVAL_HEARTBEAT_OP)).toBe(true);
27
-
28
- // The interval is cleared once the operation settles: no further ticks.
29
- const settledCount = events.length;
30
- await Bun.sleep(80);
31
- expect(events.length).toBe(settledCount);
32
- });
33
-
34
- it("emits a heartbeat immediately so a bridge call extends the budget at once", async () => {
35
- // Interval far longer than the operation: the only beat that can fire is
36
- // the immediate one at call start. It must still reach the sink.
37
- setBridgeHeartbeatIntervalMs(10_000);
38
- const events: JsStatusEvent[] = [];
39
-
40
- await withBridgeHeartbeat(
41
- event => events.push(event),
42
- async () => {
43
- await Bun.sleep(30);
44
- return "done";
45
- },
46
- );
47
-
48
- expect(events.length).toBe(1);
49
- expect(events[0]?.op).toBe(EVAL_HEARTBEAT_OP);
50
- });
51
-
52
- it("runs the operation without emitting when no status sink is wired", async () => {
53
- setBridgeHeartbeatIntervalMs(5);
54
- let ran = 0;
55
-
56
- const value = await withBridgeHeartbeat(undefined, async () => {
57
- ran++;
58
- await Bun.sleep(40);
59
- return 42;
60
- });
61
-
62
- expect(value).toBe(42);
63
- expect(ran).toBe(1);
64
- });
65
-
66
- it("clears the heartbeat even when the operation throws", async () => {
67
- setBridgeHeartbeatIntervalMs(15);
68
- const events: JsStatusEvent[] = [];
69
-
70
- await expect(
71
- withBridgeHeartbeat(
72
- event => events.push(event),
73
- async () => {
74
- await Bun.sleep(60);
75
- throw new Error("boom");
76
- },
77
- ),
78
- ).rejects.toThrow("boom");
79
-
80
- const afterThrow = events.length;
81
- await Bun.sleep(60);
82
- expect(events.length).toBe(afterThrow);
83
- });
84
- });