@oh-my-pi/pi-coding-agent 16.4.2 → 16.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/cli.js +17514 -17799
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/commit/agentic/agent.d.ts +1 -0
  5. package/dist/types/config/settings-schema.d.ts +28 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +10 -22
  7. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  8. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  9. package/dist/types/modes/components/status-line/component.d.ts +3 -0
  10. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  11. package/dist/types/modes/interactive-mode.d.ts +8 -0
  12. package/dist/types/modes/types.d.ts +2 -0
  13. package/dist/types/session/agent-session.d.ts +16 -0
  14. package/dist/types/task/executor.d.ts +31 -0
  15. package/dist/types/tools/__tests__/vibe-render.test.d.ts +1 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  17. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  18. package/dist/types/tools/browser/run-output.d.ts +25 -0
  19. package/dist/types/tools/browser/tab-worker.d.ts +16 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/vibe.d.ts +161 -0
  22. package/dist/types/utils/changelog.d.ts +35 -1
  23. package/dist/types/vibe/runtime.d.ts +124 -0
  24. package/dist/types/vibe/state.d.ts +4 -0
  25. package/dist/types/web/search/provider.d.ts +2 -0
  26. package/dist/types/web/search/providers/bing.d.ts +14 -0
  27. package/dist/types/web/search/providers/browser-headers.d.ts +9 -0
  28. package/dist/types/web/search/providers/browser-page.d.ts +32 -0
  29. package/dist/types/web/search/providers/ecosia.d.ts +14 -0
  30. package/dist/types/web/search/providers/google.d.ts +13 -0
  31. package/dist/types/web/search/providers/mojeek.d.ts +14 -0
  32. package/dist/types/web/search/providers/public.d.ts +37 -0
  33. package/dist/types/web/search/providers/startpage.d.ts +14 -0
  34. package/dist/types/web/search/providers/yahoo.d.ts +14 -0
  35. package/dist/types/web/search/types.d.ts +28 -0
  36. package/package.json +15 -18
  37. package/scripts/build-binary.ts +56 -73
  38. package/scripts/bundle-dist.ts +36 -40
  39. package/scripts/compile-binary.ts +68 -0
  40. package/scripts/generate-docs-index.ts +3 -93
  41. package/scripts/legacy-pi-virtual-module.ts +192 -0
  42. package/src/advisor/__tests__/advisor.test.ts +13 -0
  43. package/src/advisor/__tests__/config.test.ts +36 -0
  44. package/src/advisor/config.ts +11 -8
  45. package/src/commit/agentic/agent.ts +4 -0
  46. package/src/commit/agentic/index.ts +46 -21
  47. package/src/edit/index.ts +10 -76
  48. package/src/exec/non-interactive-env.ts +0 -1
  49. package/src/extensibility/plugins/legacy-pi-compat.ts +328 -162
  50. package/src/extensibility/plugins/legacy-pi-virtual-modules.d.ts +4 -0
  51. package/src/internal-urls/docs-index.ts +2 -1
  52. package/src/internal-urls/skill-protocol.ts +1 -1
  53. package/src/lsp/deferred-diagnostics.ts +66 -0
  54. package/src/main.ts +13 -13
  55. package/src/mcp/transports/stdio.test.ts +45 -1
  56. package/src/mcp/transports/stdio.ts +6 -3
  57. package/src/modes/acp/acp-agent.ts +65 -1
  58. package/src/modes/acp/acp-event-mapper.ts +5 -0
  59. package/src/modes/acp/acp-mode.ts +11 -0
  60. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  61. package/src/modes/components/advisor-config.ts +15 -7
  62. package/src/modes/components/move-overlay.ts +2 -3
  63. package/src/modes/components/status-line/component.ts +6 -0
  64. package/src/modes/components/status-line/segments.ts +6 -0
  65. package/src/modes/components/status-line/types.ts +3 -0
  66. package/src/modes/controllers/command-controller.ts +8 -10
  67. package/src/modes/interactive-mode.ts +117 -1
  68. package/src/modes/types.ts +2 -0
  69. package/src/prompts/system/eager-task.md +2 -2
  70. package/src/prompts/system/system-prompt.md +9 -2
  71. package/src/prompts/system/vibe-mode-active.md +23 -0
  72. package/src/prompts/tools/browser.md +3 -3
  73. package/src/prompts/tools/grep.md +1 -1
  74. package/src/prompts/tools/vibe-kill.md +3 -0
  75. package/src/prompts/tools/vibe-list.md +3 -0
  76. package/src/prompts/tools/vibe-send.md +9 -0
  77. package/src/prompts/tools/vibe-spawn.md +10 -0
  78. package/src/prompts/tools/vibe-turn-result.md +19 -0
  79. package/src/prompts/tools/vibe-wait.md +8 -0
  80. package/src/sdk.ts +5 -0
  81. package/src/session/agent-session.ts +103 -13
  82. package/src/session/snapcompact-inline.ts +3 -19
  83. package/src/slash-commands/builtin-registry.ts +24 -8
  84. package/src/task/agents.ts +0 -2
  85. package/src/task/executor.ts +105 -0
  86. package/src/tools/__tests__/vibe-render.test.ts +210 -0
  87. package/src/tools/bash-skill-urls.ts +1 -1
  88. package/src/tools/browser/cmux/cmux-tab.ts +46 -48
  89. package/src/tools/browser/cmux/rpc.ts +50 -0
  90. package/src/tools/browser/run-output.ts +76 -0
  91. package/src/tools/browser/tab-worker.ts +264 -129
  92. package/src/tools/glob.ts +20 -6
  93. package/src/tools/index.ts +1 -0
  94. package/src/tools/read.ts +17 -9
  95. package/src/tools/renderers.ts +6 -0
  96. package/src/tools/vibe.ts +608 -0
  97. package/src/tools/write.ts +15 -2
  98. package/src/utils/changelog.ts +106 -9
  99. package/src/utils/git.ts +0 -1
  100. package/src/utils/title-generator.ts +70 -7
  101. package/src/vibe/runtime.ts +710 -0
  102. package/src/vibe/state.ts +4 -0
  103. package/src/web/search/index.ts +14 -6
  104. package/src/web/search/provider.ts +37 -1
  105. package/src/web/search/providers/bing.ts +197 -0
  106. package/src/web/search/providers/browser-headers.ts +92 -0
  107. package/src/web/search/providers/browser-page.ts +123 -0
  108. package/src/web/search/providers/duckduckgo.ts +13 -34
  109. package/src/web/search/providers/ecosia.ts +178 -0
  110. package/src/web/search/providers/google.ts +193 -0
  111. package/src/web/search/providers/mojeek.ts +206 -0
  112. package/src/web/search/providers/public.ts +201 -0
  113. package/src/web/search/providers/startpage.ts +213 -0
  114. package/src/web/search/providers/yahoo.ts +179 -0
  115. package/src/web/search/types.ts +35 -0
  116. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +0 -10
  117. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +0 -10
  118. package/scripts/generate-legacy-pi-bundled-registry.ts +0 -420
  119. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +0 -1011
  120. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +0 -3430
  121. package/src/internal-urls/docs-index.generated.txt +0 -2
  122. package/src/prompts/agents/plan.md +0 -47
