@tshu-w/pi-agents 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +81 -2
  3. package/bin/daemon.mjs +204 -0
  4. package/bin/host.mjs +170 -0
  5. package/bin/pd.mjs +139 -0
  6. package/docs/PROGRAM.md +310 -0
  7. package/docs/SPEC.md +427 -0
  8. package/docs/WORKBENCH.md +165 -0
  9. package/package.json +62 -4
  10. package/src/agents/agents.ts +882 -0
  11. package/src/agents/registry.ts +257 -0
  12. package/src/agents/scheduler.ts +204 -0
  13. package/src/agents/search.ts +131 -0
  14. package/src/agents/tool.ts +205 -0
  15. package/src/daemon/client.mjs +84 -0
  16. package/src/daemon/router.mjs +103 -0
  17. package/src/daemon/sessions.mjs +129 -0
  18. package/src/daemon/version.mjs +19 -0
  19. package/src/host/client.mjs +139 -0
  20. package/src/host/extension.ts +74 -0
  21. package/src/host/frames.mjs +34 -0
  22. package/src/host/keys.mjs +19 -0
  23. package/src/index.ts +208 -0
  24. package/src/output.ts +56 -0
  25. package/src/params.ts +4 -0
  26. package/src/pi.mjs +16 -0
  27. package/src/programs/codemode.ts +74 -0
  28. package/src/programs/execute.ts +131 -0
  29. package/src/programs/host-patches.ts +128 -0
  30. package/src/programs/loadout.ts +99 -0
  31. package/src/programs/programs.ts +284 -0
  32. package/src/programs/sandbox.ts +90 -0
  33. package/src/programs/scope.ts +94 -0
  34. package/src/programs/tool.ts +198 -0
  35. package/src/render-call.ts +90 -0
  36. package/src/roots/discovery.mjs +113 -0
  37. package/src/roots/guard.mjs +227 -0
  38. package/src/roots/lock-extension.mjs +167 -0
  39. package/src/roots/locks.mjs +167 -0
  40. package/src/roots/paths.mjs +66 -0
  41. package/src/roots/report.ts +156 -0
  42. package/src/roots/runtime.ts +174 -0
  43. package/src/roots/snapshot.mjs +46 -0
  44. package/src/roots/socket-recovery.mjs +21 -0
  45. package/src/roots/transport.mjs +341 -0
  46. package/src/ui/panel.ts +96 -0
  47. package/src/ui/viewer.ts +445 -0
  48. package/src/workbench.mjs +587 -0
