@oh-my-pi/pi-coding-agent 16.4.6 → 16.5.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 (141) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/cli.js +3312 -3254
  3. package/dist/types/cli/args.d.ts +5 -0
  4. package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +15 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -3
  7. package/dist/types/config/model-roles.d.ts +8 -0
  8. package/dist/types/config/settings-schema.d.ts +46 -14
  9. package/dist/types/extensibility/extensions/types.d.ts +1 -1
  10. package/dist/types/launch/broker.d.ts +2 -0
  11. package/dist/types/launch/client.d.ts +22 -0
  12. package/dist/types/launch/paths.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +8 -0
  14. package/dist/types/launch/protocol.d.ts +170 -0
  15. package/dist/types/launch/terminal-output.d.ts +7 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +1 -1
  17. package/dist/types/modes/components/index.d.ts +1 -0
  18. package/dist/types/modes/components/model-browser.d.ts +28 -3
  19. package/dist/types/modes/components/model-hub.d.ts +0 -10
  20. package/dist/types/modes/components/model-picker.d.ts +44 -0
  21. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  22. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  23. package/dist/types/modes/components/welcome.d.ts +4 -0
  24. package/dist/types/modes/print-mode.d.ts +14 -8
  25. package/dist/types/modes/print-mode.test.d.ts +1 -0
  26. package/dist/types/modes/theme/theme.d.ts +2 -1
  27. package/dist/types/sdk.d.ts +5 -1
  28. package/dist/types/session/agent-session.d.ts +42 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/session/session-entries.d.ts +6 -0
  31. package/dist/types/thinking.d.ts +2 -2
  32. package/dist/types/tiny/models.d.ts +1 -1
  33. package/dist/types/tools/browser/launch.d.ts +1 -0
  34. package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
  35. package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
  36. package/dist/types/tools/browser/tab-worker.d.ts +6 -0
  37. package/dist/types/tools/builtin-names.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +1 -0
  39. package/dist/types/tools/launch.d.ts +121 -0
  40. package/dist/types/tools/render-utils.d.ts +2 -0
  41. package/dist/types/tools/terminal-output.d.ts +5 -0
  42. package/dist/types/vibe/runtime.d.ts +2 -2
  43. package/dist/types/web/search/types.d.ts +0 -8
  44. package/package.json +20 -20
  45. package/src/cli/args.ts +11 -0
  46. package/src/cli/flag-tables.ts +9 -0
  47. package/src/cli/gallery-fixtures/shell.ts +82 -1
  48. package/src/cli.ts +10 -0
  49. package/src/commands/launch.ts +17 -0
  50. package/src/config/model-resolver.ts +110 -31
  51. package/src/config/model-roles.ts +14 -0
  52. package/src/config/settings-schema.ts +71 -7
  53. package/src/edit/renderer.ts +13 -10
  54. package/src/eval/__tests__/agent-bridge.test.ts +2 -2
  55. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  56. package/src/eval/completion-bridge.ts +4 -4
  57. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/extensions/model-api.ts +1 -1
  60. package/src/extensibility/extensions/types.ts +1 -1
  61. package/src/internal-urls/docs-index.ts +4 -4
  62. package/src/launch/broker.ts +1017 -0
  63. package/src/launch/client.ts +344 -0
  64. package/src/launch/paths.ts +17 -0
  65. package/src/launch/presence.ts +82 -0
  66. package/src/launch/protocol.ts +386 -0
  67. package/src/launch/terminal-output.ts +46 -0
  68. package/src/main.ts +50 -1
  69. package/src/modes/acp/acp-agent.ts +8 -1
  70. package/src/modes/components/agent-hub.ts +101 -31
  71. package/src/modes/components/compaction-summary-message.ts +8 -2
  72. package/src/modes/components/index.ts +1 -0
  73. package/src/modes/components/model-browser.ts +152 -49
  74. package/src/modes/components/model-hub.ts +55 -142
  75. package/src/modes/components/model-picker.ts +233 -0
  76. package/src/modes/components/plan-review-overlay.ts +7 -0
  77. package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
  78. package/src/modes/components/status-line/component.test.ts +41 -2
  79. package/src/modes/components/status-line/component.ts +4 -0
  80. package/src/modes/components/status-line/segments.ts +6 -0
  81. package/src/modes/components/status-line/types.ts +3 -0
  82. package/src/modes/components/tips.txt +2 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +9 -1
  85. package/src/modes/controllers/event-controller.ts +31 -32
  86. package/src/modes/controllers/selector-controller.ts +96 -22
  87. package/src/modes/controllers/tan-command-controller.ts +40 -1
  88. package/src/modes/interactive-mode.ts +20 -3
  89. package/src/modes/print-mode.test.ts +71 -0
  90. package/src/modes/print-mode.ts +51 -2
  91. package/src/modes/theme/theme.ts +9 -0
  92. package/src/modes/utils/ui-helpers.ts +25 -4
  93. package/src/prompts/agents/designer.md +1 -1
  94. package/src/prompts/agents/librarian.md +1 -1
  95. package/src/prompts/agents/reviewer.md +1 -1
  96. package/src/prompts/agents/scout.md +1 -1
  97. package/src/prompts/system/plan-yolo-handoff.md +5 -0
  98. package/src/prompts/system/prewalk-checklist.md +7 -0
  99. package/src/prompts/system/prewalk-continue.md +1 -0
  100. package/src/prompts/system/prewalk-plan.md +13 -0
  101. package/src/prompts/system/system-prompt.md +9 -7
  102. package/src/prompts/system/tan-context-switch.md +17 -0
  103. package/src/prompts/tools/bash.md +6 -4
  104. package/src/prompts/tools/browser.md +4 -4
  105. package/src/prompts/tools/launch.md +25 -0
  106. package/src/sdk.ts +8 -2
  107. package/src/session/agent-session.ts +550 -87
  108. package/src/session/session-context.test.ts +10 -5
  109. package/src/session/session-context.ts +25 -8
  110. package/src/session/session-entries.ts +6 -0
  111. package/src/slash-commands/builtin-registry.ts +25 -0
  112. package/src/task/agents.ts +2 -2
  113. package/src/thinking.ts +10 -3
  114. package/src/tiny/models.ts +7 -7
  115. package/src/tools/bash-interactive.ts +5 -8
  116. package/src/tools/bash.ts +38 -24
  117. package/src/tools/browser/cmux/cmux-tab.ts +17 -2
  118. package/src/tools/browser/launch.ts +8 -4
  119. package/src/tools/browser/run-cancellation.ts +66 -6
  120. package/src/tools/browser/tab-protocol.ts +6 -0
  121. package/src/tools/browser/tab-supervisor.ts +17 -1
  122. package/src/tools/browser/tab-worker.ts +140 -21
  123. package/src/tools/builtin-names.ts +1 -0
  124. package/src/tools/eval-render.ts +16 -14
  125. package/src/tools/index.ts +5 -0
  126. package/src/tools/inspect-image.ts +2 -2
  127. package/src/tools/launch.ts +643 -0
  128. package/src/tools/render-utils.ts +3 -0
  129. package/src/tools/renderers.ts +2 -0
  130. package/src/tools/terminal-output.ts +141 -0
  131. package/src/tts/speech-enhancer.ts +2 -2
  132. package/src/utils/image-vision-fallback.ts +3 -3
  133. package/src/vibe/runtime.ts +2 -2
  134. package/src/web/search/provider.ts +0 -10
  135. package/src/web/search/providers/perplexity.ts +18 -2
  136. package/src/web/search/providers/public.ts +2 -4
  137. package/src/web/search/types.ts +0 -10
  138. package/dist/types/web/search/providers/bing.d.ts +0 -14
  139. package/dist/types/web/search/providers/yahoo.d.ts +0 -14
  140. package/src/web/search/providers/bing.ts +0 -197
  141. package/src/web/search/providers/yahoo.ts +0 -179
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
3
  import * as snapcompact from "@oh-my-pi/snapcompact";
