@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.
- package/CHANGELOG.md +42 -0
- package/dist/cli.js +17514 -17799
- package/dist/types/advisor/config.d.ts +4 -3
- package/dist/types/commit/agentic/agent.d.ts +1 -0
- package/dist/types/config/settings-schema.d.ts +28 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +10 -22
- package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
- package/dist/types/modes/components/move-overlay.d.ts +1 -1
- package/dist/types/modes/components/status-line/component.d.ts +3 -0
- package/dist/types/modes/components/status-line/types.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +8 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +16 -0
- package/dist/types/task/executor.d.ts +31 -0
- package/dist/types/tools/__tests__/vibe-render.test.d.ts +1 -0
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
- package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
- package/dist/types/tools/browser/run-output.d.ts +25 -0
- package/dist/types/tools/browser/tab-worker.d.ts +16 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/vibe.d.ts +161 -0
- package/dist/types/utils/changelog.d.ts +35 -1
- package/dist/types/vibe/runtime.d.ts +124 -0
- package/dist/types/vibe/state.d.ts +4 -0
- package/dist/types/web/search/provider.d.ts +2 -0
- package/dist/types/web/search/providers/bing.d.ts +14 -0
- package/dist/types/web/search/providers/browser-headers.d.ts +9 -0
- package/dist/types/web/search/providers/browser-page.d.ts +32 -0
- package/dist/types/web/search/providers/ecosia.d.ts +14 -0
- package/dist/types/web/search/providers/google.d.ts +13 -0
- package/dist/types/web/search/providers/mojeek.d.ts +14 -0
- package/dist/types/web/search/providers/public.d.ts +37 -0
- package/dist/types/web/search/providers/startpage.d.ts +14 -0
- package/dist/types/web/search/providers/yahoo.d.ts +14 -0
- package/dist/types/web/search/types.d.ts +28 -0
- package/package.json +15 -18
- package/scripts/build-binary.ts +56 -73
- package/scripts/bundle-dist.ts +36 -40
- package/scripts/compile-binary.ts +68 -0
- package/scripts/generate-docs-index.ts +3 -93
- package/scripts/legacy-pi-virtual-module.ts +192 -0
- package/src/advisor/__tests__/advisor.test.ts +13 -0
- package/src/advisor/__tests__/config.test.ts +36 -0
- package/src/advisor/config.ts +11 -8
- package/src/commit/agentic/agent.ts +4 -0
- package/src/commit/agentic/index.ts +46 -21
- package/src/edit/index.ts +10 -76
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +328 -162
- package/src/extensibility/plugins/legacy-pi-virtual-modules.d.ts +4 -0
- package/src/internal-urls/docs-index.ts +2 -1
- package/src/internal-urls/skill-protocol.ts +1 -1
- package/src/lsp/deferred-diagnostics.ts +66 -0
- package/src/main.ts +13 -13
- package/src/mcp/transports/stdio.test.ts +45 -1
- package/src/mcp/transports/stdio.ts +6 -3
- package/src/modes/acp/acp-agent.ts +65 -1
- package/src/modes/acp/acp-event-mapper.ts +5 -0
- package/src/modes/acp/acp-mode.ts +11 -0
- package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
- package/src/modes/components/advisor-config.ts +15 -7
- package/src/modes/components/move-overlay.ts +2 -3
- package/src/modes/components/status-line/component.ts +6 -0
- package/src/modes/components/status-line/segments.ts +6 -0
- package/src/modes/components/status-line/types.ts +3 -0
- package/src/modes/controllers/command-controller.ts +8 -10
- package/src/modes/interactive-mode.ts +117 -1
- package/src/modes/types.ts +2 -0
- package/src/prompts/system/eager-task.md +2 -2
- package/src/prompts/system/system-prompt.md +9 -2
- package/src/prompts/system/vibe-mode-active.md +23 -0
- package/src/prompts/tools/browser.md +3 -3
- package/src/prompts/tools/grep.md +1 -1
- package/src/prompts/tools/vibe-kill.md +3 -0
- package/src/prompts/tools/vibe-list.md +3 -0
- package/src/prompts/tools/vibe-send.md +9 -0
- package/src/prompts/tools/vibe-spawn.md +10 -0
- package/src/prompts/tools/vibe-turn-result.md +19 -0
- package/src/prompts/tools/vibe-wait.md +8 -0
- package/src/sdk.ts +5 -0
- package/src/session/agent-session.ts +103 -13
- package/src/session/snapcompact-inline.ts +3 -19
- package/src/slash-commands/builtin-registry.ts +24 -8
- package/src/task/agents.ts +0 -2
- package/src/task/executor.ts +105 -0
- package/src/tools/__tests__/vibe-render.test.ts +210 -0
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser/cmux/cmux-tab.ts +46 -48
- package/src/tools/browser/cmux/rpc.ts +50 -0
- package/src/tools/browser/run-output.ts +76 -0
- package/src/tools/browser/tab-worker.ts +264 -129
- package/src/tools/glob.ts +20 -6
- package/src/tools/index.ts +1 -0
- package/src/tools/read.ts +17 -9
- package/src/tools/renderers.ts +6 -0
- package/src/tools/vibe.ts +608 -0
- package/src/tools/write.ts +15 -2
- package/src/utils/changelog.ts +106 -9
- package/src/utils/git.ts +0 -1
- package/src/utils/title-generator.ts +70 -7
- package/src/vibe/runtime.ts +710 -0
- package/src/vibe/state.ts +4 -0
- package/src/web/search/index.ts +14 -6
- package/src/web/search/provider.ts +37 -1
- package/src/web/search/providers/bing.ts +197 -0
- package/src/web/search/providers/browser-headers.ts +92 -0
- package/src/web/search/providers/browser-page.ts +123 -0
- package/src/web/search/providers/duckduckgo.ts +13 -34
- package/src/web/search/providers/ecosia.ts +178 -0
- package/src/web/search/providers/google.ts +193 -0
- package/src/web/search/providers/mojeek.ts +206 -0
- package/src/web/search/providers/public.ts +201 -0
- package/src/web/search/providers/startpage.ts +213 -0
- package/src/web/search/providers/yahoo.ts +179 -0
- package/src/web/search/types.ts +35 -0
- package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +0 -10
- package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +0 -10
- package/scripts/generate-legacy-pi-bundled-registry.ts +0 -420
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +0 -1011
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +0 -3430
- package/src/internal-urls/docs-index.generated.txt +0 -2
- 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 {
|
|
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,
|
|
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) => {
|
package/src/task/agents.ts
CHANGED
|
@@ -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 },
|
package/src/task/executor.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
return
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
|
1316
|
+
const output = new RunOutput();
|
|
1288
1317
|
const screenshots: ScreenshotResult[] = [];
|
|
1289
1318
|
const runId = crypto.randomUUID();
|
|
1290
|
-
tab.setRunContext({ session: opts.snapshot,
|
|
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:
|
|
1364
|
+
onDisplay: displayed => {
|
|
1335
1365
|
throwIfAborted(signal);
|
|
1336
|
-
pushDisplay(
|
|
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
|
}
|