@@ -34,7 +34,12 @@ import { resolveResumableSession } from "../session/session-listing";
34
34
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
35
35
  import { expandTilde, resolveToCwd } from "../tools/path-utils";
36
36
  import { urlHyperlinkAlways } from "../tui";
37
- import { getChangelogPath, parseChangelog } from "../utils/changelog";
37
+ import {
38
+ getChangelogPath,
39
+ parseChangelog,
40
+ RECENT_CHANGELOG_ENTRY_LIMIT,
41
+ renderChangelogEntries,
42
+ } from "../utils/changelog";
38
43
  import { copyToClipboard } from "../utils/clipboard";
39
44
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
40
45
  import { buildContextReportText } from "./helpers/context-report";
@@ -250,6 +255,22 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
250
255
  runtime.ctx.editor.setText("");
251
256
  },
252
257
  },
258
+ {
259
+ name: "vibe",
260
+ description: "Toggle vibe mode (direct persistent fast/good worker sessions; read-only toolset)",
261
+ inlineHint: "[prompt]",
262
+ allowArgs: true,
263
+ getTuiAutocompleteDescription: runtime => {
264
+ if (runtime.ctx.vibeModeEnabled) return "Vibe: on";
265
+ if (runtime.ctx.planModeEnabled) return "Vibe: blocked by plan mode";
266
+ if (runtime.ctx.goalModeEnabled) return "Vibe: blocked by goal mode";
267
+ return "Vibe: off";
268
+ },
269
+ handleTui: async (command, runtime) => {
270
+ await runtime.ctx.handleVibeModeCommand(command.args || undefined);
271
+ runtime.ctx.editor.setText("");
272
+ },
273
+ },
253
274
  {
254
275
  name: "goal",
255
276
  description: "Toggle goal mode (persistent autonomous objective for this session)",
@@ -1082,17 +1103,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1082
1103
  const changelogPath = getChangelogPath();
1083
1104
  const allEntries = await parseChangelog(changelogPath);
1084
1105
  const showFull = command.args.trim().toLowerCase() === "full";
1085
- const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3);
1106
+ const entriesToShow = showFull ? allEntries : allEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
1086
1107
  if (entriesToShow.length === 0) {
1087
1108
  await runtime.output("No changelog entries found.");
1088
1109
  return commandConsumed();
1089
1110
  }