4
4
  import type { CompactionSummaryMessage } from "./messages";
5
- import { buildSessionContext } from "./session-context";
5
+ import { buildSessionContext, type StrippedToolCallsMarker } from "./session-context";
6
6
  import type { SessionEntry } from "./session-entries";
7
7
 
8
8
  const timestamp = "2026-07-09T00:00:00.000Z";
@@ -129,12 +129,16 @@ function danglingCallIds(messages: AgentMessage[]): string[] {
129
129
  }
130
130
 
131
131
  describe("buildSessionContext dangling toolCalls", () => {
132
- it("strips a dangling toolCall-only assistant turn from the transcript by default", () => {
132
+ it("strips a dangling toolCall from the transcript but keeps the turn with a stripped marker", () => {
133
133
  const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, { transcript: true });
134
134
 
135
135
  expect(danglingCallIds(context.messages)).toEqual([]);
136
- // The turn had nothing but the dangling call, so the whole message drops.
137
- expect(context.messages.some(message => message.role === "assistant")).toBe(false);
136
+ // The turn survives (even content-less) carrying the marker so the TUI
137
+ // renders a placeholder row instead of silently erasing the activity.
138
+ const assistant = context.messages.find(message => message.role === "assistant");
139
+ expect(assistant).toBeDefined();
140
+ expect(assistant?.content).toEqual([]);
141
+ expect((assistant as AgentMessage & StrippedToolCallsMarker).strippedToolCalls).toBe(1);
138
142
  });
139
143
 
140
144
  it("keeps a dangling toolCall in transcript mode with keepDanglingToolCalls", () => {
@@ -146,11 +150,12 @@ describe("buildSessionContext dangling toolCalls", () => {
146
150
  expect(danglingCallIds(context.messages)).toEqual(["call-1"]);
147
151
  });
148
152
 
149
- it("always strips dangling toolCalls from the LLM context", () => {
153
+ it("always strips dangling toolCalls from the LLM context and drops the emptied turn", () => {
150
154
  const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, {
151
155
  keepDanglingToolCalls: true,
152
156
  });
153
157
 
154
158
  expect(danglingCallIds(context.messages)).toEqual([]);
159
+ expect(context.messages.some(message => message.role === "assistant")).toBe(false);
155
160
  });
156
161
  });
@@ -131,6 +131,16 @@ export interface BuildSessionContextOptions {
131
131
  keepDanglingToolCalls?: boolean;
132
132
  }
133
133
 
134
+ /**
135
+ * Display-only marker set on transcript assistant messages whose dangling
136
+ * `toolCall` blocks were stripped (no paired result on the resolved path —
137
+ * failed/retried turns, results on sibling branches). The TUI renders a
138
+ * placeholder row from it so the turn's activity never silently vanishes.
139
+ */
140
+ export interface StrippedToolCallsMarker {
141
+ strippedToolCalls?: number;
142
+ }
143
+
134
144
  /**
135
145
  * Build the session context from entries using tree traversal.
136
146
  * If leafId is provided, walks from that entry to root.
@@ -353,6 +363,7 @@ export function buildSessionContext(
353
363
  undefined,
354
364
  undefined,
355
365
  snapcompactHistoryBlocksForContext(snapcompactArchive, options),
366
+ entry.warning,
356
367
  ),
357
368
  );
358
369
  } else {
@@ -385,6 +396,7 @@ export function buildSessionContext(
385
396
  providerPayload,
386
397
  undefined,
387
398
  snapcompactHistoryBlocksForContext(snapcompactArchive, options),
399
+ compaction.warning,
388
400
  );
389
401
  // Agent context (non-transcript): summary first so the LLM sees the
390
402
  // compacted context before recent messages.
@@ -468,10 +480,11 @@ export function buildSessionContext(
468
480
  for (let i = messages.length - 1; i >= 0; i--) {
469
481
  const message = messages[i];
470
482
  if (message.role !== "assistant") continue;
471
- const hasDangling = message.content.some(
472
- block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
473
- );
474
- if (!hasDangling) continue;
483
+ let strippedToolCalls = 0;
484
+ for (const block of message.content) {
485
+ if (block.type === "toolCall" && !pairedToolResultIds.has(block.id)) strippedToolCalls++;
486
+ }
487
+ if (strippedToolCalls === 0) continue;
475
488
  const normalized = message.content
476
489
  .filter(
477
490
  block =>
@@ -483,13 +496,17 @@ export function buildSessionContext(
483
496
  ? { ...block, thinkingSignature: undefined }
484
497
  : block,
485
498
  );
486
- if (normalized.length === 0) {
499
+ if (normalized.length === 0 && !options?.transcript) {
487
500
  messages.splice(i, 1);
501
+ } else {
502
+ const rewritten = { ...message, content: normalized };
488
503
  if (options?.transcript) {
489
- cacheMissExplainedAt.splice(i, 1);
504
+ // Display transcript: keep the turn (even content-less) and mark
505
+ // how many calls were dropped so the TUI renders a placeholder
506
+ // row instead of silently erasing the turn's activity.
507
+ (rewritten as AgentMessage & StrippedToolCallsMarker).strippedToolCalls = strippedToolCalls;
490
508
  }
491
- } else {
492
- messages[i] = { ...message, content: normalized };
509
+ messages[i] = rewritten;
493
510
  }
494
511
  }
495
512
  }
@@ -92,6 +92,12 @@ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
92
92
  preserveData?: Record<string, unknown>;
93
93
  /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
94
94
  fromExtension?: boolean;
95
+ /**
96
+ * Dead-end warning from the post-pass progress guard: the pass completed
97
+ * but freed too little for maintenance to continue. Rendered on the
98
+ * compaction divider.
99
+ */
100
+ warning?: string;
95
101
  }
96
102
 
97
103
  export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
@@ -6,6 +6,7 @@ import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
6
6
  import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
7
7
  import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
8
8
  import { CollabHost } from "../collab/host";
9
+ import { expandRoleAlias, getModelMatchPreferences, resolveCliModel } from "../config/model-resolver";
9
10
  import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
10
11
  import type { SettingPath, SettingValue } from "../config/settings";
11
12
  import { settings } from "../config/settings";
@@ -460,6 +461,30 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
460
461
  runtime.ctx.editor.setText("");
461
462
  },
462
463
  },
464
+ {
465
+ name: "prewalk",
466
+ description: "Switch to a fast/cheap model at the next action (works even without --prewalk)",
467
+ acpDescription: "Prewalk at the next action",
468
+ handle: async (_command, runtime) => {
469
+ const rolePattern = expandRoleAlias("@smol", runtime.settings);
470
+ const resolved = resolveCliModel({
471
+ cliModel: rolePattern,
472
+ modelRegistry: runtime.session.modelRegistry,
473
+ preferences: getModelMatchPreferences(runtime.settings),
474
+ });
475
+ if (resolved.error || !resolved.model) {
476
+ return usage(resolved.error ?? `Model "${rolePattern}" not found`, runtime);
477
+ }
478
+ if (!runtime.session.modelRegistry.hasConfiguredAuth(resolved.model)) {
479
+ return usage(`No API key for ${resolved.model.provider}/${resolved.model.id}`, runtime);
480
+ }
481
+ runtime.session.armPrewalk(resolved.model, resolved.thinkingLevel);
482
+ await runtime.output(
483
+ `Prewalk on: switching to ${resolved.model.provider}/${resolved.model.id} at the next edit/write (todo-gated).`,
484
+ );
485
+ return commandConsumed();
486
+ },
487
+ },
463
488
  {
464
489
  name: "advisor",
465
490
  description: "Toggle the advisor (a second model that reviews each turn and injects notes)",
@@ -50,7 +50,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
50
50
  name: "task",
51
51
  description: "General-purpose subagent with full capabilities for delegated multi-step tasks",
52
52
  spawns: "*",
53
- model: "pi/task",
53
+ model: "@task",
54
54
  thinkingLevel: AUTO_THINKING,
55
55
  },
56
56
  template: taskMd,
@@ -60,7 +60,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
60
60
  frontmatter: {
61
61
  name: "sonic",
62
62
  description: "Low-reasoning agent for strictly mechanical updates or data collection only",
63
- model: "pi/smol",
63
+ model: "@smol",
64
64
  thinkingLevel: Effort.Medium,
65
65
  },
66
66
  template: taskMd,
package/src/thinking.ts CHANGED
@@ -62,18 +62,25 @@ const THINKING_LEVEL_BY_SELECTOR: Readonly<Record<string, ThinkingLevel>> = {
62
62
  };
63
63
 
64
64
  function getOwnSelector<T>(selectors: Readonly<Record<string, T>>, value: string | null | undefined): T | undefined {
65
- return value === undefined || value === null || !Object.hasOwn(selectors, value) ? undefined : selectors[value];
65
+ if (value === undefined || value === null) return undefined;
66
+ if (Object.hasOwn(selectors, value)) return selectors[value];
67
+ // Accept unambiguous abbreviations (`xhi` → xhigh, `med` → medium) so every
68
+ // selector surface (`--thinking`, `:suffix`, role values) parses alike.
69
+ // Two-character minimum keeps single letters (`m`) from guessing.
70
+ if (value.length < 2) return undefined;
71
+ const matches = Object.keys(selectors).filter(selector => selector.startsWith(value));
72
+ return matches.length === 1 ? selectors[matches[0]] : undefined;
66
73
  }
67
74
 
68
75
  /**
69
- * Parses a provider-facing effort value.
76
+ * Parses a provider-facing effort value. Accepts unambiguous abbreviations.
70
77
  */
71
78
  export function parseEffort(value: string | null | undefined): Effort | undefined {
72
79
  return getOwnSelector(EFFORT_BY_SELECTOR, value);
73
80
  }
74
81
 
75
82
  /**
76
- * Parses an agent-local thinking selector.
83
+ * Parses an agent-local thinking selector. Accepts unambiguous abbreviations.
77
84
  */
78
85
  export function parseThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined {
79
86
  return getOwnSelector(THINKING_LEVEL_BY_SELECTOR, value);
@@ -1,4 +1,4 @@
1
- /** Default session-title model: the online pi/smol path (no local download / on-device inference). */
1
+ /** Default session-title model: the online @smol path (no local download / on-device inference). */
2
2
  export const ONLINE_TINY_TITLE_MODEL_KEY = "online";
3
3
  /** Local model the `tiny-models` CLI downloads when none is named. Not the session-title default — that is {@link ONLINE_TINY_TITLE_MODEL_KEY}. */
4
4
  export const DEFAULT_TINY_TITLE_LOCAL_MODEL_KEY = "lfm2-700m";
@@ -87,9 +87,9 @@ void TINY_TITLE_MODEL_VALUES_MATCH_REGISTRY;
87
87
  export const TINY_TITLE_MODEL_OPTIONS = [
88
88
  {
89
89
  value: ONLINE_TINY_TITLE_MODEL_KEY,
90
- label: "Online (TINY role, else pi/smol)",
90
+ label: "Online (TINY role, else @smol)",
91
91
  description:
92
- "Online title generation: the TINY model role (set one in /models) when assigned, otherwise the online fallback (commit role, then pi/smol). No local download or on-device inference.",
92
+ "Online title generation: the TINY model role (set one in /models) when assigned, otherwise the online fallback (commit role, then @smol). No local download or on-device inference.",
93
93
  },
94
94
  ...TINY_TITLE_LOCAL_MODELS.map(model => ({
95
95
  value: model.key,
@@ -194,9 +194,9 @@ void TINY_MEMORY_MODEL_VALUES_MATCH_REGISTRY;
194
194
  export const TINY_MEMORY_MODEL_OPTIONS = [
195
195
  {
196
196
  value: ONLINE_MEMORY_MODEL_KEY,
197
- label: "Online (TINY role, else smol)",
197
+ label: "Online (TINY role, else @smol)",
198
198
  description:
199
- "Use the online model: the TINY role from /models when set, otherwise pi/smol. No local model download or on-device inference.",
199
+ "Use the online model: the TINY role from /models when set, otherwise @smol. No local model download or on-device inference.",
200
200
  },
201
201
  ...TINY_MEMORY_LOCAL_MODELS.map(model => ({
202
202
  value: model.key,
@@ -256,9 +256,9 @@ export type AutoThinkingModelKey = TinyMemoryModelKey;
256
256
  export const AUTO_THINKING_MODEL_OPTIONS = [
257
257
  {
258
258
  value: ONLINE_AUTO_THINKING_MODEL_KEY,
259
- label: "Online (TINY role, else smol)",
259
+ label: "Online (TINY role, else @smol)",
260
260
  description:
261
- "Classify prompt difficulty online with the TINY role model (set one in /models) or pi/smol; no local download or on-device inference.",
261
+ "Classify prompt difficulty online with the TINY role model (set one in /models) or @smol; no local download or on-device inference.",
262
262
  },
263
263
  ...TINY_MEMORY_LOCAL_MODELS.map(model => ({
264
264
  value: model.key,
@@ -19,6 +19,7 @@ import { OutputSink, type OutputSummary } from "../session/streaming-output";
19
19
  import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
20
20
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
21
21
  import { formatStatusIcon, replaceTabs } from "./render-utils";
22
+ import { readTerminalRows, styleTerminalRow } from "./terminal-output";
22
23
 
23
24
  export interface BashInteractiveResult extends OutputSummary {
24
25
  exitCode: number | undefined;
@@ -225,14 +226,10 @@ class BashInteractiveOverlayComponent implements Component {
225
226
 
226
227
  #readViewport(innerWidth: number, maxContentRows: number): string[] {
227
228
  this.#terminal.resize(innerWidth, maxContentRows);
228
- const buffer = this.#terminal.buffer.active;
229
- const viewportY = buffer.viewportY;
230
- const visibleLines: string[] = [];
231
- for (let i = 0; i < maxContentRows; i++) {
232
- const line = buffer.getLine(viewportY + i)?.translateToString(true) ?? "";
233
- visibleLines.push(truncateToWidth(replaceTabs(sanitizeText(line)), innerWidth));
234
- }
235
- return visibleLines;
229
+ const viewportY = this.#terminal.buffer.active.viewportY;
230
+ return readTerminalRows(this.#terminal, viewportY, maxContentRows).map(line =>
231
+ truncateToWidth(styleTerminalRow(line, this.uiTheme.getFgAnsi("toolOutput")), innerWidth),
232
+ );
236
233
  }
237
234
  render(width: number): readonly string[] {
238
235
  const safeWidth = Math.max(20, width);
package/src/tools/bash.ts CHANGED
@@ -36,12 +36,18 @@ import {
36
36
  stripRawOutputArtifactNotice,
37
37
  } from "./output-meta";
38
38
  import { resolveToCwd } from "./path-utils";
39
- import { capPreviewLines, formatToolWorkingDirectory, previewWindowRows, replaceTabs } from "./render-utils";
39
+ import {
40
+ capPreviewLines,
41
+ DEFAULT_TERMINAL_PREVIEW_LINES,
42
+ formatToolWorkingDirectory,
43
+ previewWindowRows,
44
+ replaceTabs,
45
+ } from "./render-utils";
40
46
  import { ToolAbortError, ToolError } from "./tool-errors";
41
47
  import { toolResult } from "./tool-result";
42
48
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
43
49
 
44
- export const BASH_DEFAULT_PREVIEW_LINES = 10;
50
+ export const BASH_DEFAULT_PREVIEW_LINES = DEFAULT_TERMINAL_PREVIEW_LINES;
45
51
 
46
52
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
47
53
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
@@ -193,10 +199,9 @@ type ManagedBashJobCompletion =
193
199
 
194
200
  interface ManagedBashJobHandle {
195
201
  jobId: string;
196
- label: string;
197
202
  completion: Promise<ManagedBashJobCompletion>;
198
203
  getLatestText: () => string;
199
- setBackgrounded: (backgrounded: boolean) => void;
204
+ stopUpdates: () => void;
200
205
  }
201
206
 
202
207
  function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
@@ -327,6 +332,10 @@ function formatExitCodeNotice(exitCode: number): string {
327
332
  return `Command exited with code ${exitCode}`;
328
333
  }
329
334
 
335
+ function formatBackgroundNotice(jobId: string): string {
336
+ return `Backgrounded as job ${jobId}; result will be delivered automatically.`;
337
+ }
338
+
330
339
  /**
331
340
  * Strip the trailing occurrence of `notice` (plus a single surrounding newline
332
341
  * on each side) so the TUI can echo the value via a styled footer label
@@ -355,6 +364,11 @@ function stripExitCodeNotice(text: string, exitCode: number | undefined): string
355
364
  return stripTrailingNotice(text, formatExitCodeNotice(exitCode));
356
365
  }
357
366
 
367
+ function stripBackgroundNotice(text: string, async: BashToolDetails["async"] | undefined): string {
368
+ if (async?.state !== "running") return text;
369
+ return stripTrailingNotice(text, formatBackgroundNotice(async.jobId));
370
+ }
371
+
358
372
  /**
359
373
  * Bash tool implementation.
360
374
  *
@@ -389,6 +403,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
389
403
  hasGrep: isToolActive("grep", this.session.settings.get("grep.enabled")),
390
404
  hasGlob: isToolActive("glob", this.session.settings.get("glob.enabled")),
391
405
  hasRead: isToolActive("read", true),
406
+ hasLaunch: isToolActive("launch", this.session.settings.get("launch.enabled")),
392
407
  hasEval: isToolActive(
393
408
  "eval",
394
409
  evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia,
@@ -518,7 +533,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
518
533
 
519
534
  #buildBackgroundStartResult(
520
535
  jobId: string,
521
- label: string,
522
536
  previewText: string,
523
537
  timeoutSec: number | undefined,
524
538
  options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
@@ -542,11 +556,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
542
556
  if (options.notices?.length) {
543
557
  lines.push(...options.notices, "");
544
558
  }
545
- lines.push(`Background job ${jobId} started: ${label}`);
546
- lines.push("Result will be delivered automatically when complete.");
547
- lines.push(
548
- `You can use \`job\` to poll until complete, but prefer to continue with another task in the meanwhile if it's not blocking.`,
549
- );
559
+ lines.push(formatBackgroundNotice(jobId));
550
560
  return {
551
561
  content: [{ type: "text", text: lines.join("\n") }],
552
562
  details,
@@ -567,7 +577,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
567
577
 
568
578
  resolvedEnv?: Record<string, string>;
569
579
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
570
- startBackgrounded: boolean;
580
+ forwardUpdates: boolean;
571
581
  }): ManagedBashJobHandle {
572
582
  const manager = this.session.asyncJobManager;
573
583
  if (!manager) {
@@ -576,7 +586,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
576
586
 
577
587
  const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
578
588
  let latestText = "";
579
- let backgrounded = options.startBackgrounded;
589
+ let forwardUpdates = options.forwardUpdates;
580
590
  const completion = Promise.withResolvers<ManagedBashJobCompletion>();
581
591
 
582
592
  const jobId = manager.register(
@@ -632,11 +642,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
632
642
  },
633
643
  {
634
644
  ownerId: this.session.getAgentId?.() ?? undefined,
635
- onProgress: async (text, details) => {
645
+ onProgress: async text => {
636
646
  latestText = text;
647
+ if (!forwardUpdates) return;
637
648
  await options.onUpdate?.({
638
649
  content: [{ type: "text", text }],
639
- details: backgrounded ? ((details ?? {}) as BashToolDetails) : {},
650
+ details: {},
640
651
  });
641
652
  },
642
653
  },
@@ -644,11 +655,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
644
655
 
645
656
  return {
646
657
  jobId,
647
- label,
648
658
  completion: completion.promise,
649
659
  getLatestText: () => latestText,
650
- setBackgrounded: (nextBackgrounded: boolean) => {
651
- backgrounded = nextBackgrounded;
660
+ stopUpdates: () => {
661
+ forwardUpdates = false;
652
662
  },
653
663
  };
654
664
  }
@@ -813,9 +823,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
813
823
 
814
824
  resolvedEnv,
815
825
  onUpdate,
816
- startBackgrounded: true,
826
+ forwardUpdates: false,
817
827
  });
818
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
828
+ return this.#buildBackgroundStartResult(job.jobId, "", timeoutSec, {
819
829
  requestedTimeoutSec,
820
830
  notices: pendingNotices,
821
831
  });
@@ -851,10 +861,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
851
861
 
852
862
  resolvedEnv,
853
863
  onUpdate,
854
- startBackgrounded,
864
+ forwardUpdates: !startBackgrounded,
855
865
  });
856
866
  if (startBackgrounded) {
857
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
867
+ return this.#buildBackgroundStartResult(job.jobId, "", timeoutSec, {
858
868
  requestedTimeoutSec,
859
869
  notices: pendingNotices,
860
870
  });
@@ -874,9 +884,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
874
884
  autoBgManager.cancel(job.jobId);
875
885
  throw new ToolAbortError(job.getLatestText() || "Command aborted");
876
886
  }
877
- job.setBackgrounded(true);
887
+ job.stopUpdates();
878
888
  autoBgManager.resumeDeliveries([job.jobId]);
879
- return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
889
+ return this.#buildBackgroundStartResult(job.jobId, job.getLatestText(), timeoutSec, {
880
890
  requestedTimeoutSec,
881
891
  notices: pendingNotices,
882
892
  });
@@ -1303,7 +1313,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1303
1313
  ) {
1304
1314
  return cachedLines;
1305
1315
  }
1306
- const strippedOutput = stripOutputNotice(rawOutput, details?.meta);
1316
+ const withoutBackground = stripBackgroundNotice(rawOutput, details?.async);
1317
+ const strippedOutput = stripOutputNotice(withoutBackground, details?.meta);
1307
1318
  const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode);
1308
1319
  const withoutWall = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
1309
1320
  const rawOutputArtifact = stripRawOutputArtifactNotice(withoutWall);
@@ -1317,6 +1328,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1317
1328
  const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
1318
1329
  const wallTimeMs = details?.wallTimeMs;
1319
1330
  const statsParts: string[] = [];
1331
+ if (details?.async?.state === "running") {
1332
+ statsParts.push(`Backgrounded: ${details.async.jobId}`);
1333
+ }
1320
1334
  if (wallTimeMs !== undefined) {
1321
1335
  statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`);
1322
1336
  }
@@ -12,7 +12,12 @@ import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors";
12
12
  import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-snapshot";
13
13
  import { DEFAULT_VIEWPORT } from "../launch";
14
14
  import { extractReadableFromHtml, type ReadableFormat } from "../readable";
15
- import { bindBrowserRunFacade, waitForBrowserRun } from "../run-cancellation";
15
+ import {
16
+ bindBrowserRunFacade,
17
+ resolvePredicateTimeout,
18
+ type WaitPredicateOptions,
19
+ waitForBrowserRun,
20
+ } from "../run-cancellation";
16
21
  import { cloneSafe, RunOutput } from "../run-output";
17
22
  import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol";
18
23
  import {
@@ -1352,7 +1357,17 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1352
1357
  assert: (cond: unknown, text?: string): void => {
1353
1358
  if (!cond) throw new ToolError(text ?? "Assertion failed");
1354
1359
  },
1355
- wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
1360
+ wait: (msOrPredicate: number | (() => unknown), waitOpts?: WaitPredicateOptions): Promise<unknown> =>
1361
+ waitForBrowserRun(
1362
+ msOrPredicate,
1363
+ signal,
1364
+ typeof msOrPredicate === "number"
1365
+ ? waitOpts
1366
+ : {
1367
+ timeout: resolvePredicateTimeout(opts.timeoutMs, waitOpts?.timeout),
1368
+ interval: waitOpts?.interval,
1369
+ },
1370
+ ),
1356
1371
  });
1357
1372
 
1358
1373
  const hooks: RuntimeHooks = {
@@ -121,12 +121,16 @@ async function loadBrowsers(): Promise<typeof BrowsersNs> {
121
121
  }
122
122
 
123
123
  /**
124
- * Lazily download Chromium on first browser launch via @puppeteer/browsers.
125
- * Skipped when a system Chromium (NixOS) or PUPPETEER_EXECUTABLE_PATH is set.
126
- * The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
124
+ * Resolve the Chromium executable puppeteer will launch, lazily downloading it
125
+ * on first use via @puppeteer/browsers. Skipped when a system Chromium (NixOS)
126
+ * or PUPPETEER_EXECUTABLE_PATH is set. The browser is cached under
127
+ * ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when platform
128
+ * detection fails (puppeteer default resolution takes over). Exported so
129
+ * real-browser tests can probe launchability and skip on hosts missing
130
+ * Chrome's system libraries.
127
131
  */
128
132
  let chromiumExecutablePromise: Promise<string | undefined> | undefined;
129
- async function ensureChromiumExecutable(): Promise<string | undefined> {
133
+ export async function ensureChromiumExecutable(): Promise<string | undefined> {
130
134
  const sysChrome = resolveSystemChromium();
131
135
  if (sysChrome) return sysChrome;
132
136
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -1,5 +1,5 @@
1
1
  import { untilAborted } from "@oh-my-pi/pi-utils";
2
- import { throwIfAborted } from "../tool-errors";
2
+ import { ToolError, throwIfAborted } from "../tool-errors";
3
3
 
4
4
  /**
5
5
  * Marks a run-scoped promise as observed without changing its behavior for awaited callers.
@@ -16,12 +16,72 @@ export function markHandled<T>(promise: Promise<T>): Promise<T> {
16
16
  return promise;
17
17
  }
18
18
 
19
- /** Sleeps inside evaluated browser code while honoring the owning run's cancellation signal. */
20
- export function waitForBrowserRun(ms: number, signal: AbortSignal): Promise<void> {
21
- const promise = (async (): Promise<void> => {
22
- throwIfAborted(signal);
23
- await untilAborted(signal, () => Bun.sleep(ms));
19
+ /** Headroom subtracted from the cell budget so an in-run deadline fires before the opaque whole-cell timeout. */
20
+ export const CELL_BUDGET_SLACK_MS = 1_000;
21
+
22
+ /** Default poll deadline for `wait(predicate)` before clamping to the cell budget. */
23
+ export const DEFAULT_PREDICATE_TIMEOUT_MS = 30_000;
24
+
25
+ /** Options for the predicate form of the run-scoped `wait()` helper. */
26
+ export interface WaitPredicateOptions {
27
+ /** Max time to poll before failing, in ms (default 30s, clamped to the cell budget). */
28
+ timeout?: number;
29
+ /** Poll interval in ms (default 100, floor 10). */
30
+ interval?: number;
31
+ }
32
+
33
+ /**
34
+ * Effective `wait(predicate)` deadline for a given cell budget. Always strictly below
35
+ * the cell budget so the named `wait(predicate) timed out` error wins the race against
36
+ * the opaque whole-cell "Browser code execution timed out". `0`/`Infinity` ("disable")
37
+ * map to the largest bounded deadline; negative/NaN garbage falls back to the default.
38
+ */
39
+ export function resolvePredicateTimeout(cellTimeoutMs: number, explicit?: number): number {
40
+ const budgetBound = Math.max(1, cellTimeoutMs - CELL_BUDGET_SLACK_MS);
41
+ if (explicit === 0 || explicit === Number.POSITIVE_INFINITY) return budgetBound;
42
+ if (explicit !== undefined && Number.isFinite(explicit) && explicit > 0) return Math.min(explicit, budgetBound);
43
+ return Math.min(DEFAULT_PREDICATE_TIMEOUT_MS, budgetBound);
44
+ }
45
+
46
+ /**
47
+ * Run-scoped `wait()` helper for evaluated browser code, honoring the owning run's
48
+ * cancellation signal.
49
+ *
50
+ * - `wait(ms)` sleeps for `ms` milliseconds.
51
+ * - `wait(fn, { timeout?, interval? })` polls `fn` (sync or async) until it returns a
52
+ * truthy value and resolves with that value; throws a named `ToolError` on timeout
53
+ * instead of stalling into the whole-cell deadline. Predicate errors propagate.
54
+ */
55
+ export function waitForBrowserRun(
56
+ msOrPredicate: number | (() => unknown),
57
+ signal: AbortSignal,
58
+ opts?: WaitPredicateOptions,
59
+ ): Promise<unknown> {
60
+ const promise = (async (): Promise<unknown> => {
24
61
  throwIfAborted(signal);
62
+ if (typeof msOrPredicate === "number") {
63
+ await untilAborted(signal, async () => await Bun.sleep(msOrPredicate));
64
+ throwIfAborted(signal);
65
+ return undefined;
66
+ }
67
+ if (typeof msOrPredicate !== "function") {
68
+ throw new ToolError("wait(...) expects milliseconds (number) or a predicate function to poll");
69
+ }
70
+ const timeout =
71
+ opts?.timeout !== undefined && Number.isFinite(opts.timeout) && opts.timeout > 0
72
+ ? opts.timeout
73
+ : DEFAULT_PREDICATE_TIMEOUT_MS;
74
+ const interval = Math.max(opts?.interval ?? 100, 10);
75
+ const deadline = Date.now() + timeout;
76
+ for (;;) {
77
+ const value = await untilAborted(signal, async () => await msOrPredicate());
78
+ throwIfAborted(signal);
79
+ if (value) return value;
80
+ if (Date.now() + interval > deadline) {
81
+ throw new ToolError(`wait(predicate) timed out after ${timeout}ms — predicate never returned truthy`);
82
+ }
83
+ await untilAborted(signal, async () => await Bun.sleep(interval));
84
+ }
25
85
  })();
26
86
  return markHandled(promise);
27
87
  }
@@ -59,6 +59,12 @@ export type WorkerInitPayload =
59
59
  safeDir: string;
60
60
  targetId: string;
61
61
  dialogs?: "accept" | "dismiss";
62
+ /**
63
+ * Post-timeout recycle: before adopting the page, dismiss any open JS dialog and
64
+ * stop a pending navigation so a blocked target cannot stall worker init (which
65
+ * previously force-killed the tab). Never set for first-time Electron attach.
66
+ */
67
+ recover?: boolean;
62
68
  };
63
69
 
64
70
  export type ToolReply = { ok: true; value: unknown } | { ok: false; error: RunErrorPayload };