@tshu-w/pi-agents 0.1.1 → 0.2.1
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 +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +87 -42
- package/docs/HOSTS.md +111 -0
- package/docs/PROGRAM.md +39 -114
- package/docs/SPEC.md +109 -161
- package/package.json +7 -5
- package/src/agents/agents.ts +129 -55
- package/src/agents/registry.ts +62 -10
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +41 -13
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +30 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +380 -0
- package/src/index.ts +44 -16
- package/src/output.ts +5 -1
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/loadout.ts +18 -3
- package/src/programs/programs.ts +128 -20
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +15 -7
- package/src/programs/tool.ts +172 -16
- package/src/render-call.ts +113 -15
- package/src/roots/guard.mjs +8 -2
- package/src/roots/paths.mjs +4 -4
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/message.ts +24 -0
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +47 -0
- package/src/ui/viewer.ts +182 -38
- package/src/workbench.mjs +91 -51
- package/docs/WORKBENCH.md +0 -167
package/src/roots/transport.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
|
-
import { StringDecoder } from 'node:string_decoder';
|
|
3
2
|
import { chmodSync, lstatSync, mkdirSync } from 'node:fs';
|
|
4
3
|
import { dirname, resolve } from 'node:path';
|
|
5
4
|
|
|
6
5
|
const MAX_BYTES = 1024 * 1024;
|
|
6
|
+
// Pi's events on an rpc stream carry whole messages, images included.
|
|
7
|
+
const RPC_MAX_BYTES = 256 * 1024 * 1024;
|
|
7
8
|
const MAX_SOCKET_PATH_BYTES = 103;
|
|
8
9
|
const MAX_PENDING = 64;
|
|
9
10
|
// Each running Pi keeps a stream open to the daemon.
|
|
@@ -39,9 +40,9 @@ function safeDirectory(path, create = false) {
|
|
|
39
40
|
function privateSocket(path) {
|
|
40
41
|
safeDirectory(dirname(path));
|
|
41
42
|
const info = lstatSync(path);
|
|
42
|
-
if (!info.isSocket() || info.uid !== process.getuid()
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
if (!info.isSocket() || info.uid !== process.getuid()) throw fault('UNSAFE_PATH', 'Socket must be owned by the current user');
|
|
44
|
+
// A listener sets the mode just after it binds; a client waiting for a new listener may retry this.
|
|
45
|
+
if ((info.mode & 0o777) !== 0o600) throw fault('SOCKET_MODE', 'Socket must have mode 0600');
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -51,7 +52,7 @@ function validate(value) {
|
|
|
51
52
|
if (!record(value)) throw fault('INVALID_REQUEST', 'Expected a request object');
|
|
52
53
|
if (value.action === 'status') return { action: 'status' };
|
|
53
54
|
if (value.action === 'stream') {
|
|
54
|
-
if (
|
|
55
|
+
if (!['session', 'workbench', 'rpc', 'print'].includes(value.role)) throw fault('INVALID_REQUEST', 'stream role must be session, workbench, rpc or print');
|
|
55
56
|
return { action: 'stream', role: value.role };
|
|
56
57
|
}
|
|
57
58
|
const m = value.message, l = value.launch;
|
|
@@ -75,12 +76,14 @@ function validate(value) {
|
|
|
75
76
|
}, ...(l === undefined ? {} : { launch: { command: l.command, args: [...l.args], env: { ...l.env } } }) };
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
function encode(value) {
|
|
79
|
+
function encode(value, limit = MAX_BYTES) {
|
|
79
80
|
const bytes = Buffer.from(`${JSON.stringify(value)}\n`);
|
|
80
|
-
if (bytes.length >
|
|
81
|
+
if (bytes.length > limit) throw fault('FRAME_TOO_LARGE', `JSONL frame exceeds ${limit / 1024 / 1024} MiB`);
|
|
81
82
|
return bytes;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
const streamLimit = role => role === 'rpc' || role === 'print' ? RPC_MAX_BYTES : MAX_BYTES;
|
|
86
|
+
|
|
84
87
|
// Consume exactly one bounded JSONL frame; never dispatch additional requests.
|
|
85
88
|
function readFrame(socket, done) {
|
|
86
89
|
let chunks = [], size = 0, finished = false;
|
|
@@ -108,28 +111,33 @@ function readFrame(socket, done) {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
// JSONL messages after a stream's opening frame.
|
|
111
|
-
function readLines(socket, onLine, onInvalid) {
|
|
112
|
-
|
|
113
|
-
let
|
|
114
|
+
function readLines(socket, onLine, onInvalid, limit) {
|
|
115
|
+
// An unfinished line is kept as chunks, so each byte is scanned once.
|
|
116
|
+
let pending = [], size = 0;
|
|
114
117
|
socket.on('data', chunk => {
|
|
115
|
-
|
|
116
|
-
for (let newline; (newline =
|
|
117
|
-
const line =
|
|
118
|
-
|
|
118
|
+
let start = 0;
|
|
119
|
+
for (let newline; (newline = chunk.indexOf(10, start)) >= 0; start = newline + 1) {
|
|
120
|
+
const line = Buffer.concat([...pending, chunk.subarray(start, newline)]).toString('utf8');
|
|
121
|
+
pending = [];
|
|
122
|
+
size = 0;
|
|
119
123
|
let value;
|
|
120
124
|
try { value = JSON.parse(line); } catch { return onInvalid(); }
|
|
121
125
|
onLine(value);
|
|
122
126
|
}
|
|
123
|
-
if (
|
|
127
|
+
if (start < chunk.length) {
|
|
128
|
+
pending.push(chunk.subarray(start));
|
|
129
|
+
size += chunk.length - start;
|
|
130
|
+
}
|
|
131
|
+
if (size > limit) onInvalid();
|
|
124
132
|
});
|
|
125
133
|
}
|
|
126
134
|
|
|
127
|
-
function channel(socket, signal) {
|
|
135
|
+
function channel(socket, signal, limit) {
|
|
128
136
|
const listeners = [];
|
|
129
|
-
readLines(socket, value => { for (const listener of listeners) listener(value); }, () => socket.destroy());
|
|
137
|
+
readLines(socket, value => { for (const listener of listeners) listener(value); }, () => socket.destroy(), limit);
|
|
130
138
|
return {
|
|
131
139
|
signal,
|
|
132
|
-
send(value) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(value)); },
|
|
140
|
+
send(value) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(value, limit)); },
|
|
133
141
|
onMessage(listener) { listeners.push(listener); },
|
|
134
142
|
close() { socket.destroy(); },
|
|
135
143
|
};
|
|
@@ -145,7 +153,8 @@ function errorFrame(error) {
|
|
|
145
153
|
|
|
146
154
|
/**
|
|
147
155
|
* FIFO by default. Handlers receive a connection-lifetime signal, not an acceptance rollback.
|
|
148
|
-
* `stream` takes over the connections that open a stream; they keep the server from being idle.
|
|
156
|
+
* `stream` takes over the connections that open a stream; they keep the server from being idle. It may
|
|
157
|
+
* refuse a stream by throwing; its result joins the acceptance, which reaches the client before anything it sends.
|
|
149
158
|
* @param {string} socketPath
|
|
150
159
|
* @param {{ status: Function, accept: Function, stream?: Function, serialize?: boolean }} handlers
|
|
151
160
|
*/
|
|
@@ -189,8 +198,17 @@ export async function listen(socketPath, { status, accept, stream, serialize = t
|
|
|
189
198
|
if (input.action === 'stream') {
|
|
190
199
|
if (!stream) return respond(fault('INVALID_REQUEST', 'Streams are not supported here'));
|
|
191
200
|
clearTimeout(timer);
|
|
192
|
-
|
|
193
|
-
|
|
201
|
+
const opened = channel(socket, signal, streamLimit(input.role));
|
|
202
|
+
const send = opened.send;
|
|
203
|
+
const early = [];
|
|
204
|
+
opened.send = value => early.push(value);
|
|
205
|
+
let result;
|
|
206
|
+
try { result = stream(input, opened); }
|
|
207
|
+
catch (failure) { return respond(failure); }
|
|
208
|
+
socket.write(encode({ ok: true, result: { pid: process.pid, ...result } }));
|
|
209
|
+
opened.send = send;
|
|
210
|
+
for (const value of early) send(value);
|
|
211
|
+
return;
|
|
194
212
|
}
|
|
195
213
|
if (pending >= MAX_PENDING) return respond(fault('SERVER_BUSY', 'Request queue is full'));
|
|
196
214
|
pending++;
|
|
@@ -332,10 +350,10 @@ export function openStream(socketPath, input, { onMessage, onClose }) {
|
|
|
332
350
|
opened = true;
|
|
333
351
|
resolvePromise({
|
|
334
352
|
result: response.result,
|
|
335
|
-
send(message) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(message)); },
|
|
353
|
+
send(message) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(message, streamLimit(value.role))); },
|
|
336
354
|
close() { socket.destroy(); },
|
|
337
355
|
unref() { socket.unref(); },
|
|
338
356
|
});
|
|
339
|
-
}, () => socket.destroy());
|
|
357
|
+
}, () => socket.destroy(), streamLimit(value.role));
|
|
340
358
|
});
|
|
341
359
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getMarkdownTheme, UserMessageComponent, type MessageRenderer } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { messageBody, type Sender } from "../agents/registry.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Collapsed, a message from another Agent shows its sender after the label, as Pi shows a collapsed skill, and the
|
|
7
|
+
* user's input from the task viewer shows as a user message. Expanded, or for any other message, Pi shows the text the
|
|
8
|
+
* model received.
|
|
9
|
+
*/
|
|
10
|
+
export const renderAgentMessage: MessageRenderer = (message, { expanded, outputPad }, theme) => {
|
|
11
|
+
if (expanded || typeof message.content !== "string") return undefined;
|
|
12
|
+
const details = message.details as { user?: boolean; sender?: Sender } | undefined;
|
|
13
|
+
if (details?.user) return new UserMessageComponent(message.content, getMarkdownTheme(), outputPad);
|
|
14
|
+
const body = details?.sender && messageBody(message.content, details.sender);
|
|
15
|
+
if (body === undefined) return undefined;
|
|
16
|
+
const { id, name } = details!.sender!;
|
|
17
|
+
const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
|
|
18
|
+
const label = theme.fg("customMessageLabel", `\x1b[1m[${message.customType}]\x1b[22m`);
|
|
19
|
+
const sender = name === undefined ? theme.fg("customMessageText", ` from ${id}`) : theme.fg("customMessageText", ` from ${name}`) + theme.fg("dim", ` (${id})`);
|
|
20
|
+
box.addChild(new Text(label + sender, 0, 0));
|
|
21
|
+
box.addChild(new Spacer(1));
|
|
22
|
+
box.addChild(new Markdown(body, 0, 0, getMarkdownTheme(), { color: (text) => theme.fg("customMessageText", text) }));
|
|
23
|
+
return box;
|
|
24
|
+
};
|
package/src/ui/panel.ts
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Container, TruncatedText } from "@earendil-works/pi-tui";
|
|
3
|
-
import { label, nodes, onTreeChange, programLabel, programsOf, type AgentNode } from "../agents/registry.ts";
|
|
3
|
+
import { label, nodes, onTreeChange, programLabel, programsOf, type AgentNode, type Spend } from "../agents/registry.ts";
|
|
4
4
|
|
|
5
5
|
const WIDGET_KEY = "pi-agents";
|
|
6
6
|
|
|
7
7
|
const MAX_LINES = 10;
|
|
8
8
|
|
|
9
|
+
/** Time since `since` as Pi's bash tool shows durations, in whole seconds: `45s`, `1m 32s`, `1h 5m 3s`. */
|
|
10
|
+
function elapsed(since: number, now: number): string {
|
|
11
|
+
const seconds = Math.max(0, Math.floor((now - since) / 1000));
|
|
12
|
+
const minutes = Math.floor(seconds / 60);
|
|
13
|
+
if (minutes === 0) return `${seconds}s`;
|
|
14
|
+
return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m ${seconds % 60}s`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `$2.10`, or the input and output tokens as Pi's footer shows them, `7.3M tok`, when a reply had no price. */
|
|
18
|
+
function cost({ cost, tokens, unpriced }: Spend): string {
|
|
19
|
+
if (!unpriced) return `$${cost.toFixed(2)}`;
|
|
20
|
+
if (tokens < 1000) return `${tokens} tok`;
|
|
21
|
+
if (tokens < 10_000) return `${(tokens / 1000).toFixed(1)}k tok`;
|
|
22
|
+
if (tokens < 1_000_000) return `${Math.round(tokens / 1000)}k tok`;
|
|
23
|
+
return tokens < 10_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M tok` : `${Math.round(tokens / 1_000_000)}M tok`;
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
/**
|
|
10
27
|
* The task panel: the Agent's live owned Agents and running background Programs as a tree, in the
|
|
11
|
-
* order they started, with their Programs' live Agents under them
|
|
28
|
+
* order they started, with their Programs' live Agents under them, and how long each has run and what
|
|
29
|
+
* it has cost. Empty when none.
|
|
12
30
|
*/
|
|
13
|
-
export function panelLines(self: AgentNode): string[] {
|
|
31
|
+
export function panelLines(self: AgentNode, now = Date.now()): string[] {
|
|
14
32
|
const rows: Array<{ line: string; parent?: number; shown: boolean }> = [];
|
|
15
33
|
const ids = self.agents.ids();
|
|
16
34
|
let live = 0;
|
|
@@ -26,7 +44,9 @@ export function panelLines(self: AgentNode): string[] {
|
|
|
26
44
|
for (const child of children) {
|
|
27
45
|
const index = rows.length;
|
|
28
46
|
if ("program" in child) {
|
|
29
|
-
|
|
47
|
+
const since = Date.parse(child.program.createdAt ?? "");
|
|
48
|
+
const time = since ? [elapsed(since, now), ...(child.program.spent ? [cost(child.program.spent)] : [])] : [];
|
|
49
|
+
rows.push({ line: `${" ".repeat(level)}${[programLabel(child.program), "running", ...time].join(" ")}`, parent, shown: false });
|
|
30
50
|
live += 1;
|
|
31
51
|
show(index);
|
|
32
52
|
visit(child.program, level + 1, index);
|
|
@@ -35,7 +55,8 @@ export function panelLines(self: AgentNode): string[] {
|
|
|
35
55
|
const { entry } = child;
|
|
36
56
|
const own = node.agents.counts(entry.id);
|
|
37
57
|
const pending = own?.pending ? [`${own.pending} pending`] : [];
|
|
38
|
-
|
|
58
|
+
const time = own?.since ? [elapsed(own.since, now), ...(own.spent ? [cost(own.spent)] : [])] : [];
|
|
59
|
+
rows.push({ line: `${" ".repeat(level)}Agent ${[label(entry, ids), entry.state, ...time, ...pending].join(" ")}`, parent, shown: false });
|
|
39
60
|
if (entry.state !== "idle") {
|
|
40
61
|
live += 1;
|
|
41
62
|
show(index);
|
|
@@ -48,21 +69,30 @@ export function panelLines(self: AgentNode): string[] {
|
|
|
48
69
|
const lines = rows.filter((row) => row.shown).map((row) => row.line);
|
|
49
70
|
if (lines.length === 0) return [];
|
|
50
71
|
if (lines.length > MAX_LINES - 1) lines.splice(MAX_LINES - 2, Infinity, `+${lines.length - (MAX_LINES - 2)} more`);
|
|
51
|
-
return [`Tasks (${live} live, /tasks to
|
|
72
|
+
return [`Tasks (${live} live, /tasks to view)`, ...lines.map((line) => ` ${line}`)];
|
|
52
73
|
}
|
|
53
74
|
|
|
54
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Keeps the task panel above the editor current while the Session is loaded, and its times each second
|
|
77
|
+
* while it shows; `toggle` hides or shows it.
|
|
78
|
+
*/
|
|
55
79
|
export function createPanel(getNode: () => AgentNode | undefined) {
|
|
56
80
|
let ctx: ExtensionContext | undefined;
|
|
57
81
|
let hidden = false;
|
|
58
82
|
let scheduled = false;
|
|
59
83
|
let unsubscribe: (() => void) | undefined;
|
|
84
|
+
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
60
85
|
|
|
61
86
|
const render = () => {
|
|
62
87
|
scheduled = false;
|
|
63
88
|
const node = getNode();
|
|
64
89
|
if (!ctx?.hasUI || !node) return;
|
|
65
90
|
const lines = hidden ? [] : panelLines(node);
|
|
91
|
+
if (lines.length > 0) ticker ??= setInterval(render, 1000);
|
|
92
|
+
else {
|
|
93
|
+
clearInterval(ticker);
|
|
94
|
+
ticker = undefined;
|
|
95
|
+
}
|
|
66
96
|
ctx.ui.setWidget(WIDGET_KEY, lines.length === 0 ? undefined : (_tui, theme) => {
|
|
67
97
|
const panel = new Container();
|
|
68
98
|
lines.forEach((line, index) => panel.addChild(new TruncatedText(theme.fg(index === 0 ? "accent" : "muted", line), 0, 0)));
|
|
@@ -87,6 +117,8 @@ export function createPanel(getNode: () => AgentNode | undefined) {
|
|
|
87
117
|
render();
|
|
88
118
|
},
|
|
89
119
|
stop() {
|
|
120
|
+
clearInterval(ticker);
|
|
121
|
+
ticker = undefined;
|
|
90
122
|
unsubscribe?.();
|
|
91
123
|
unsubscribe = undefined;
|
|
92
124
|
if (ctx?.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
package/src/ui/queue.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AgentSession, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { TruncatedText, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { messageBody, MESSAGE_TYPE, type Sender } from "../agents/registry.ts";
|
|
4
|
+
|
|
5
|
+
const WIDGET_KEY = "pi-agents-queue";
|
|
6
|
+
|
|
7
|
+
type Queued = { role: string; customType?: string; content?: unknown; details?: { delivery?: string; sender?: Sender } };
|
|
8
|
+
|
|
9
|
+
/** One line for an Agent message: its sender and body. */
|
|
10
|
+
function summary({ content, details }: Queued): string {
|
|
11
|
+
const text = typeof content === "string" ? content : "";
|
|
12
|
+
const sender = details?.sender;
|
|
13
|
+
const body = sender && messageBody(text, sender);
|
|
14
|
+
return (body === undefined ? text : `[${sender!.name ?? sender!.id}] ${body}`).replace(/\s+/g, " ").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Agent messages waiting in the Session's queues, which Pi does not list as it lists the user's. */
|
|
18
|
+
export function queuedLines(agent: AgentSession["agent"]): string[] {
|
|
19
|
+
const queues = agent as unknown as Record<"steeringQueue" | "followUpQueue", { messages: Queued[] }>;
|
|
20
|
+
const lines = (label: string, messages: Queued[]) => messages
|
|
21
|
+
.filter((message) => message.role === "custom" && message.customType === MESSAGE_TYPE)
|
|
22
|
+
// A write waits in the steering queue but asks for nothing, so it is labeled apart from a steer.
|
|
23
|
+
.map((message) => `${message.details?.delivery === "write" ? "Write" : label}: ${summary(message)}`);
|
|
24
|
+
return [...lines("Steering", queues.steeringQueue.messages), ...lines("Follow-up", queues.followUpQueue.messages)];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lists the Agent messages queued in `session` under Pi's own queued messages. The list is read from
|
|
29
|
+
* the queues on each render, so it follows them as they are consumed or cleared; `update` renders it
|
|
30
|
+
* after a message is queued.
|
|
31
|
+
*/
|
|
32
|
+
export function showQueue(ctx: ExtensionContext, session: () => AgentSession | undefined) {
|
|
33
|
+
let tui: TUI | undefined;
|
|
34
|
+
if (ctx.hasUI) {
|
|
35
|
+
ctx.ui.setWidget(WIDGET_KEY, (current, theme) => {
|
|
36
|
+
tui = current;
|
|
37
|
+
return {
|
|
38
|
+
render: (width: number) => {
|
|
39
|
+
const agent = session()?.agent;
|
|
40
|
+
return agent ? queuedLines(agent).flatMap((line) => new TruncatedText(theme.fg("dim", line), 1, 0).render(width)) : [];
|
|
41
|
+
},
|
|
42
|
+
invalidate() {},
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { update: () => tui?.requestRender() };
|
|
47
|
+
}
|