@tt-a1i/openpi 0.3.1 → 0.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.
- package/README.md +184 -59
- package/SETUP.md +23 -7
- package/assets/openpi-launch-card-v1.webp +0 -0
- package/bin/openpi.js +145 -0
- package/extensions/ask-user/index.ts +30 -14
- package/extensions/background-terminals/index.ts +30 -2
- package/extensions/background-terminals/src/domain.ts +2 -0
- package/extensions/background-terminals/src/manager.ts +486 -106
- package/extensions/background-terminals/src/output.ts +33 -0
- package/extensions/background-terminals/src/prompt.ts +14 -6
- package/extensions/background-terminals/src/result-delivery.ts +4 -1
- package/extensions/background-terminals/src/ui/ps.ts +132 -129
- package/extensions/capabilities/index.ts +30 -42
- package/extensions/capabilities/src/ui.ts +93 -0
- package/extensions/clear-context/index.ts +83 -0
- package/extensions/context-pivot/index.ts +16 -6
- package/extensions/cron/schedule.ts +7 -1
- package/extensions/file-mutation-display/index.ts +34 -76
- package/extensions/file-mutation-display/render.ts +146 -87
- package/extensions/file-search/index.ts +8 -7
- package/extensions/file-search/src/binaries.ts +75 -59
- package/extensions/git-info/src/changed-files-view.ts +47 -14
- package/extensions/git-read/index.ts +328 -0
- package/extensions/git-read/src/args.ts +171 -0
- package/extensions/git-read/src/process.ts +81 -0
- package/extensions/git-read/src/prompt.ts +56 -0
- package/extensions/model-info/index.ts +21 -33
- package/extensions/model-info/session-metrics.ts +96 -0
- package/extensions/plan-mode/bash-policy.ts +54 -9
- package/extensions/plan-mode/index.ts +7 -2
- package/extensions/post-edit/index.ts +16 -6
- package/extensions/sessions/git-stats.ts +258 -72
- package/extensions/sessions/index.ts +222 -140
- package/extensions/sessions/preview-cache.ts +104 -0
- package/extensions/sessions/preview-loader.ts +856 -0
- package/extensions/sessions/sessions.ts +43 -4
- package/extensions/setup/index.ts +127 -131
- package/extensions/shared/activity-status.ts +36 -5
- package/extensions/shared/agent-session-page.ts +319 -0
- package/extensions/shared/agent-tool-renderer.ts +218 -0
- package/extensions/shared/agent-transcript.ts +524 -0
- package/extensions/shared/below-editor-navigation.ts +26 -0
- package/extensions/shared/capability-intent.ts +53 -0
- package/extensions/shared/child-session.ts +444 -22
- package/extensions/shared/result-budget.ts +134 -0
- package/extensions/shared/result-delivery.ts +34 -0
- package/extensions/shared/screen-chrome.ts +133 -0
- package/extensions/shared/setup-config.ts +97 -38
- package/extensions/shared/setup-episode-state.ts +1 -1
- package/extensions/shared/spinner.ts +28 -0
- package/extensions/shared/terminal-text.ts +110 -23
- package/extensions/shared/text-projection.ts +113 -0
- package/extensions/shared/tool-activity.ts +382 -0
- package/extensions/shared/tool-surface.ts +42 -8
- package/extensions/shared/transcript-viewport.ts +46 -0
- package/extensions/shared/web-observer-registry.ts +390 -0
- package/extensions/shared/worktree.ts +11 -0
- package/extensions/subagents/index.ts +461 -186
- package/extensions/subagents/navigation.ts +86 -28
- package/extensions/subagents/src/agent-types.ts +37 -15
- package/extensions/subagents/src/backend.ts +12 -1
- package/extensions/subagents/src/backends/pi.ts +375 -66
- package/extensions/subagents/src/domain.ts +5 -0
- package/extensions/subagents/src/id-sequence.ts +84 -0
- package/extensions/subagents/src/manager.ts +651 -536
- package/extensions/subagents/src/prompt.ts +185 -42
- package/extensions/subagents/src/result-artifact.ts +146 -0
- package/extensions/subagents/src/result-delivery.ts +7 -1
- package/extensions/subagents/src/runtime.ts +23 -6
- package/extensions/subagents/src/ui/takeover.ts +128 -337
- package/extensions/subagents/src/ui/transcript.ts +38 -501
- package/extensions/subagents/src/ui/wait-result.ts +103 -15
- package/extensions/suggestions/src/ui.ts +10 -4
- package/extensions/tasks/index.ts +0 -3
- package/extensions/tasks/ui.ts +79 -62
- package/extensions/ui-customization/footer.ts +7 -44
- package/extensions/ui-customization/index.ts +0 -4
- package/extensions/user-input-fold/index.ts +185 -0
- package/extensions/web/index.ts +234 -0
- package/extensions/workflows/artifacts.ts +147 -22
- package/extensions/workflows/completion-projection.ts +457 -0
- package/extensions/workflows/controller.ts +14 -2
- package/extensions/workflows/coordinator.ts +62 -0
- package/extensions/workflows/dashboard.ts +458 -339
- package/extensions/workflows/handoff.ts +121 -25
- package/extensions/workflows/index.ts +1042 -492
- package/extensions/workflows/journal.ts +148 -13
- package/extensions/workflows/model.ts +131 -19
- package/extensions/workflows/navigation.ts +61 -18
- package/extensions/workflows/progress-projection.ts +306 -0
- package/extensions/workflows/prompt.ts +166 -10
- package/extensions/workflows/replay-safety.ts +58 -27
- package/extensions/workflows/result-delivery.ts +253 -0
- package/extensions/workflows/retention.ts +593 -0
- package/extensions/workflows/runner.ts +388 -279
- package/extensions/workflows/sandbox-child.cjs +36 -3
- package/extensions/workflows/sandbox.ts +62 -8
- package/extensions/workflows/serialization.ts +325 -17
- package/extensions/workflows/tool-renderer.ts +22 -0
- package/extensions/workflows/transcript.ts +149 -0
- package/extensions/workspace-cleanup-guard/index.ts +54 -0
- package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
- package/package.json +28 -8
- package/skills/subagents/REFERENCE.md +189 -0
- package/skills/subagents/SKILL.md +2 -2
- package/skills/workflows/REFERENCE.md +10 -5
- package/skills/workflows/SKILL.md +53 -10
- package/web/adapter/pi-adapter.ts +661 -0
- package/web/host/browser-launcher.ts +20 -0
- package/web/host/static-assets.ts +4 -0
- package/web/host/terminal-status.ts +38 -0
- package/web/host/web-host.ts +789 -0
- package/web/http-dispatcher.ts +125 -0
- package/web/protocol/types.ts +462 -0
- package/web/runtime/pi-runtime.ts +991 -0
- package/web/runtime/types.ts +71 -0
- package/web/runtime/web-host-lease.ts +497 -0
- package/web/trace.ts +18 -0
- package/web/ui/app.js +1398 -0
- package/web/ui/index.html +139 -0
- package/web/ui/styles.css +598 -0
- package/web/vite.config.mjs +34 -0
- package/extensions/execution-convergence/active-evidence.ts +0 -129
- package/extensions/execution-convergence/index.ts +0 -442
- package/extensions/execution-convergence/workspace-provenance.ts +0 -338
- package/extensions/setup/intercom-fs-helper.cjs +0 -130
- package/extensions/setup/intercom.ts +0 -603
- package/extensions/subagents/src/backends/stub.ts +0 -296
- package/extensions/subagents/src/format.ts +0 -48
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold very long user messages in the transcript display.
|
|
3
|
+
*
|
|
4
|
+
* A pasted log, stack trace, or whole file can wipe out several screens of
|
|
5
|
+
* chat. When a finalized user message exceeds the fold thresholds, the
|
|
6
|
+
* display keeps a short preview of the prose and of each fenced code block
|
|
7
|
+
* and closes with a marker line stating how much was folded.
|
|
8
|
+
*
|
|
9
|
+
* A fold that would hide nothing (or almost nothing) is skipped and the
|
|
10
|
+
* message rendered in full: hiding a handful of lines costs a marker row
|
|
11
|
+
* while saving almost no screen space, and char-heavy messages with few
|
|
12
|
+
* long lines do not shrink on screen when logical lines are removed —
|
|
13
|
+
* folding must hide at least MIN_FOLDED_LINES lines to earn its keep.
|
|
14
|
+
*
|
|
15
|
+
* This is display-only. The transformer runs through Pi's
|
|
16
|
+
* `registerMarkdownTransformer` hook, which changes only what the TUI
|
|
17
|
+
* renders: the session file and the model context keep the full message
|
|
18
|
+
* untouched, so the model always receives the complete paste. The pure
|
|
19
|
+
* `foldUserMessage` helper never mutates its input and has no side effects.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
/** Fold a user message longer than this many lines. */
|
|
25
|
+
const MAX_LINES = 20;
|
|
26
|
+
/** Fold a user message longer than this many characters. */
|
|
27
|
+
const MAX_CHARS = 1_200;
|
|
28
|
+
/** Prose lines kept when a message is folded. */
|
|
29
|
+
const PROSE_PREVIEW_LINES = 12;
|
|
30
|
+
/** Content lines kept per fenced code block when a message is folded. */
|
|
31
|
+
const BLOCK_PREVIEW_LINES = 4;
|
|
32
|
+
/** Character budget for the prose part of a folded message. */
|
|
33
|
+
const PROSE_PREVIEW_CHARS = 1_200;
|
|
34
|
+
/** Total rendered lines before the fold marker, across prose and code blocks. */
|
|
35
|
+
const MAX_PREVIEW_LINES = 20;
|
|
36
|
+
/** Only fold when at least this many lines would be hidden. */
|
|
37
|
+
const MIN_FOLDED_LINES = 8;
|
|
38
|
+
|
|
39
|
+
type Segment =
|
|
40
|
+
| { kind: "prose"; lines: string[] }
|
|
41
|
+
| { kind: "code"; open: string; content: string[]; close: string };
|
|
42
|
+
|
|
43
|
+
const FENCE_OPEN = /^ {0,3}`{3,}/;
|
|
44
|
+
const FENCE_CLOSE = /^ {0,3}`{3,}[ \t]*\r?$/;
|
|
45
|
+
|
|
46
|
+
function countLines(markdown: string) {
|
|
47
|
+
const parts = markdown.split("\n");
|
|
48
|
+
// A trailing newline ends the last line; it does not open a new one.
|
|
49
|
+
return parts.at(-1) === "" ? parts.length - 1 : parts.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitLines(markdown: string) {
|
|
53
|
+
const lines = markdown.split("\n");
|
|
54
|
+
if (lines.at(-1) === "") lines.pop();
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseSegments(lines: string[]): Segment[] {
|
|
59
|
+
const segments: Segment[] = [];
|
|
60
|
+
let prose: string[] = [];
|
|
61
|
+
let i = 0;
|
|
62
|
+
while (i < lines.length) {
|
|
63
|
+
if (!FENCE_OPEN.test(lines[i])) {
|
|
64
|
+
prose.push(lines[i]);
|
|
65
|
+
i += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (prose.length > 0) {
|
|
69
|
+
segments.push({ kind: "prose", lines: prose });
|
|
70
|
+
prose = [];
|
|
71
|
+
}
|
|
72
|
+
const open = lines[i];
|
|
73
|
+
const content: string[] = [];
|
|
74
|
+
let close: string | undefined;
|
|
75
|
+
let j = i + 1;
|
|
76
|
+
while (j < lines.length && close === undefined) {
|
|
77
|
+
if (FENCE_CLOSE.test(lines[j])) close = lines[j];
|
|
78
|
+
else content.push(lines[j]);
|
|
79
|
+
j += 1;
|
|
80
|
+
}
|
|
81
|
+
if (close === undefined) {
|
|
82
|
+
// Unterminated fence: fold the whole message conservatively as text.
|
|
83
|
+
return [{ kind: "prose", lines }];
|
|
84
|
+
}
|
|
85
|
+
segments.push({ kind: "code", open, content, close });
|
|
86
|
+
i = j;
|
|
87
|
+
}
|
|
88
|
+
if (prose.length > 0) segments.push({ kind: "prose", lines: prose });
|
|
89
|
+
return segments;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return the Markdown Pi should render instead of a long user message.
|
|
94
|
+
* Messages at or below both thresholds — and messages whose fold would
|
|
95
|
+
* hide fewer than MIN_FOLDED_LINES lines — are returned unchanged. Pure:
|
|
96
|
+
* the input string is never modified, and the model still sees the original.
|
|
97
|
+
*/
|
|
98
|
+
export function foldUserMessage(markdown: string): string {
|
|
99
|
+
const totalLines = countLines(markdown);
|
|
100
|
+
if (totalLines <= MAX_LINES && markdown.length <= MAX_CHARS) return markdown;
|
|
101
|
+
|
|
102
|
+
const preview: string[] = [];
|
|
103
|
+
let proseLinesLeft = PROSE_PREVIEW_LINES;
|
|
104
|
+
let proseCharsLeft = PROSE_PREVIEW_CHARS;
|
|
105
|
+
let linesShown = 0;
|
|
106
|
+
|
|
107
|
+
for (const segment of parseSegments(splitLines(markdown))) {
|
|
108
|
+
const remainingLines = MAX_PREVIEW_LINES - preview.length;
|
|
109
|
+
if (remainingLines <= 0) break;
|
|
110
|
+
if (segment.kind === "code") {
|
|
111
|
+
if (segment.content.length === 0) {
|
|
112
|
+
if (remainingLines < 2) break;
|
|
113
|
+
preview.push(segment.open, segment.close);
|
|
114
|
+
linesShown += 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// A partial block needs opening/closing fences plus an ellipsis. If that
|
|
118
|
+
// cannot fit, stop before the block instead of emitting broken Markdown.
|
|
119
|
+
if (remainingLines < 3) break;
|
|
120
|
+
let shown = Math.min(
|
|
121
|
+
segment.content.length,
|
|
122
|
+
BLOCK_PREVIEW_LINES,
|
|
123
|
+
remainingLines - 2,
|
|
124
|
+
);
|
|
125
|
+
const truncated = () => shown < segment.content.length;
|
|
126
|
+
while (truncated() && shown + 3 > remainingLines) shown -= 1;
|
|
127
|
+
preview.push(segment.open, ...segment.content.slice(0, shown));
|
|
128
|
+
if (truncated()) preview.push("…");
|
|
129
|
+
preview.push(segment.close);
|
|
130
|
+
linesShown += 2 + shown;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const line of segment.lines) {
|
|
134
|
+
if (proseLinesLeft <= 0 || preview.length >= MAX_PREVIEW_LINES) break;
|
|
135
|
+
const cost = line.length + (preview.length > 0 ? 1 : 0);
|
|
136
|
+
if (preview.length > 0 && cost > proseCharsLeft) break;
|
|
137
|
+
if (preview.length === 0 && line.length > proseCharsLeft) {
|
|
138
|
+
// A single giant first line is the only case that cuts mid-line.
|
|
139
|
+
preview.push(`${line.slice(0, proseCharsLeft)}…`);
|
|
140
|
+
proseLinesLeft = 0;
|
|
141
|
+
proseCharsLeft = 0;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
preview.push(line);
|
|
145
|
+
linesShown += 1;
|
|
146
|
+
proseLinesLeft -= 1;
|
|
147
|
+
proseCharsLeft -= cost;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const foldedLines = totalLines - linesShown;
|
|
152
|
+
if (foldedLines < MIN_FOLDED_LINES) {
|
|
153
|
+
// Folding must earn its keep. Hiding fewer lines than MIN_FOLDED_LINES
|
|
154
|
+
// costs a marker row, hides content, and saves almost nothing on screen
|
|
155
|
+
// (char-heavy messages with few long lines wrap regardless), so render
|
|
156
|
+
// the message in full instead.
|
|
157
|
+
return markdown;
|
|
158
|
+
}
|
|
159
|
+
const noun = foldedLines === 1 ? "line" : "lines";
|
|
160
|
+
preview.push(
|
|
161
|
+
`… folded ${foldedLines} ${noun} · full content was sent to the model`,
|
|
162
|
+
);
|
|
163
|
+
return preview.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The registered transformer: fold only finalized user messages and leave
|
|
168
|
+
* assistant text, thinking blocks, and streaming updates untouched.
|
|
169
|
+
*/
|
|
170
|
+
export function transformUserMarkdown(
|
|
171
|
+
markdown: string,
|
|
172
|
+
context: { messageType: string; isStreaming: boolean },
|
|
173
|
+
): string {
|
|
174
|
+
if (context.messageType !== "user" || context.isStreaming) return markdown;
|
|
175
|
+
try {
|
|
176
|
+
return foldUserMessage(markdown);
|
|
177
|
+
} catch {
|
|
178
|
+
// Display-only: a folding bug must never break rendering.
|
|
179
|
+
return markdown;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function (pi: ExtensionAPI) {
|
|
184
|
+
pi.registerMarkdownTransformer(transformUserMarkdown);
|
|
185
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type {
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionCommandContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
10
|
+
|
|
11
|
+
export interface WebProcess {
|
|
12
|
+
readonly exitCode: number | null;
|
|
13
|
+
readonly signalCode: NodeJS.Signals | null;
|
|
14
|
+
once(event: "error", listener: (error: Error) => void): this;
|
|
15
|
+
once(
|
|
16
|
+
event: "close",
|
|
17
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
18
|
+
): this;
|
|
19
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SpawnWebOptions {
|
|
23
|
+
cwd: string;
|
|
24
|
+
env: NodeJS.ProcessEnv;
|
|
25
|
+
shell: false;
|
|
26
|
+
stdio: "inherit";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function webProcessEnvironment(cwd: string) {
|
|
30
|
+
const environment: NodeJS.ProcessEnv = { ...process.env, PWD: cwd };
|
|
31
|
+
delete environment.OLDPWD;
|
|
32
|
+
delete environment.INIT_CWD;
|
|
33
|
+
delete environment.PI_SESSION_ID;
|
|
34
|
+
delete environment.PI_SESSION_FILE;
|
|
35
|
+
return environment;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WebCommandDependencies {
|
|
39
|
+
entrypoint: string;
|
|
40
|
+
spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess;
|
|
41
|
+
clearTerminal(): void;
|
|
42
|
+
holdParentSigint(): () => void;
|
|
43
|
+
shutdownTimeoutMs: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type WebExit =
|
|
47
|
+
| { kind: "close"; code: number | null; signal: NodeJS.Signals | null }
|
|
48
|
+
| { kind: "error"; error: Error };
|
|
49
|
+
|
|
50
|
+
interface ActiveWebProcess {
|
|
51
|
+
child: WebProcess;
|
|
52
|
+
closed: Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const defaultDependencies: WebCommandDependencies = {
|
|
56
|
+
entrypoint: fileURLToPath(new URL("../../bin/openpi.js", import.meta.url)),
|
|
57
|
+
spawn(command, args, options) {
|
|
58
|
+
return nodeSpawn(command, args, options);
|
|
59
|
+
},
|
|
60
|
+
clearTerminal() {
|
|
61
|
+
process.stdout.write("\u001b[2J\u001b[H");
|
|
62
|
+
},
|
|
63
|
+
holdParentSigint() {
|
|
64
|
+
const keepPiAlive = () => {};
|
|
65
|
+
process.on("SIGINT", keepPiAlive);
|
|
66
|
+
return () => process.removeListener("SIGINT", keepPiAlive);
|
|
67
|
+
},
|
|
68
|
+
shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function delay(milliseconds: number) {
|
|
72
|
+
return new Promise<void>((resolve) => {
|
|
73
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
74
|
+
timer.unref();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function stopWebProcess(active: ActiveWebProcess, timeoutMs: number) {
|
|
79
|
+
if (active.child.exitCode !== null || active.child.signalCode !== null)
|
|
80
|
+
return;
|
|
81
|
+
active.child.kill("SIGTERM");
|
|
82
|
+
const timedOut = await Promise.race([
|
|
83
|
+
active.closed.then(() => false),
|
|
84
|
+
delay(timeoutMs).then(() => true),
|
|
85
|
+
]);
|
|
86
|
+
if (!timedOut) return;
|
|
87
|
+
if (active.child.exitCode === null && active.child.signalCode === null) {
|
|
88
|
+
active.child.kill("SIGKILL");
|
|
89
|
+
await Promise.race([active.closed, delay(timeoutMs)]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function runWebInForeground(
|
|
94
|
+
ctx: ExtensionCommandContext,
|
|
95
|
+
dependencies: WebCommandDependencies,
|
|
96
|
+
setActive: (active: ActiveWebProcess | undefined) => void,
|
|
97
|
+
isShuttingDown: () => boolean,
|
|
98
|
+
) {
|
|
99
|
+
return ctx.ui.custom<WebExit>((tui, _theme, _keybindings, done) => {
|
|
100
|
+
let finished = false;
|
|
101
|
+
let tuiStopped = false;
|
|
102
|
+
let resolveClosed = () => {};
|
|
103
|
+
const closed = new Promise<void>((resolve) => {
|
|
104
|
+
resolveClosed = resolve;
|
|
105
|
+
});
|
|
106
|
+
const releaseParentSigint = dependencies.holdParentSigint();
|
|
107
|
+
|
|
108
|
+
const finish = (result: WebExit) => {
|
|
109
|
+
if (finished) return;
|
|
110
|
+
finished = true;
|
|
111
|
+
releaseParentSigint();
|
|
112
|
+
setActive(undefined);
|
|
113
|
+
resolveClosed();
|
|
114
|
+
if (tuiStopped && !isShuttingDown()) {
|
|
115
|
+
tui.start();
|
|
116
|
+
tui.requestRender(true);
|
|
117
|
+
}
|
|
118
|
+
done(result);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
tui.stop();
|
|
123
|
+
tuiStopped = true;
|
|
124
|
+
dependencies.clearTerminal();
|
|
125
|
+
const childCwd = dirname(dependencies.entrypoint);
|
|
126
|
+
const child = dependencies.spawn(
|
|
127
|
+
process.execPath,
|
|
128
|
+
[dependencies.entrypoint, "web", "--no-workspace"],
|
|
129
|
+
{
|
|
130
|
+
cwd: childCwd,
|
|
131
|
+
env: webProcessEnvironment(childCwd),
|
|
132
|
+
shell: false,
|
|
133
|
+
stdio: "inherit",
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
setActive({ child, closed });
|
|
137
|
+
child.once("error", (error) => finish({ kind: "error", error }));
|
|
138
|
+
child.once("close", (code, signal) =>
|
|
139
|
+
finish({ kind: "close", code, signal }),
|
|
140
|
+
);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
finish({
|
|
143
|
+
kind: "error",
|
|
144
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { render: () => [], invalidate: () => {} };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default function web(
|
|
153
|
+
pi: ExtensionAPI,
|
|
154
|
+
dependencies: WebCommandDependencies = defaultDependencies,
|
|
155
|
+
) {
|
|
156
|
+
let active: ActiveWebProcess | undefined;
|
|
157
|
+
let running = false;
|
|
158
|
+
let shuttingDown = false;
|
|
159
|
+
|
|
160
|
+
pi.on("session_start", () => {
|
|
161
|
+
shuttingDown = false;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
pi.on("session_shutdown", async () => {
|
|
165
|
+
shuttingDown = true;
|
|
166
|
+
if (active) {
|
|
167
|
+
await stopWebProcess(active, dependencies.shutdownTimeoutMs);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
pi.registerCommand("web", {
|
|
172
|
+
description:
|
|
173
|
+
"Open the separate OpenPI Web Workbench in this terminal until Ctrl+C",
|
|
174
|
+
handler: async (args, ctx) => {
|
|
175
|
+
if (args.trim()) {
|
|
176
|
+
ctx.ui.notify("Usage: /web", "warning");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (ctx.mode !== "tui") {
|
|
180
|
+
ctx.ui.notify("/web requires the interactive TUI.", "warning");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
184
|
+
ctx.ui.notify(
|
|
185
|
+
"Wait until the current Pi Session is idle before starting /web.",
|
|
186
|
+
"warning",
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (running) {
|
|
191
|
+
ctx.ui.notify("OpenPI Web Workbench is already running.", "warning");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
running = true;
|
|
196
|
+
try {
|
|
197
|
+
const result = await runWebInForeground(
|
|
198
|
+
ctx,
|
|
199
|
+
dependencies,
|
|
200
|
+
(next) => {
|
|
201
|
+
active = next;
|
|
202
|
+
},
|
|
203
|
+
() => shuttingDown,
|
|
204
|
+
);
|
|
205
|
+
if (shuttingDown) return;
|
|
206
|
+
if (result.kind === "error") {
|
|
207
|
+
ctx.ui.notify(
|
|
208
|
+
`Failed to start OpenPI Web Workbench: ${result.error.message}`,
|
|
209
|
+
"error",
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (result.signal !== null) {
|
|
214
|
+
ctx.ui.notify(
|
|
215
|
+
`OpenPI Web Workbench was terminated by ${result.signal}.`,
|
|
216
|
+
"error",
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (result.code !== 0) {
|
|
221
|
+
ctx.ui.notify(
|
|
222
|
+
`OpenPI Web Workbench exited with code ${result.code ?? "unknown"}.`,
|
|
223
|
+
"error",
|
|
224
|
+
);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
ctx.ui.notify("OpenPI Web Workbench stopped.", "info");
|
|
228
|
+
} finally {
|
|
229
|
+
active = undefined;
|
|
230
|
+
running = false;
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -1,31 +1,40 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
type TranscriptEntry,
|
|
4
|
-
type WorkflowDetails,
|
|
5
|
-
} from "./model.ts";
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
6
3
|
import {
|
|
7
4
|
boundedJournal,
|
|
8
|
-
|
|
5
|
+
JOURNAL_MAX_BYTES,
|
|
9
6
|
type JournalEntry,
|
|
7
|
+
parseJournal,
|
|
10
8
|
type WorkflowJournal,
|
|
9
|
+
type WorkflowJournalAccumulator,
|
|
11
10
|
} from "./journal.ts";
|
|
12
11
|
import {
|
|
12
|
+
refreshWorkflowGraph,
|
|
13
|
+
type TranscriptEntry,
|
|
14
|
+
type WorkflowDelivery,
|
|
15
|
+
type WorkflowDetails,
|
|
16
|
+
} from "./model.ts";
|
|
17
|
+
import {
|
|
18
|
+
encodeCompleteJson,
|
|
13
19
|
safeStringify,
|
|
14
20
|
truncateUtf8,
|
|
15
21
|
writeFileAtomic,
|
|
16
22
|
} from "./serialization.ts";
|
|
17
|
-
import * as fs from "node:fs";
|
|
18
|
-
import * as path from "node:path";
|
|
19
23
|
|
|
20
24
|
export const JOURNAL_FILE = "journal.json";
|
|
21
25
|
|
|
22
26
|
const ARTIFACT_TRANSCRIPT_MAX_BYTES = 32 * 1024;
|
|
23
27
|
const ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES = 8 * 1024;
|
|
28
|
+
const AGENT_RESULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
|
|
24
29
|
export const WORKFLOW_CHECKPOINT_INTERVAL_MS = 500;
|
|
25
30
|
const ENTRY_TRUNCATION_MARKER = "\n[entry truncated]";
|
|
26
31
|
const TRANSCRIPT_TRUNCATION_MARKER =
|
|
27
32
|
"[artifact transcript truncated: older entries omitted]";
|
|
28
33
|
|
|
34
|
+
type WorkflowJournalSource =
|
|
35
|
+
| readonly JournalEntry[]
|
|
36
|
+
| WorkflowJournalAccumulator;
|
|
37
|
+
|
|
29
38
|
function textBytes(text: string) {
|
|
30
39
|
return Buffer.byteLength(text, "utf8");
|
|
31
40
|
}
|
|
@@ -100,10 +109,61 @@ function writeRunFile(runDir: string, name: string, content: string) {
|
|
|
100
109
|
writeFileAtomic(path.join(runDir, name), content);
|
|
101
110
|
}
|
|
102
111
|
|
|
112
|
+
export function persistWorkflowTerminalState(
|
|
113
|
+
runDir: string,
|
|
114
|
+
details: WorkflowDetails,
|
|
115
|
+
) {
|
|
116
|
+
const terminalManifest: WorkflowDetails = {
|
|
117
|
+
...details,
|
|
118
|
+
agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
|
|
119
|
+
};
|
|
120
|
+
delete terminalManifest.result;
|
|
121
|
+
delete terminalManifest.resultArtifact;
|
|
122
|
+
delete terminalManifest.transcriptArtifact;
|
|
123
|
+
writeRunFile(
|
|
124
|
+
runDir,
|
|
125
|
+
"workflow.json",
|
|
126
|
+
safeStringify(terminalManifest, { maxBytes: 1024 * 1024 }),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Persist one successful child result before any handoff/context projection. */
|
|
131
|
+
export function persistWorkflowAgentResult(
|
|
132
|
+
runDir: string,
|
|
133
|
+
index: number,
|
|
134
|
+
result: { output: string; structured?: unknown },
|
|
135
|
+
) {
|
|
136
|
+
// Artifact references are persisted as portable workflow paths. The host
|
|
137
|
+
// filesystem join happens in writeRunFile; handoff validation and replay
|
|
138
|
+
// consumers intentionally use `/` regardless of the platform.
|
|
139
|
+
const artifact = `agent-results/agent-${String(index).padStart(4, "0")}.json`;
|
|
140
|
+
const encoded = encodeCompleteJson(
|
|
141
|
+
{
|
|
142
|
+
output: result.output,
|
|
143
|
+
...(result.structured !== undefined
|
|
144
|
+
? { structured: result.structured }
|
|
145
|
+
: {}),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
maxBytes: AGENT_RESULT_ARTIFACT_MAX_BYTES,
|
|
149
|
+
maxDepth: 32,
|
|
150
|
+
maxNodes: 100_000,
|
|
151
|
+
maxStringBytes: AGENT_RESULT_ARTIFACT_MAX_BYTES,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
if (!encoded.ok) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Agent result artifact exceeded the ${AGENT_RESULT_ARTIFACT_MAX_BYTES}-byte budget (${encoded.limit} limit at ${encoded.path})`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
writeRunFile(runDir, artifact, encoded.json);
|
|
160
|
+
return artifact;
|
|
161
|
+
}
|
|
162
|
+
|
|
103
163
|
export function persistWorkflowJson(
|
|
104
164
|
runDir: string,
|
|
105
165
|
details: WorkflowDetails,
|
|
106
|
-
journal?:
|
|
166
|
+
journal?: WorkflowJournalSource,
|
|
107
167
|
) {
|
|
108
168
|
refreshWorkflowGraph(details);
|
|
109
169
|
const transcripts = Object.fromEntries(
|
|
@@ -112,6 +172,15 @@ export function persistWorkflowJson(
|
|
|
112
172
|
boundedArtifactTranscript(agent.transcript),
|
|
113
173
|
]),
|
|
114
174
|
);
|
|
175
|
+
|
|
176
|
+
// Publish terminal execution facts before dependent side artifacts. If a
|
|
177
|
+
// later artifact write fails, readers still see an explained terminal run
|
|
178
|
+
// instead of the previous `running` manifest. The final manifest below adds
|
|
179
|
+
// the artifact references once every dependent file has committed.
|
|
180
|
+
if (details.status !== "running") {
|
|
181
|
+
persistWorkflowTerminalState(runDir, details);
|
|
182
|
+
}
|
|
183
|
+
|
|
115
184
|
writeRunFile(
|
|
116
185
|
runDir,
|
|
117
186
|
"transcripts.json",
|
|
@@ -119,15 +188,20 @@ export function persistWorkflowJson(
|
|
|
119
188
|
);
|
|
120
189
|
// Written alongside the rest so it inherits atomic write, 500ms coalescing,
|
|
121
190
|
// and the final flush. Only present once a call has actually succeeded.
|
|
122
|
-
//
|
|
123
|
-
// JSON
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
191
|
+
// Accumulators already enforce the cap incrementally and can assemble the
|
|
192
|
+
// canonical JSON from their cached entry fragments. Plain arrays retain the
|
|
193
|
+
// compatibility path through boundedJournal; plain JSON.stringify is
|
|
194
|
+
// deliberate because safeStringify would swap an over-cap value for a
|
|
195
|
+
// preview stub, silently turning the journal into something unusable.
|
|
196
|
+
if (
|
|
197
|
+
journal &&
|
|
198
|
+
(journal.length > 0 || ("toJson" in journal && journal.dropped > 0))
|
|
199
|
+
) {
|
|
200
|
+
const content =
|
|
201
|
+
"toJson" in journal
|
|
202
|
+
? journal.toJson()
|
|
203
|
+
: JSON.stringify(boundedJournal(journal).journal, null, 2);
|
|
204
|
+
writeRunFile(runDir, JOURNAL_FILE, content);
|
|
131
205
|
}
|
|
132
206
|
if (details.result !== undefined) {
|
|
133
207
|
writeRunFile(
|
|
@@ -151,6 +225,25 @@ export function persistWorkflowJson(
|
|
|
151
225
|
);
|
|
152
226
|
}
|
|
153
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Update only the durable delivery receipt. Completion delivery may retain a
|
|
230
|
+
* compact memory projection after the run has been evicted, so rewriting the
|
|
231
|
+
* whole details object here would risk replacing exact result artifacts with
|
|
232
|
+
* that projection.
|
|
233
|
+
*/
|
|
234
|
+
export function persistWorkflowDeliveryState(
|
|
235
|
+
runDir: string,
|
|
236
|
+
delivery: WorkflowDelivery,
|
|
237
|
+
) {
|
|
238
|
+
const file = path.join(runDir, "workflow.json");
|
|
239
|
+
const raw: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
240
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
241
|
+
throw new Error(`Invalid persisted workflow details: ${file}`);
|
|
242
|
+
}
|
|
243
|
+
const next = { ...(raw as Record<string, unknown>), delivery };
|
|
244
|
+
writeFileAtomic(file, JSON.stringify(next));
|
|
245
|
+
}
|
|
246
|
+
|
|
154
247
|
/** Coalesce live checkpoints while keeping final persistence synchronous. */
|
|
155
248
|
export function createWorkflowPersistence(
|
|
156
249
|
runDir: string,
|
|
@@ -160,10 +253,10 @@ export function createWorkflowPersistence(
|
|
|
160
253
|
persist?: (
|
|
161
254
|
runDir: string,
|
|
162
255
|
details: WorkflowDetails,
|
|
163
|
-
journal?:
|
|
256
|
+
journal?: WorkflowJournalSource,
|
|
164
257
|
) => void;
|
|
165
|
-
/** Read at write time so callers only have to append to their
|
|
166
|
-
journal?: () =>
|
|
258
|
+
/** Read at write time so callers only have to append to their journal. */
|
|
259
|
+
journal?: () => WorkflowJournalSource;
|
|
167
260
|
} = {},
|
|
168
261
|
) {
|
|
169
262
|
const intervalMs = Math.max(
|
|
@@ -221,10 +314,42 @@ export function createWorkflowPersistence(
|
|
|
221
314
|
* turn into a new way for a run to fail.
|
|
222
315
|
*/
|
|
223
316
|
export function loadJournal(runDir: string): WorkflowJournal | undefined {
|
|
317
|
+
let descriptor: number | undefined;
|
|
224
318
|
try {
|
|
225
|
-
|
|
319
|
+
descriptor = fs.openSync(path.join(runDir, JOURNAL_FILE), "r");
|
|
320
|
+
const initialSize = fs.fstatSync(descriptor).size;
|
|
321
|
+
if (initialSize > JOURNAL_MAX_BYTES) return undefined;
|
|
322
|
+
|
|
323
|
+
// Read at most one byte beyond the cap. The descriptor pins the inspected
|
|
324
|
+
// file, while the extra byte catches growth after fstat without ever
|
|
325
|
+
// allocating or parsing an unbounded journal.
|
|
326
|
+
const bytes = Buffer.allocUnsafe(initialSize + 1);
|
|
327
|
+
let length = 0;
|
|
328
|
+
while (length < bytes.length) {
|
|
329
|
+
const read = fs.readSync(
|
|
330
|
+
descriptor,
|
|
331
|
+
bytes,
|
|
332
|
+
length,
|
|
333
|
+
bytes.length - length,
|
|
334
|
+
null,
|
|
335
|
+
);
|
|
336
|
+
if (read === 0) break;
|
|
337
|
+
length += read;
|
|
338
|
+
}
|
|
339
|
+
// Filling the extra byte means the file changed after inspection. Reject
|
|
340
|
+
// that race even when the new size would still fit the ordinary cap.
|
|
341
|
+
if (length === bytes.length) return undefined;
|
|
342
|
+
const raw = bytes.toString("utf8", 0, length);
|
|
226
343
|
return parseJournal(JSON.parse(raw));
|
|
227
344
|
} catch {
|
|
228
345
|
return undefined;
|
|
346
|
+
} finally {
|
|
347
|
+
if (descriptor !== undefined) {
|
|
348
|
+
try {
|
|
349
|
+
fs.closeSync(descriptor);
|
|
350
|
+
} catch {
|
|
351
|
+
// Loading a replay cache is optional and always fails open to a miss.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
229
354
|
}
|
|
230
355
|
}
|