@@ -0,0 +1,341 @@
1
+ import net from 'node:net';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ import { chmodSync, lstatSync, mkdirSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+
6
+ const MAX_BYTES = 1024 * 1024;
7
+ const MAX_SOCKET_PATH_BYTES = 103;
8
+ const MAX_PENDING = 64;
9
+ // Each running Pi keeps a stream open to the daemon.
10
+ const MAX_CONNECTIONS = 1024;
11
+ const TIMEOUT_MS = 10000;
12
+
13
+ function fault(code, message) {
14
+ return Object.assign(new Error(message), { code });
15
+ }
16
+
17
+ function validateSocketPath(path) {
18
+ if (Buffer.byteLength(path, 'utf8') > MAX_SOCKET_PATH_BYTES) {
19
+ throw fault('SOCKET_PATH_TOO_LONG', `Unix socket paths must fit within ${MAX_SOCKET_PATH_BYTES} UTF-8 bytes; use a shorter PI_AGENTS_STATE_DIR: ${path}`);
20
+ }
21
+ }
22
+
23
+ function stat(path) {
24
+ try { return lstatSync(path); }
25
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
26
+ }
27
+
28
+ function safeDirectory(path, create = false) {
29
+ if (create) {
30
+ try { mkdirSync(path, { mode: 0o700 }); }
31
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
32
+ }
33
+ const info = lstatSync(path);
34
+ if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o022) !== 0) {
35
+ throw fault('UNSAFE_PATH', 'Socket parent must be owned by the current user and not group/world writable');
36
+ }
37
+ }
38
+
39
+ function privateSocket(path) {
40
+ safeDirectory(dirname(path));
41
+ const info = lstatSync(path);
42
+ if (!info.isSocket() || info.uid !== process.getuid() || (info.mode & 0o777) !== 0o600) {
43
+ throw fault('UNSAFE_PATH', 'Socket must be owned by the current user with mode 0600');
44
+ }
45
+ }
46
+
47
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
48
+ const text = value => typeof value === 'string' && value.trim().length > 0;
49
+
50
+ function validate(value) {
51
+ if (!record(value)) throw fault('INVALID_REQUEST', 'Expected a request object');
52
+ if (value.action === 'status') return { action: 'status' };
53
+ if (value.action === 'stream') {
54
+ if (value.role !== 'session' && value.role !== 'workbench') throw fault('INVALID_REQUEST', 'stream role must be session or workbench');
55
+ return { action: 'stream', role: value.role };
56
+ }
57
+ const m = value.message, l = value.launch;
58
+ if (value.action !== 'deliver' || !record(m) || !text(m.id)
59
+ || (m.user !== true && (!record(m.sender) || !text(m.sender.id) || (m.sender.name !== undefined && !text(m.sender.name))))
60
+ || !text(m.recipient) || !text(m.body)) {
61
+ throw fault('INVALID_REQUEST', 'Expected status or deliver with nonempty message id, sender or user, recipient and body');
62
+ }
63
+ if (m.deliverAs !== undefined && m.deliverAs !== 'followUp' && m.deliverAs !== 'steer' && m.deliverAs !== 'write') {
64
+ throw fault('INVALID_REQUEST', 'deliverAs must be followUp, steer or write');
65
+ }
66
+ if (l !== undefined && (!record(l) || !text(l.command) || !Array.isArray(l.args) || !l.args.every(arg => typeof arg === 'string')
67
+ || !record(l.env) || !Object.values(l.env).every(entry => typeof entry === 'string'))) {
68
+ throw fault('INVALID_REQUEST', 'launch must have a command, string args and a string env');
69
+ }
70
+ return { action: 'deliver', message: {
71
+ id: m.id,
72
+ ...(m.user === true ? { user: true } : { sender: { id: m.sender.id, ...(m.sender.name === undefined ? {} : { name: m.sender.name }) } }),
73
+ recipient: m.recipient, body: m.body,
74
+ ...(m.deliverAs === undefined ? {} : { deliverAs: m.deliverAs }),
75
+ }, ...(l === undefined ? {} : { launch: { command: l.command, args: [...l.args], env: { ...l.env } } }) };
76
+ }
77
+
78
+ function encode(value) {
79
+ const bytes = Buffer.from(`${JSON.stringify(value)}\n`);
80
+ if (bytes.length > MAX_BYTES) throw fault('FRAME_TOO_LARGE', 'JSONL frame exceeds 1 MiB');
81
+ return bytes;
82
+ }
83
+
84
+ // Consume exactly one bounded JSONL frame; never dispatch additional requests.
85
+ function readFrame(socket, done) {
86
+ let chunks = [], size = 0, finished = false;
87
+ function finish(error, value) {
88
+ if (finished) return;
89
+ finished = true;
90
+ chunks = [];
91
+ done(error, value);
92
+ }
93
+ socket.on('data', chunk => {
94
+ if (finished) return;
95
+ size += chunk.length;
96
+ if (size > MAX_BYTES) return finish(fault('FRAME_TOO_LARGE', 'JSONL frame exceeds 1 MiB'));
97
+ chunks.push(chunk);
98
+ const newline = chunk.indexOf(10);
99
+ if (newline < 0) return;
100
+ if (newline !== chunk.length - 1) return finish(fault('INVALID_FRAME', 'Expected one JSONL frame'));
101
+ try {
102
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
103
+ const value = JSON.parse(source);
104
+ finish(null, value);
105
+ } catch { finish(fault('INVALID_FRAME', 'Invalid JSONL frame')); }
106
+ });
107
+ socket.on('end', () => finish(fault('INVALID_FRAME', 'Connection ended before a complete JSONL frame')));
108
+ }
109
+
110
+ // JSONL messages after a stream's opening frame.
111
+ function readLines(socket, onLine, onInvalid) {
112
+ const decoder = new StringDecoder('utf8');
113
+ let buffer = '';
114
+ socket.on('data', chunk => {
115
+ buffer += decoder.write(chunk);
116
+ for (let newline; (newline = buffer.indexOf('\n')) >= 0;) {
117
+ const line = buffer.slice(0, newline);
118
+ buffer = buffer.slice(newline + 1);
119
+ let value;
120
+ try { value = JSON.parse(line); } catch { return onInvalid(); }
121
+ onLine(value);
122
+ }
123
+ if (Buffer.byteLength(buffer) > MAX_BYTES) onInvalid();
124
+ });
125
+ }
126
+
127
+ function channel(socket, signal) {
128
+ const listeners = [];
129
+ readLines(socket, value => { for (const listener of listeners) listener(value); }, () => socket.destroy());
130
+ return {
131
+ signal,
132
+ send(value) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(value)); },
133
+ onMessage(listener) { listeners.push(listener); },
134
+ close() { socket.destroy(); },
135
+ };
136
+ }
137
+
138
+ function errorFrame(error) {
139
+ return encode({ ok: false, error: {
140
+ code: typeof error?.code === 'string' ? error.code : 'HANDLER_ERROR',
141
+ message: typeof error?.message === 'string' ? error.message.slice(0, 4096) : 'Handler failed',
142
+ ...(typeof error?.uncertainDelivery === 'boolean' ? { uncertainDelivery: error.uncertainDelivery } : {}),
143
+ } });
144
+ }
145
+
146
+ /**
147
+ * 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.
149
+ * @param {string} socketPath
150
+ * @param {{ status: Function, accept: Function, stream?: Function, serialize?: boolean }} handlers
151
+ */
152
+ export async function listen(socketPath, { status, accept, stream, serialize = true }) {
153
+ socketPath = resolve(socketPath);
154
+ validateSocketPath(socketPath);
155
+ const parent = dirname(socketPath);
156
+ safeDirectory(parent, true);
157
+ // Other users must not reach the socket before its mode is set.
158
+ if (lstatSync(parent).mode & 0o077) throw fault('UNSAFE_PATH', 'Socket parent must be private to the current user');
159
+ if (stat(socketPath)) throw fault('EADDRINUSE', 'Socket path already exists');
160
+ const clients = new Map();
161
+ const queued = new Map();
162
+ const closedError = Object.assign(fault('SERVER_CLOSED', 'Server is closing; request was not invoked'), { uncertainDelivery: false });
163
+ let queue = Promise.resolve(), pending = 0, closing, lastActivity = Date.now();
164
+ const server = net.createServer({ allowHalfOpen: true }, socket => {
165
+ const controller = new AbortController();
166
+ const { signal } = controller;
167
+ clients.set(socket, controller);
168
+ lastActivity = Date.now();
169
+ function disconnect() {
170
+ controller.abort();
171
+ queued.get(socket)?.(signal.reason);
172
+ queued.delete(socket);
173
+ }
174
+ socket.on('error', () => { disconnect(); socket.destroy(); });
175
+ const timer = setTimeout(() => { disconnect(); socket.destroy(); }, TIMEOUT_MS);
176
+ socket.on('close', () => { disconnect(); clearTimeout(timer); clients.delete(socket); lastActivity = Date.now(); });
177
+ function respond(error, result) {
178
+ if (socket.destroyed || socket.writableEnded) return;
179
+ let bytes;
180
+ try { bytes = error ? errorFrame(error) : encode({ ok: true, result }); }
181
+ catch (failure) { bytes = errorFrame(failure); }
182
+ socket.end(bytes);
183
+ }
184
+ readFrame(socket, (error, value) => {
185
+ if (error) return respond(error);
186
+ let input;
187
+ try { input = validate(value); }
188
+ catch (failure) { return respond(failure); }
189
+ if (input.action === 'stream') {
190
+ if (!stream) return respond(fault('INVALID_REQUEST', 'Streams are not supported here'));
191
+ clearTimeout(timer);
192
+ socket.write(encode({ ok: true, result: { pid: process.pid } }));
193
+ return stream(input, channel(socket, signal));
194
+ }
195
+ if (pending >= MAX_PENDING) return respond(fault('SERVER_BUSY', 'Request queue is full'));
196
+ pending++;
197
+ const serialized = serialize && input.action !== 'status';
198
+ const work = new Promise((resolve, reject) => {
199
+ queued.set(socket, reject);
200
+ const invocation = (serialized ? queue : Promise.resolve()).then(async () => {
201
+ queued.delete(socket);
202
+ if (closing) throw closedError;
203
+ signal.throwIfAborted();
204
+ if (input.action === 'status') return status({ signal });
205
+ return accept(input.message, { signal, ...(input.launch ? { launch: input.launch } : {}) });
206
+ });
207
+ // Cancelling a queued response must not let later requests bypass its predecessor.
208
+ if (serialized) queue = invocation.catch(() => {});
209
+ invocation.then(resolve, reject).finally(() => { pending--; lastActivity = Date.now(); });
210
+ });
211
+ work.then(result => respond(null, result), respond);
212
+ });
213
+ // Clients keep the write side open until acknowledgement. EOF also signals
214
+ // sender exit; allowHalfOpen otherwise delays close until the handler returns.
215
+ socket.on('end', () => { disconnect(); socket.destroySoon(); });
216
+ });
217
+ server.maxConnections = MAX_CONNECTIONS;
218
+ try {
219
+ await new Promise((resolve, reject) => {
220
+ server.once('error', reject);
221
+ server.listen(socketPath, () => { server.removeListener('error', reject); resolve(); });
222
+ });
223
+ chmodSync(socketPath, 0o600);
224
+ } catch (error) {
225
+ await new Promise(resolve => server.close(resolve));
226
+ throw error;
227
+ }
228
+ return {
229
+ closeIfIdle(idleMs) {
230
+ if (clients.size || pending || Date.now() - lastActivity < idleMs) return;
231
+ return this.close();
232
+ },
233
+ close() {
234
+ if (closing) return closing;
235
+ closing = Promise.resolve().then(() => new Promise((resolve, reject) => {
236
+ for (const [socket, reject] of queued) {
237
+ reject(closedError);
238
+ if (!socket.destroyed && !socket.writableEnded) socket.end(errorFrame(closedError));
239
+ socket.destroySoon();
240
+ }
241
+ for (const [socket, controller] of clients) {
242
+ controller.abort();
243
+ if (!queued.has(socket)) socket.destroy();
244
+ }
245
+ queued.clear();
246
+ // Node removes the socket file; the caller holds the lock on its path until then.
247
+ server.close(error => error ? reject(error) : resolve());
248
+ }));
249
+ return closing;
250
+ },
251
+ };
252
+ }
253
+
254
+ /**
255
+ * No retries. Timeout/cancellation of deliver always reports uncertainDelivery.
256
+ * @param {string} socketPath
257
+ * @param {object} input
258
+ * @param {{ signal?: AbortSignal, timeoutMs?: number }} [options]
259
+ * @returns {Promise<any>}
260
+ */
261
+ export async function request(socketPath, input, { signal, timeoutMs = TIMEOUT_MS } = {}) {
262
+ const value = validate(input);
263
+ const bytes = encode(value);
264
+ const interrupted = (code, message) => Object.assign(fault(code, message
265
+ + (value.action === 'deliver' ? '; delivery is uncertain: the recipient may have received it' : '')),
266
+ { uncertainDelivery: value.action === 'deliver' });
267
+ if (signal?.aborted) throw interrupted('ABORT_ERR', 'Request cancelled');
268
+ socketPath = resolve(socketPath);
269
+ validateSocketPath(socketPath);
270
+ privateSocket(socketPath);
271
+ return new Promise((resolve, reject) => {
272
+ const socket = net.connect(socketPath);
273
+ let settled = false, sent = false;
274
+ function finish(error, result, remote = false) {
275
+ if (settled) return;
276
+ settled = true;
277
+ clearTimeout(timer);
278
+ signal?.removeEventListener('abort', abort);
279
+ socket.destroy();
280
+ if (error) {
281
+ if (!remote && sent && value.action === 'deliver') error.uncertainDelivery = true;
282
+ reject(error);
283
+ } else resolve(result);
284
+ }
285
+ const abort = () => finish(interrupted('ABORT_ERR', 'Request cancelled'));
286
+ const timer = setTimeout(() => finish(interrupted('ETIMEDOUT', 'Acknowledgement timed out')), timeoutMs);
287
+ signal?.addEventListener('abort', abort, { once: true });
288
+ socket.on('error', error => finish(error));
289
+ socket.on('close', () => finish(fault('CONNECTION_CLOSED', 'Connection closed before acknowledgement')));
290
+ socket.on('connect', () => { sent = true; socket.write(bytes); });
291
+ readFrame(socket, (error, response) => {
292
+ if (error) return finish(error);
293
+ if (record(response) && response.ok === true && Object.hasOwn(response, 'result')) return finish(null, response.result);
294
+ if (record(response) && response.ok === false && record(response.error)
295
+ && text(response.error.code) && typeof response.error.message === 'string') {
296
+ const failure = fault(response.error.code, response.error.message);
297
+ if (typeof response.error.uncertainDelivery === 'boolean') failure.uncertainDelivery = response.error.uncertainDelivery;
298
+ return finish(failure, undefined, true);
299
+ }
300
+ finish(fault('INVALID_RESPONSE', 'Invalid response'));
301
+ });
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Opens a stream: resolves once the server accepts it, then passes each message to `onMessage`
307
+ * and calls `onClose` when the connection ends.
308
+ */
309
+ export function openStream(socketPath, input, { onMessage, onClose }) {
310
+ const value = validate(input);
311
+ socketPath = resolve(socketPath);
312
+ validateSocketPath(socketPath);
313
+ privateSocket(socketPath);
314
+ return new Promise((resolvePromise, reject) => {
315
+ const socket = net.connect(socketPath);
316
+ let opened = false;
317
+ const timer = setTimeout(() => socket.destroy(fault('ETIMEDOUT', 'Stream was not accepted')), TIMEOUT_MS);
318
+ socket.on('error', error => { if (!opened) reject(error); });
319
+ socket.on('close', () => {
320
+ clearTimeout(timer);
321
+ if (opened) onClose?.();
322
+ else reject(fault('CONNECTION_CLOSED', 'Connection closed before the stream opened'));
323
+ });
324
+ socket.on('connect', () => socket.write(encode(value)));
325
+ readLines(socket, response => {
326
+ if (opened) return onMessage(response);
327
+ clearTimeout(timer);
328
+ if (!record(response) || response.ok !== true) {
329
+ socket.destroy();
330
+ return reject(fault(response?.error?.code ?? 'INVALID_RESPONSE', response?.error?.message ?? 'Stream was refused'));
331
+ }
332
+ opened = true;
333
+ resolvePromise({
334
+ result: response.result,
335
+ send(message) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(message)); },
336
+ close() { socket.destroy(); },
337
+ unref() { socket.unref(); },
338
+ });
339
+ }, () => socket.destroy());
340
+ });
341
+ }
@@ -0,0 +1,96 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Container, TruncatedText } from "@earendil-works/pi-tui";
3
+ import { label, nodes, onTreeChange, programLabel, programsOf, type AgentNode } from "../agents/registry.ts";
4
+
5
+ const WIDGET_KEY = "pi-agents";
6
+
7
+ const MAX_LINES = 10;
8
+
9
+ /**
10
+ * 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. Empty when none.
12
+ */
13
+ export function panelLines(self: AgentNode): string[] {
14
+ const rows: Array<{ line: string; parent?: number; shown: boolean }> = [];
15
+ const ids = self.agents.ids();
16
+ let live = 0;
17
+ // A live row is listed with its owners, so the tree stays readable.
18
+ const show = (index: number | undefined) => {
19
+ for (let row = index === undefined ? undefined : rows[index]; row && !row.shown; row = row.parent === undefined ? undefined : rows[row.parent]) row.shown = true;
20
+ };
21
+ const visit = (node: AgentNode, level: number, parent?: number) => {
22
+ const children = [
23
+ ...node.agents.owned().map((entry) => ({ at: entry.createdAt ?? "", entry })),
24
+ ...programsOf(node).filter((program) => program.background).map((program) => ({ at: program.createdAt ?? "", program })),
25
+ ].sort((a, b) => a.at.localeCompare(b.at));
26
+ for (const child of children) {
27
+ const index = rows.length;
28
+ if ("program" in child) {
29
+ rows.push({ line: `${" ".repeat(level)}${programLabel(child.program)} running`, parent, shown: false });
30
+ live += 1;
31
+ show(index);
32
+ visit(child.program, level + 1, index);
33
+ continue;
34
+ }
35
+ const { entry } = child;
36
+ const own = node.agents.counts(entry.id);
37
+ const pending = own?.pending ? [`${own.pending} pending`] : [];
38
+ rows.push({ line: `${" ".repeat(level)}Agent ${[label(entry, ids), entry.state, ...pending].join(" ")}`, parent, shown: false });
39
+ if (entry.state !== "idle") {
40
+ live += 1;
41
+ show(index);
42
+ }
43
+ const loaded = nodes.get(entry.id);
44
+ if (loaded) visit(loaded, level + 1, index);
45
+ }
46
+ };
47
+ visit(self, 0);
48
+ const lines = rows.filter((row) => row.shown).map((row) => row.line);
49
+ if (lines.length === 0) return [];
50
+ 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 hide)`, ...lines.map((line) => ` ${line}`)];
52
+ }
53
+
54
+ /** Keeps the task panel above the editor current while the Session is loaded; `toggle` hides or shows it. */
55
+ export function createPanel(getNode: () => AgentNode | undefined) {
56
+ let ctx: ExtensionContext | undefined;
57
+ let hidden = false;
58
+ let scheduled = false;
59
+ let unsubscribe: (() => void) | undefined;
60
+
61
+ const render = () => {
62
+ scheduled = false;
63
+ const node = getNode();
64
+ if (!ctx?.hasUI || !node) return;
65
+ const lines = hidden ? [] : panelLines(node);
66
+ ctx.ui.setWidget(WIDGET_KEY, lines.length === 0 ? undefined : (_tui, theme) => {
67
+ const panel = new Container();
68
+ lines.forEach((line, index) => panel.addChild(new TruncatedText(theme.fg(index === 0 ? "accent" : "muted", line), 0, 0)));
69
+ return panel;
70
+ });
71
+ };
72
+ const update = () => {
73
+ if (scheduled) return;
74
+ scheduled = true;
75
+ setImmediate(render);
76
+ };
77
+
78
+ return {
79
+ start(current: ExtensionContext) {
80
+ ctx = current;
81
+ unsubscribe ??= onTreeChange(update);
82
+ update();
83
+ },
84
+ update,
85
+ toggle() {
86
+ hidden = !hidden;
87
+ render();
88
+ },
89
+ stop() {
90
+ unsubscribe?.();
91
+ unsubscribe = undefined;
92
+ if (ctx?.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
93
+ ctx = undefined;
94
+ },
95
+ };
96
+ }