1090
- await runtime.output(
1091
- [...entriesToShow]
1092
- .reverse()
1093
- .map(entry => entry.content)
1094
- .join("\n\n"),
1095
- );
1111
+ await runtime.output(renderChangelogEntries(entriesToShow).markdown);
1096
1112
  return commandConsumed();
1097
1113
  },
1098
1114
  handleTui: async (command, runtime) => {
@@ -10,7 +10,6 @@ import designerMd from "../prompts/agents/designer.md" with { type: "text" };
10
10
  // Embed agent markdown files at build time
11
11
  import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
12
12
  import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
13
- import planMd from "../prompts/agents/plan.md" with { type: "text" };
14
13
  import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
15
14
  import scoutMd from "../prompts/agents/scout.md" with { type: "text" };
16
15
  import taskMd from "../prompts/agents/task.md" with { type: "text" };
@@ -41,7 +40,6 @@ function buildAgentContent(def: EmbeddedAgentDef): string {
41
40
 
42
41
  const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
43
42
  { fileName: "scout.md", template: scoutMd },
44
- { fileName: "plan.md", template: planMd },
45
43
  { fileName: "designer.md", template: designerMd },
46
44
  { fileName: "reviewer.md", template: reviewerMd },
47
45
  { fileName: "librarian.md", template: librarianMd },
@@ -1880,6 +1880,111 @@ export async function finalizeSubagentLifecycle(args: {
1880
1880
  });
1881
1881
  }
1882
1882
 
1883
+ /** Options for {@link runSubagentFollowUpTurn}. */
1884
+ export interface FollowUpTurnOptions {
1885
+ /** Registry id of the (live or parked) subagent to continue. */
1886
+ id: string;
1887
+ /** Agent definition the session was originally spawned with (drives progress labels + finalize). */
1888
+ agent: AgentDefinition;
1889
+ /** The follow-up message; sent as the turn's user prompt. */
1890
+ message: string;
1891
+ index?: number;
1892
+ description?: string;
1893
+ signal?: AbortSignal;
1894
+ onProgress?: (progress: AgentProgress) => void;
1895
+ eventBus?: EventBus;
1896
+ parentToolCallId?: string;
1897
+ /** When set, the turn's raw output is (re)written to `<artifactsDir>/<id>.md` so `agent://<id>` tracks the latest turn. */
1898
+ artifactsDir?: string;
1899
+ /** Wall-clock cap in ms for this turn; 0 disables. */
1900
+ maxRuntimeMs?: number;
1901
+ }
1902
+
1903
+ /**
1904
+ * Continue a previously spawned (keep-alive) subagent with one more monitored
1905
+ * turn: revive it if parked, send `message` as a real prompt, drive it to
1906
+ * `yield`, and finalize a {@link SingleResult} exactly like a first run.
1907
+ *
1908
+ * The session's full conversation history is retained (live session, or JSONL
1909
+ * replay through the lifecycle reviver), so the turn sees all prior context.
1910
+ * Unlike {@link runSubprocess}, the session is NOT torn down afterwards — it
1911
+ * stays adopted by the {@link AgentLifecycleManager} (idle → TTL park →
1912
+ * revive), and an aborted turn only aborts the in-flight turn.
1913
+ */
1914
+ export async function runSubagentFollowUpTurn(options: FollowUpTurnOptions): Promise<SingleResult> {
1915
+ const { id, agent, message, signal } = options;
1916
+ const index = options.index ?? 0;
1917
+ const startTime = Date.now();
1918
+ const session = await AgentLifecycleManager.global().ensureLive(id);
1919
+ const ref = AgentRegistry.global().get(id);
1920
+ const sessionFile = ref?.sessionFile ?? undefined;
1921
+
1922
+ const monitor = createSubagentRunMonitor({
1923
+ index,
1924
+ id,
1925
+ agent,
1926
+ task: message,
1927
+ description: options.description,
1928
+ signal,
1929
+ onProgress: options.onProgress,
1930
+ eventBus: options.eventBus,
1931
+ parentToolCallId: options.parentToolCallId,
1932
+ detached: true,
1933
+ sessionFile,
1934
+ softRequestBudget: 0,
1935
+ softRequestBudgetNotice: false,
1936
+ maxRuntimeMs: options.maxRuntimeMs ?? 0,
1937
+ });
1938
+
1939
+ if (options.eventBus) {
1940
+ options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
1941
+ id,
1942
+ agent: agent.name,
1943
+ parentToolCallId: options.parentToolCallId,
1944
+ detached: true,
1945
+ agentSource: agent.source,
1946
+ description: options.description,
1947
+ status: "started",
1948
+ sessionFile,
1949
+ index,
1950
+ });
1951
+ }
1952
+
1953
+ monitor.setActiveSession(session);
1954
+ const unsubscribe = monitor.attach(session);
1955
+ let outcome: DriveOutcome;
1956
+ try {
1957
+ outcome = await driveSessionToYield(session, monitor, message);
1958
+ } finally {
1959
+ try {
1960
+ await untilAborted(AbortSignal.timeout(5000), () => monitor.waitForActiveSessionAbort());
1961
+ } catch {
1962
+ // Ignore abort cleanup timeouts; the session stays adopted either way.
1963
+ }
1964
+ unsubscribe();
1965
+ const active = monitor.takeActiveSession();
1966
+ if (active) monitor.captureSalvage(active);
1967
+ monitor.finish();
1968
+ }
1969
+
1970
+ return finalizeRunResult({
1971
+ monitor,
1972
+ done: { ...outcome, abortReason: outcome.abortReasonText, durationMs: Date.now() - startTime },
1973
+ index,
1974
+ id,
1975
+ agent,
1976
+ task: message,
1977
+ description: options.description,
1978
+ signal,
1979
+ artifactsDir: options.artifactsDir,
1980
+ eventBus: options.eventBus,
1981
+ parentToolCallId: options.parentToolCallId,
1982
+ detached: true,
1983
+ sessionFile,
1984
+ startTime,
1985
+ });
1986
+ }
1987
+
1883
1988
  /**
1884
1989
  * Run a single agent in-process.
1885
1990
  */
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Contracts: vibe tool renderers.
3
+ *
4
+ * 1. spawn/send render a mini composer — the message typed into a tiny CLI
5
+ * frame with a prompt glyph and (while pending) a blinking cursor.
6
+ * 2. wait/list render the TV wall: one boxed screen per worker, stacked, a
7
+ * running screen showing its tool-call trace, current tool, and streamed
8
+ * text tail; an idle screen its last-activity gist; a settled screen its
9
+ * delivery footer.
10
+ * 3. Animated content (cursor blink, spinner) re-derives from the shared
11
+ * mutable options on every paint of the SAME component — spinner ticks
12
+ * repaint the block without re-invoking renderCall/renderResult.
13
+ * 4. Every emitted line respects the render width (sanitized, truncated).
14
+ */
15
+ import { beforeAll, describe, expect, it } from "bun:test";
16
+ import { Settings } from "../../config/settings";
17
+ import { getThemeByName, setThemeInstance, type Theme } from "../../modes/theme/theme";
18
+ import type { VibeScreenSnapshot } from "../../vibe/runtime";
19
+ import { createVibeToolRenderer, type VibeToolDetails } from "../vibe";
20
+
21
+ const strip = (lines: readonly string[]): string[] =>
22
+ lines.map(line => line.replace(/\x1b\]8;[^\x1b\x07]*(?:\x07|\x1b\\)/g, "").replace(/\x1b\[[0-9;]*m/g, ""));
23
+
24
+ function makeScreen(overrides: Partial<VibeScreenSnapshot> = {}): VibeScreenSnapshot {
25
+ return {
26
+ id: "Anna",
27
+ cli: "fast",
28
+ state: "running",
29
+ turns: 1,
30
+ queued: 0,
31
+ trace: [],
32
+ outputTail: [],
33
+ lastActivityAt: Date.now(),
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function renderLines(component: { render(width: number): readonly string[] }, width = 100): string[] {
39
+ return strip(component.render(width));
40
+ }
41
+
42
+ describe("vibe tool renderers", () => {
43
+ let uiTheme: Theme;
44
+
45
+ beforeAll(async () => {
46
+ await Settings.init({ inMemory: true });
47
+ const loaded = await getThemeByName("dark");
48
+ if (!loaded) throw new Error("theme unavailable");
49
+ uiTheme = loaded;
50
+ setThemeInstance(uiTheme);
51
+ });
52
+
53
+ it("send composer types the message into a mini CLI frame with a blinking cursor while pending", () => {
54
+ const renderer = createVibeToolRenderer("send");
55
+ const component = renderer.renderCall(
56
+ { session: "Anna", message: "Focus on the API first.\nThen tests." },
57
+ { expanded: false, isPartial: true, spinnerFrame: 0 },
58
+ uiTheme,
59
+ ) as { render(width: number): readonly string[] };
60
+ const text = renderLines(component).join("\n");
61
+
62
+ expect(text).toContain("vibe send → Anna");
63
+ expect(text).toContain("> Focus on the API first.");
64
+ expect(text).toContain("Then tests.▌");
65
+ expect(text).toContain("delivering…");
66
+ // Odd frame: cursor blinks off.
67
+ const off = renderLines(
68
+ renderer.renderCall(
69
+ { session: "Anna", message: "Hi" },
70
+ { expanded: false, isPartial: true, spinnerFrame: 1 },
71
+ uiTheme,
72
+ ) as { render(width: number): readonly string[] },
73
+ ).join("\n");
74
+ expect(off).not.toContain("▌");
75
+ });
76
+
77
+ it("composer cursor re-derives from mutated options on the same component", () => {
78
+ const renderer = createVibeToolRenderer("send");
79
+ const options = { expanded: false, isPartial: true, spinnerFrame: 0 };
80
+ const component = renderer.renderCall({ session: "Anna", message: "Hi" }, options, uiTheme) as {
81
+ render(width: number): readonly string[];
82
+ };
83
+ expect(renderLines(component).join("\n")).toContain("▌");
84
+ // The tool block mutates ONE shared render-state object per spinner tick
85
+ // and repaints — the component must not have baked frame 0 in.
86
+ options.spinnerFrame = 1;
87
+ expect(renderLines(component).join("\n")).not.toContain("▌");
88
+ });
89
+
90
+ it("wait wall spinner re-derives from mutated options on the same component", () => {
91
+ const renderer = createVibeToolRenderer("wait");
92
+ const details: VibeToolDetails = {
93
+ op: "wait",
94
+ screens: [makeScreen({ currentTool: "edit" })],
95
+ wait: { settled: [], stillRunning: ["Anna"], timedOut: false, waiting: true },
96
+ };
97
+ const options = { expanded: false, isPartial: true, spinnerFrame: 0 };
98
+ const component = renderer.renderResult({ content: [{ type: "text", text: "" }], details }, options, uiTheme, {
99
+ sessions: ["Anna"],
100
+ }) as { render(width: number): readonly string[] };
101
+ const first = renderLines(component).join("\n");
102
+ options.spinnerFrame = 5;
103
+ const second = renderLines(component).join("\n");
104
+ // Spinner glyphs for frames 0 and 5 differ; a component that baked its
105
+ // lines at build time would render byte-identical output.
106
+ expect(second).not.toBe(first);
107
+ });
108
+
109
+ it("send result frames the ack under the composer", () => {
110
+ const renderer = createVibeToolRenderer("send");
111
+ const details: VibeToolDetails = {
112
+ op: "send",
113
+ screens: [makeScreen()],
114
+ send: { id: "Anna", mode: "steered" },
115
+ };
116
+ const component = renderer.renderResult(
117
+ { content: [{ type: "text", text: "ack" }], details },
118
+ { expanded: false, isPartial: false },
119
+ uiTheme,
120
+ { session: "Anna", message: "Focus on the API first." },
121
+ ) as { render(width: number): readonly string[] };
122
+ const text = renderLines(component).join("\n");
123
+
124
+ expect(text).toContain("vibe send → Anna");
125
+ expect(text).toContain("> Focus on the API first.");
126
+ expect(text).toContain("steered into the running turn");
127
+ expect(text).not.toContain("▌");
128
+ });
129
+
130
+ it("wait renders stacked TV screens: live trace + streamed text, idle gist, settled footer", () => {
131
+ const renderer = createVibeToolRenderer("wait");
132
+ const details: VibeToolDetails = {
133
+ op: "wait",
134
+ screens: [
135
+ makeScreen({
136
+ id: "Anna",
137
+ cli: "fast",
138
+ state: "running",
139
+ turnStartedAt: Date.now() - 5000,
140
+ turnMessage: "Build the widget",
141
+ trace: ["read(src/foo.ts)", "bash(bun test)"],
142
+ currentTool: "edit",
143
+ lastIntent: "Fixing the parser",
144
+ outputTail: ["The parser now accepts nested arrays"],
145
+ model: "prov/fast-model",
146
+ }),
147
+ makeScreen({ id: "Bob", cli: "good", state: "idle", turns: 2, lastActivity: "turn 2 completed" }),
148
+ ],
149
+ wait: {
150
+ settled: [{ id: "Bob", jobId: "Bob-t2", status: "completed" }],
151
+ stillRunning: ["Anna"],
152
+ timedOut: false,
153
+ },
154
+ };
155
+ const component = renderer.renderResult(
156
+ { content: [{ type: "text", text: "" }], details },
157
+ { expanded: true, isPartial: true, spinnerFrame: 2 },
158
+ uiTheme,
159
+ { sessions: ["Anna", "Bob"] },
160
+ ) as { render(width: number): readonly string[] };
161
+ const lines = renderLines(component);
162
+ const text = lines.join("\n");
163
+
164
+ // One framed screen per worker, stacked.
165
+ expect(lines.filter(line => line.includes("╭─")).length).toBe(2);
166
+ expect(lines.filter(line => line.startsWith("╰─")).length).toBe(2);
167
+ // Live screen: header, typed turn message, trace, current tool, streamed tail.
168
+ expect(text).toContain("Anna");
169
+ // Badge glyphs are theme-driven (⟦fast⟧ on dark); assert the flavor label itself.
170
+ expect(text).toMatch(/fast.\s*Anna/u);
171
+ expect(text).toContain("> Build the widget");
172
+ expect(text).toContain("read(src/foo.ts)");
173
+ expect(text).toContain("bash(bun test)");
174
+ expect(text).toContain("edit: Fixing the parser");
175
+ expect(text).toContain("The parser now accepts nested arrays");
176
+ expect(text).toContain("prov/fast-model");
177
+ // Idle screen + settled footer.
178
+ expect(text).toContain("Bob");
179
+ expect(text).toContain("turn 2 completed");
180
+ expect(text).toContain("turn completed — result delivered");
181
+ // Wall header counts what is on air.
182
+ expect(text).toContain("1 on air");
183
+ });
184
+
185
+ it("clamps every TV line to the render width", () => {
186
+ const renderer = createVibeToolRenderer("list");
187
+ const details: VibeToolDetails = {
188
+ op: "list",
189
+ screens: [
190
+ makeScreen({
191
+ id: "VeryLongSessionNameForTruncation",
192
+ trace: [`read(${"x".repeat(200)})`],
193
+ outputTail: ["y".repeat(300)],
194
+ currentTool: "bash",
195
+ currentToolArgs: "z".repeat(200),
196
+ }),
197
+ ],
198
+ };
199
+ const component = renderer.renderResult(
200
+ { content: [{ type: "text", text: "" }], details },
201
+ { expanded: true, isPartial: false },
202
+ uiTheme,
203
+ {},
204
+ ) as { render(width: number): readonly string[] };
205
+ const width = 48;
206
+ for (const line of renderLines(component, width)) {
207
+ expect(line.length).toBeLessThanOrEqual(width);
208
+ }
209
+ });
210
+ });
@@ -66,7 +66,7 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
66
66
  const hasRelativePath = rawPath !== "" && rawPath !== "/";
67
67
 
68
68
  if (!hasRelativePath) {
69
- return path.resolve(skill.filePath);
69
+ return path.resolve(skill.baseDir);
70
70
  }
71
71
 
72
72
  let relativePath: string;
@@ -3,7 +3,6 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { logger, postmortem, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
5
5
  import { JsRuntime, type RuntimeHooks } from "../../../eval/js/shared/runtime";
6
- import type { JsDisplayOutput } from "../../../eval/js/shared/types";
7
6
  import { callSessionTool } from "../../../eval/js/tool-bridge";
8
7
  import { resizeImage } from "../../../utils/image-resize";
9
8
  import type { ToolSession } from "../../index";
@@ -14,6 +13,7 @@ import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-
14
13
  import { DEFAULT_VIEWPORT } from "../launch";
15
14
  import { extractReadableFromHtml, type ReadableFormat } from "../readable";
16
15
  import { bindBrowserRunFacade, waitForBrowserRun } from "../run-cancellation";
16
+ import { cloneSafe, RunOutput } from "../run-output";
17
17
  import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol";
18
18
  import {
19
19
  type CmuxEvalResult,
@@ -24,7 +24,8 @@ import {
24
24
  cmuxSnapshotToObservation,
25
25
  GEOMETRY_SCRIPT,
26
26
  mapWaitUntil,
27
- serializeEval,
27
+ serializeEvalWithEnvelope,
28
+ unwrapEvalEnvelope,
28
29
  } from "./rpc";
29
30
  import type { CmuxSocketClient } from "./socket-client";
30
31
 
@@ -43,7 +44,7 @@ interface ObserveOptions {
43
44
 
44
45
  interface RunContext {
45
46
  session: SessionSnapshot;
46
- displays: RunResultOk["displays"];
47
+ output: RunOutput;
47
48
  screenshots: ScreenshotResult[];
48
49
  signal: AbortSignal;
49
50
  timeoutMs: number;
@@ -445,10 +446,14 @@ export class CmuxTab {
445
446
  fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
446
447
  ...args: TArgs
447
448
  ): Promise<TResult> {
448
- const result = (await this.#request("browser.eval", {
449
- script: serializeEval(fn as string | ((...args: unknown[]) => unknown), args),
450
- })) as CmuxEvalResult;
451
- return result.value as TResult;
449
+ // A script that throws inside the daemon comes back as a bare
450
+ // `js_error: A JavaScript exception occurred` with no message or stack.
451
+ // Catch page-side instead so the exception is diagnosable, and turn the
452
+ // daemon's other blind spot — Promise return values it cannot
453
+ // serialize — into an actionable error instead of "unsupported type".
454
+ const script = serializeEvalWithEnvelope(fn as string | ((...args: unknown[]) => unknown), args);
455
+ const result = (await this.#request("browser.eval", { script })) as CmuxEvalResult;
456
+ return unwrapEvalEnvelope<TResult>(result.value, "tab.evaluate()");
452
457
  }
453
458
 
454
459
  async scrollIntoView(selector: string): Promise<void> {
@@ -479,10 +484,22 @@ export class CmuxTab {
479
484
 
480
485
  async screenshot(opts: ScreenshotOptions = {}): Promise<ScreenshotResult> {
481
486
  const context = this.#requireRunContext("tab.screenshot()");
487
+ // The cmux daemon's `browser.screenshot` captures the surface viewport
488
+ // only — it has no element-clip or full-page mode, and Bun.Image cannot
489
+ // crop locally. Degrade transparently instead of silently mislabeling
490
+ // the capture: scroll the element into view, then TELL the model the
491
+ // image is the full viewport (reports showed selector captures being
492
+ // consumed as element crops).
493
+ const captureNotes: string[] = [];
482
494
  if (opts.selector) {
483
495
  await this.scrollIntoView(opts.selector);
496
+ captureNotes.push(
497
+ `selector ${JSON.stringify(opts.selector)} was scrolled into view, but this surface cannot clip to an element — the image is the full viewport`,
498
+ );
499
+ }
500
+ if (opts.fullPage) {
501
+ captureNotes.push("fullPage is unavailable on this surface — the image is the viewport only");
484
502
  }
485
- void opts.fullPage;
486
503
  const result = await this.#captureScreenshotPng(context.timeoutMs);
487
504
  const buffer = Buffer.from(result.png_base64, "base64");
488
505
  const captureMime = "image/png";
@@ -528,8 +545,11 @@ export class CmuxTab {
528
545
  dest,
529
546
  resized,
530
547
  });
531
- context.displays.push({ type: "text", text: lines.join("\n") });
532
- context.displays.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
548
+ if (captureNotes.length > 0) {
549
+ lines.push(`[cmux surface: ${captureNotes.join("; ")}]`);
550
+ }
551
+ context.output.push({ type: "text", text: lines.join("\n") });
552
+ context.output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
533
553
  }
534
554
  return info;
535
555
  }
@@ -724,7 +744,12 @@ export class CmuxTab {
724
744
  const callable = (0, eval)("(" + source + ")");
725
745
  return callable(element, ...args);
726
746
  })()`;
727
- return await this.#evalScript<TResult>(script);
747
+ // Envelope so a stale selector or a throwing callback reports its actual
748
+ // error instead of the daemon's generic js_error (see tab.evaluate()).
749
+ const result = (await this.#request("browser.eval", {
750
+ script: serializeEvalWithEnvelope(script, []),
751
+ })) as CmuxEvalResult;
752
+ return unwrapEvalEnvelope<TResult>(result.value, "elementHandle.evaluate()");
728
753
  }
729
754
 
730
755
  async pageContent(): Promise<string> {
@@ -1097,6 +1122,10 @@ class CmuxElementHandle {
1097
1122
  await this.#tab.fill(this.#selector, value);
1098
1123
  }
1099
1124
 
1125
+ async press(key: string): Promise<void> {
1126
+ await this.#tab.press(key, { selector: this.#selector });
1127
+ }
1128
+
1100
1129
  async focus(): Promise<void> {
1101
1130
  await this.#tab.focus(this.#selector);
1102
1131
  }
@@ -1284,10 +1313,10 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1284
1313
  const signal = AbortSignal.any(
1285
1314
  opts.signal ? [timeoutSignal, opts.signal, runAc.signal] : [timeoutSignal, runAc.signal],
1286
1315
  );
1287
- const displays: RunResultOk["displays"] = [];
1316
+ const output = new RunOutput();
1288
1317
  const screenshots: ScreenshotResult[] = [];
1289
1318
  const runId = crypto.randomUUID();
1290
- tab.setRunContext({ session: opts.snapshot, displays, screenshots, signal, timeoutMs: opts.timeoutMs });
1319
+ tab.setRunContext({ session: opts.snapshot, output, screenshots, signal, timeoutMs: opts.timeoutMs });
1291
1320
 
1292
1321
  const { promise: cancelRejection, reject } = Promise.withResolvers<never>();
1293
1322
  // If the synchronous setup below throws (same-realm ownership conflict)
@@ -1329,11 +1358,12 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1329
1358
  const hooks: RuntimeHooks = {
1330
1359
  onText: chunk => {
1331
1360
  throwIfAborted(signal);
1361
+ output.pushText(chunk);
1332
1362
  logger.debug(chunk.replace(/\n$/, ""));
1333
1363
  },
1334
- onDisplay: output => {
1364
+ onDisplay: displayed => {
1335
1365
  throwIfAborted(signal);
1336
- pushDisplay(displays, output);
1366
+ output.pushDisplay(displayed);
1337
1367
  },
1338
1368
  callTool: (name, args) => {
1339
1369
  throwIfAborted(signal);
@@ -1346,7 +1376,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1346
1376
  runtime.run(opts.code, `cmux-run-${runId}.js`, hooks, { runId, cwd: opts.snapshot.cwd }),
1347
1377
  cancelRejection,
1348
1378
  ]);
1349
- return { displays, returnValue: cloneSafe(returnValue), screenshots };
1379
+ return { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots };
1350
1380
  } finally {
1351
1381
  signal.removeEventListener("abort", onAbort);
1352
1382
  runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended")));
@@ -1354,38 +1384,6 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1354
1384
  }
1355
1385
  }
1356
1386
 
1357
- function pushDisplay(displays: RunResultOk["displays"], output: JsDisplayOutput): void {
1358
- if (output.type === "image") {
1359
- displays.push({ type: "image", data: output.data, mimeType: output.mimeType });
1360
- return;
1361
- }
1362
- if (output.type === "json") {
1363
- displays.push({ type: "text", text: safeJsonStringify(output.data) });
1364
- return;
1365
- }
1366
- displays.push({ type: "text", text: safeJsonStringify(output.event) });
1367
- }
1368
-
1369
- function safeJsonStringify(value: unknown): string {
1370
- try {
1371
- return JSON.stringify(value, null, 2);
1372
- } catch {
1373
- return String(value);
1374
- }
1375
- }
1376
-
1377
- function cloneSafe(value: unknown): unknown {
1378
- if (value === undefined) return undefined;
1379
- try {
1380
- structuredClone(value);
1381
- return value;
1382
- } catch {}
1383
- try {
1384
- return JSON.parse(JSON.stringify(value)) as unknown;
1385
- } catch {}
1386
- return String(value);
1387
- }
1388
-
1389
1387
  function numberFrom(value: unknown, fallback: number): number {
1390
1388
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1391
1389
  }
@@ -1,3 +1,4 @@
1
+ import { ToolError } from "../../tool-errors";
1
2
  import type { Observation, ObservationEntry } from "../tab-protocol";
2
3
 
3
4
  export interface CmuxKind {
@@ -120,6 +121,55 @@ export function serializeEval(fn: string | ((...args: unknown[]) => unknown), ar
120
121
  return `(${fn.toString()})(${args.map(arg => JSON.stringify(arg)).join(",")})`;
121
122
  }
122
123
 
124
+ /**
125
+ * Like {@link serializeEval}, but wraps the expression in a page-side
126
+ * try/catch envelope so a throwing script surfaces its message + stack
127
+ * instead of the daemon's opaque `js_error: A JavaScript exception occurred`,
128
+ * and a Promise return (which the daemon cannot serialize) is flagged
129
+ * explicitly rather than failing as "unsupported type".
130
+ *
131
+ * String scripts run through indirect eval to keep global-scope semantics;
132
+ * function sources are already expressions and are invoked directly.
133
+ * `undefined` results come back as `null` (JSON cannot carry `undefined`).
134
+ * Decode with {@link unwrapEvalEnvelope}.
135
+ */
136
+ export function serializeEvalWithEnvelope(fn: string | ((...args: unknown[]) => unknown), args: unknown[]): string {
137
+ const inner = serializeEval(fn, args);
138
+ const expr = typeof fn === "string" ? `(0, eval)(${JSON.stringify(inner)})` : inner;
139
+ return `(() => {
140
+ try {
141
+ const __v = (${expr});
142
+ if (__v && typeof __v.then === "function") return { __ompPromise: true };
143
+ return { __ompOk: __v === undefined ? null : __v };
144
+ } catch (e) {
145
+ return { __ompErr: (e && (e.stack || e.message)) || String(e) };
146
+ }
147
+ })()`;
148
+ }
149
+
150
+ /**
151
+ * Decode a {@link serializeEvalWithEnvelope} result: rethrow page-side
152
+ * exceptions as rich {@link ToolError}s, reject unserializable Promise
153
+ * returns with an actionable message, and pass through values from daemons
154
+ * that did not run the wrapper.
155
+ */
156
+ export function unwrapEvalEnvelope<TResult>(value: unknown, label: string): TResult {
157
+ if (value && typeof value === "object") {
158
+ if ("__ompErr" in value && typeof value.__ompErr === "string") {
159
+ throw new ToolError(`${label} threw a JavaScript exception:\n${value.__ompErr}`);
160
+ }
161
+ if ("__ompPromise" in value && value.__ompPromise === true) {
162
+ throw new ToolError(
163
+ `${label} returned a Promise, but this surface evaluates synchronously and cannot await it — return a plain value (poll with waitForFunction for async state instead)`,
164
+ );
165
+ }
166
+ if ("__ompOk" in value) {
167
+ return value.__ompOk as TResult;
168
+ }
169
+ }
170
+ return value as TResult;
171
+ }
172
+
123
173
  export function mapWaitUntil(waitUntil: string | undefined): "interactive" | "complete" {
124
174
  return waitUntil === "domcontentloaded" ? "interactive" : "complete";
125
175
  }