@tshu-w/pi-agents 0.1.1 → 0.2.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.
@@ -0,0 +1,360 @@
1
+ // `pd -p`, `pd --mode json`, and `pd --mode rpc`: Pi's non-interactive modes as clients of a Session in
2
+ // a host, which other clients and an attached terminal can use at the same time.
3
+ import { resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { alive, hostStatus, settledHosts, startHost } from './client.mjs';
7
+ import { readSessionHeader, readSessionId } from '../roots/guard.mjs';
8
+ import { openStream, request } from '../roots/transport.mjs';
9
+ import { piPackage } from '../pi.mjs';
10
+ import { PROGRAM_MESSAGE, runOutput } from '../programs/run-output.mjs';
11
+
12
+ const TIMEOUT_MS = 30000;
13
+ const NOT_READY = ['ENOENT', 'ECONNREFUSED', 'SOCKET_MODE', 'NOT_READY'];
14
+ const GONE = ['ENOENT', 'ECONNREFUSED', 'ECONNRESET'];
15
+ // Options that choose the Session; another Session opens with the rest.
16
+ const SESSION_FLAGS = new Set(['--session', '--session-id', '--fork', '--name', '-n']);
17
+ const SESSION_SWITCHES = new Set(['-c', '--continue', '-r', '--resume']);
18
+
19
+ function fault(code, message) {
20
+ return Object.assign(new Error(message), { code });
21
+ }
22
+
23
+ function stopped(paths) {
24
+ return fault('HOST_STOPPED', `Pi stopped before the Session was ready. See ${paths.hostLog}`);
25
+ }
26
+
27
+ /** Opens a `role` stream to Session `id` once its Pi is ready, while `live()` holds. */
28
+ async function connect(paths, id, handlers, live, role = 'rpc') {
29
+ const socket = paths.session(id);
30
+ const deadline = performance.now() + TIMEOUT_MS;
31
+ for (;;) {
32
+ try {
33
+ const status = await request(socket, { action: 'status' });
34
+ if (status.ready) {
35
+ if (status.availabilityError) throw new Error(status.availabilityError);
36
+ return { status, stream: await openStream(socket, { action: 'stream', role }, handlers) };
37
+ }
38
+ } catch (error) { if (!NOT_READY.includes(error.code)) throw error; }
39
+ if (!live()) throw stopped(paths);
40
+ if (performance.now() > deadline) throw new Error(`The Session did not become ready. See ${paths.hostLog}`);
41
+ await delay(50);
42
+ }
43
+ }
44
+
45
+ /** `args` without the prompts, and without the options that make Pi non-interactive. */
46
+ function withoutPrompts(parseArgs, args) {
47
+ const options = [];
48
+ let separated = false;
49
+ for (let i = 0, before = parseArgs([]); i < args.length; i++) {
50
+ const after = parseArgs(args.slice(0, i + 1));
51
+ const positional = after.messages.length > before.messages.length || after.fileArgs.length > before.fileArgs.length;
52
+ before = after;
53
+ if (separated || positional) continue;
54
+ if (args[i] === '--') separated = true;
55
+ else if (args[i] === '--mode') i++;
56
+ else if (args[i] !== '-p' && args[i] !== '--print') options.push(args[i]);
57
+ }
58
+ return options;
59
+ }
60
+
61
+ /** The prompts Pi sends on startup for `parsed`: its initial message, then each further message. */
62
+ async function startupPrompts(dist, parsed, stdinContent) {
63
+ const { buildInitialMessage } = await dist('cli/initial-message.js');
64
+ let files = {};
65
+ if (parsed.fileArgs.length) {
66
+ const { processFileArguments } = await dist('cli/file-processor.js');
67
+ const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages: false });
68
+ files = { fileText: text, fileImages: images };
69
+ }
70
+ const { initialMessage, initialImages } = buildInitialMessage({ parsed, stdinContent, ...files });
71
+ return [...(initialMessage ? [{ message: initialMessage, images: initialImages }] : []), ...parsed.messages.map(message => ({ message }))];
72
+ }
73
+
74
+ function write(value) {
75
+ process.stdout.write(`${JSON.stringify(value)}\n`);
76
+ }
77
+
78
+ /**
79
+ * Runs Pi's `mode` ('text', 'json', or 'rpc') for `args` on a hosted Session: the host `findRunning` resolves to,
80
+ * already holding the Session `args` choose, or a new host. `warnIgnored` reports the options a running host does not apply.
81
+ */
82
+ export async function runHeadless({ pi, paths, args, mode, findRunning, warnIgnored }) {
83
+ const dist = file => import(pathToFileURL(`${piPackage(pi)}/dist/${file}`).href);
84
+ const { parseArgs } = await dist('cli/args.js');
85
+ const parsed = parseArgs(args);
86
+ if (mode === 'rpc' && parsed.fileArgs.length) {
87
+ process.stderr.write('Error: @file arguments are not supported in RPC mode\n');
88
+ process.exit(1);
89
+ }
90
+
91
+ // The host runs Pi without the prompts, which arrive as RPC commands instead.
92
+ const hostArgs = withoutPrompts(parseArgs, args);
93
+ const sessionArgs = hostArgs.filter((arg, i) => !SESSION_FLAGS.has(arg) && !SESSION_SWITCHES.has(arg) && !SESSION_FLAGS.has(hostArgs[i - 1]));
94
+
95
+ /** Starts a host for `hostArgs` in `cwd`; resolves once its Pi reports the Session. */
96
+ async function host(hostArgs, cwd) {
97
+ const socket = await startHost(paths, { pi, args: hostArgs, cwd, waitForTerminal: false });
98
+ const deadline = performance.now() + TIMEOUT_MS;
99
+ for (;;) {
100
+ let status;
101
+ try { status = await hostStatus(socket); }
102
+ catch (error) { throw GONE.includes(error.code) ? stopped(paths) : error; }
103
+ if (status.session?.id) return status;
104
+ if (performance.now() > deadline) throw new Error(`Pi did not start. See ${paths.hostLog}`);
105
+ await delay(50);
106
+ }
107
+ }
108
+
109
+ /** Connects to the Session in `file`, starting it in a host in `cwd` if no Pi runs it. */
110
+ async function open(file, cwd, handlers) {
111
+ const deadline = performance.now() + TIMEOUT_MS;
112
+ for (;;) {
113
+ const id = readSessionId(file);
114
+ const holds = h => h.session && (h.session.id === id || h.session.file === file);
115
+ try {
116
+ const held = (await settledHosts(paths, holds)).find(holds);
117
+ if (held) return await connect(paths, held.session.id, handlers, () => alive(held.pid));
118
+ if (id && await request(paths.session(id), { action: 'status' }).then(() => true, () => false)) {
119
+ return await connect(paths, id, handlers, () => true);
120
+ }
121
+ const status = await host([...sessionArgs, '--session', file], cwd);
122
+ return await connect(paths, status.session.id, handlers, () => alive(status.pid));
123
+ } catch (error) {
124
+ // Its Pi may be stopping, another client may have just started it, or its previous Pi may still hold its lock.
125
+ if (error.code !== 'HOST_STOPPED' || performance.now() > deadline) throw error;
126
+ }
127
+ await delay(200);
128
+ }
129
+ }
130
+
131
+ /** Opens a `role` stream to the Session `args` choose. */
132
+ async function start(handlers, role) {
133
+ let running = await findRunning(), status;
134
+ if (!running) {
135
+ try { status = await host(hostArgs, process.cwd()); }
136
+ catch (error) {
137
+ // Another client may have started the same Session first.
138
+ if (error.code !== 'HOST_STOPPED' || !(running = await findRunning())) throw error;
139
+ }
140
+ }
141
+ if (!running) return connect(paths, status.session.id, handlers, () => alive(status.pid), role);
142
+ warnIgnored(hostArgs);
143
+ return connect(paths, running.session.id, handlers, () => alive(running.pid), role);
144
+ }
145
+
146
+ try {
147
+ if (mode === 'rpc') await rpc({ start, open, dist });
148
+ else await print({ start, parsed, mode, dist });
149
+ } catch (error) {
150
+ process.stderr.write(`${error.message}\n`);
151
+ process.exit(1);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Prepares the messages in `args` for the Session in the host `running`, as Pi sends them on startup; `warnIgnored`
157
+ * reports the other options, which a running host does not apply. Resolves to a function that sends them, each as a
158
+ * follow-up once the previous one is accepted, and resolves to the error that stopped it, if any.
159
+ */
160
+ export async function startupMessages({ pi, paths, args, running, warnIgnored }) {
161
+ const dist = file => import(pathToFileURL(`${piPackage(pi)}/dist/${file}`).href);
162
+ const { parseArgs } = await dist('cli/args.js');
163
+ warnIgnored(withoutPrompts(parseArgs, args));
164
+ const prompts = await startupPrompts(dist, parseArgs(args));
165
+ return async () => {
166
+ if (!prompts.length) return;
167
+ try {
168
+ let answer;
169
+ const { stream } = await connect(paths, running.session.id, {
170
+ onMessage(value) { if (value.type === 'response') answer?.(value); },
171
+ onClose() { answer?.({ success: false, error: 'The Session stopped.' }); },
172
+ }, () => alive(running.pid));
173
+ for (const { message, images } of prompts) {
174
+ const response = await new Promise(resolve => {
175
+ answer = resolve;
176
+ stream.send({ type: 'prompt', message, images, streamingBehavior: 'followUp' });
177
+ });
178
+ if (!response.success) throw new Error(response.error);
179
+ }
180
+ stream.close();
181
+ } catch (error) {
182
+ return error.message;
183
+ }
184
+ };
185
+ }
186
+
187
+ async function print({ start, parsed, mode, dist }) {
188
+ let stdinContent;
189
+ if (!process.stdin.isTTY) {
190
+ let data = '';
191
+ process.stdin.setEncoding('utf8');
192
+ for await (const chunk of process.stdin) data += chunk;
193
+ stdinContent = data.trim() || undefined;
194
+ }
195
+ const prompts = await startupPrompts(dist, parsed, stdinContent);
196
+
197
+ // The host runs the prompts once the Session is idle, and sends the events of their turns.
198
+ let header, last, stopping, done = false, finish;
199
+ const finished = new Promise(resolve => { finish = resolve; });
200
+ const { stream } = await start({
201
+ onMessage(value) {
202
+ if (value.start && mode === 'json' && header) write(header);
203
+ if (value.event) {
204
+ if (mode === 'json') write(value.event);
205
+ if (value.event.type === 'message_end') {
206
+ last = value.event.message;
207
+ // A `/program` run prints when it ends, as with `pi -p`; a failed one sets status 1.
208
+ if (mode === 'text' && last.role === 'custom' && last.customType === PROGRAM_MESSAGE) {
209
+ const body = runOutput(last.content.slice(1));
210
+ if (last.details?.isError) {
211
+ process.stderr.write(`${body}\n`);
212
+ process.exitCode = 1;
213
+ } else process.stdout.write(`${body}\n`);
214
+ }
215
+ }
216
+ }
217
+ if (value.end) finish(value);
218
+ },
219
+ onClose() {
220
+ if (done) return;
221
+ if (stopping !== undefined) process.exit(stopping);
222
+ process.stderr.write('The Session stopped.\n');
223
+ process.exit(1);
224
+ },
225
+ }, 'print');
226
+ header = stream.result.header;
227
+
228
+ // A signal withdraws the prompts or stops their turn, and exits with Pi's code; a second one exits at once.
229
+ for (const [signal, code] of Object.entries({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 })) {
230
+ process.on(signal, () => {
231
+ if (stopping !== undefined) process.exit(stopping);
232
+ stopping = code;
233
+ stream.send({ type: 'stop' });
234
+ });
235
+ }
236
+ stream.send({ type: 'run', prompts });
237
+ const end = await finished;
238
+ done = true;
239
+ stream.close();
240
+ if (stopping !== undefined) {
241
+ process.exitCode = stopping;
242
+ return;
243
+ }
244
+ if (end.error) throw new Error(end.error);
245
+ if (mode === 'text' && last?.role === 'assistant') {
246
+ if (last.stopReason === 'error' || last.stopReason === 'aborted') {
247
+ process.stderr.write(`${last.errorMessage || `Request ${last.stopReason}`}\n`);
248
+ process.exitCode = 1;
249
+ }
250
+ else {
251
+ for (const content of last.content) if (content.type === 'text') process.stdout.write(`${content.text}\n`);
252
+ // As Pi's print mode does.
253
+ if (last.stopReason === 'length') {
254
+ process.stderr.write('Response was truncated: the model reached its output token limit.\n');
255
+ process.exitCode = 1;
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ async function rpc({ start, open, dist }) {
262
+ const { attachJsonlLineReader } = await dist('modes/rpc/jsonl.js');
263
+ let current, reopening, queue = Promise.resolve();
264
+ const enqueue = task => { queue = queue.then(task).catch(fail); };
265
+ const failure = (command, error) => ({ id: command.id, type: 'response', command: command.type, success: false, error });
266
+
267
+ function fail(error) {
268
+ process.stderr.write(`${error.message}\n`);
269
+ process.exit(1);
270
+ }
271
+
272
+ /** A connection made by `connecting`; it delivers until another replaces it. */
273
+ async function connection(connecting) {
274
+ // Commands sent on this connection that await their response; `resolve` takes it instead of stdout.
275
+ const conn = { retired: false, pending: [] };
276
+ Object.assign(conn, await connecting({
277
+ onMessage(value) {
278
+ if (conn.retired) return;
279
+ if (value.type === 'response') {
280
+ const at = conn.pending.findIndex(entry => entry.command.id === value.id && entry.command.type === value.command);
281
+ const [entry] = at >= 0 ? conn.pending.splice(at, 1) : [];
282
+ if (entry?.resolve) return entry.resolve(value);
283
+ }
284
+ write(value);
285
+ },
286
+ onClose() {
287
+ if (conn.retired) return;
288
+ retire(conn, 'The Session stopped.');
289
+ // The host stopped or moved to another Session: open this one again.
290
+ reopening = connection(handlers => open(conn.status.sessionFile, conn.status.cwd, handlers)).then(next => {
291
+ if (current === conn) return use(next);
292
+ next.retired = true;
293
+ next.stream.close();
294
+ }, fail);
295
+ },
296
+ }));
297
+ return conn;
298
+ }
299
+
300
+ /** Sends `command` to the current Session; with `resolve`, its response goes there. */
301
+ function send(command, resolve) {
302
+ if (current.retired) return (resolve ?? write)(failure(command, 'The Session stopped.'));
303
+ if (command?.type !== 'extension_ui_response') current.pending.push({ command, resolve });
304
+ current.stream.send(command);
305
+ }
306
+
307
+ /** Stops delivering `conn`; a command still awaiting its response may have run or not, so it is answered, not sent again. */
308
+ function retire(conn, reason) {
309
+ conn.retired = true;
310
+ for (const { command, resolve } of conn.pending.splice(0)) (resolve ?? write)(failure(command, reason));
311
+ }
312
+
313
+ function use(conn) {
314
+ const previous = current;
315
+ current = conn;
316
+ if (previous && !previous.retired) {
317
+ retire(previous, 'The client moved to another Session.');
318
+ previous.stream.close();
319
+ }
320
+ }
321
+
322
+ async function move(command, file, cwd, response) {
323
+ try {
324
+ use(await connection(handlers => open(file, cwd, handlers)));
325
+ write(response);
326
+ } catch (error) {
327
+ write({ id: command.id, type: 'response', command: command.type, success: false, error: error.message });
328
+ }
329
+ }
330
+
331
+ async function handle(line) {
332
+ await reopening;
333
+ let command;
334
+ try { command = JSON.parse(line); }
335
+ catch (error) {
336
+ return write({ type: 'response', command: 'parse', success: false, error: `Failed to parse command: ${error.message}` });
337
+ }
338
+ if (command?.type === 'switch_session') {
339
+ const file = resolve(command.sessionPath);
340
+ const cwd = readSessionHeader(file)?.cwd ?? current.status.cwd;
341
+ return move(command, file, cwd, { id: command.id, type: 'response', command: command.type, success: true, data: { cancelled: false } });
342
+ }
343
+ if (['new_session', 'fork', 'clone'].includes(command?.type)) {
344
+ // The Session creates the new Session, which only this client moves to.
345
+ const response = await new Promise(resolve => send(command, resolve));
346
+ if (!response.success || !response.data?.sessionFile) return write(response);
347
+ const { sessionFile, ...data } = response.data;
348
+ return move(command, sessionFile, current.status.cwd, { ...response, data });
349
+ }
350
+ send(command);
351
+ }
352
+
353
+ use(await connection(start));
354
+ attachJsonlLineReader(process.stdin, line => enqueue(() => handle(line)));
355
+ process.stdin.on('end', () => enqueue(async () => {
356
+ await reopening;
357
+ current.retired = true;
358
+ current.stream.close();
359
+ }));
360
+ }
package/src/index.ts CHANGED
@@ -36,9 +36,12 @@ import { installGuard } from "./roots/guard.mjs";
36
36
  import lockExtension from "./roots/lock-extension.mjs";
37
37
  import { statePaths } from "./roots/paths.mjs";
38
38
  import { createReporter } from "./roots/report.ts";
39
+ import { installQueuePatches } from "./roots/queue.ts";
40
+ import { agentSession, installRpcPatches } from "./roots/rpc.ts";
39
41
  import { createRootRuntime } from "./roots/runtime.ts";
40
42
  import { createPanel } from "./ui/panel.ts";
41
- import { openAgentViewer } from "./ui/viewer.ts";
43
+ import { showQueue } from "./ui/queue.ts";
44
+ import { openTaskViewer } from "./ui/viewer.ts";
42
45
 
43
46
  const DEFAULT_LIMITS: Limits = { maxConcurrent: 3, maxOutstanding: 8 };
44
47
 
@@ -94,6 +97,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
94
97
 
95
98
  installGuard({ SessionManager, AgentSession, AgentSessionRuntime, parseSessionEntries, stateDir: statePaths().locks });
96
99
  installHostPatches(AgentSession, ExtensionRunner, piCodemode.codemodeSchema);
100
+ installQueuePatches(AgentSession);
97
101
  const roots = createRootRuntime(pi, {
98
102
  receive: async (message) => {
99
103
  const delivery = message.deliverAs ?? "followUp";
@@ -108,8 +112,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
108
112
  });
109
113
  lockExtension(pi, { start: (current: ExtensionContext) => roots.start(current), stop: () => roots.stop(), runner: ExtensionRunner });
110
114
  const idle = () => node === undefined || treeIdle(node.rootId);
111
- const host = hostExtension(pi, { treeIdle: idle });
112
- createReporter(pi, { treeIdle: idle, liveWork: () => node ? liveWork(node) : 0, host });
115
+ const host = hostExtension(pi, { treeIdle: idle, rpcClients: () => roots.rpcClients() });
116
+ installRpcPatches(AgentSession, ExtensionRunner, host);
117
+ let browsing = false;
118
+ createReporter(pi, { treeIdle: idle, liveWork: () => node ? liveWork(node) : 0, browsing: () => browsing, host });
113
119
  // Event bus handlers run synchronously, so an extension that emits a query object reads the answer right after emit.
114
120
  pi.events.on("busy:query", (query) => {
115
121
  if (node && !treeIdle(node.rootId)) (query as { busy: boolean }).busy = true;
@@ -136,18 +142,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
136
142
  const agents = new Agents(pi, self, agentDir, settings, settings.extensions);
137
143
  agents.setContext(current);
138
144
  agents.restore(current);
139
- node = {
145
+ const manager = current.sessionManager;
146
+ const queue = showQueue(current, () => agentSession(manager));
147
+ const created: AgentNode = {
140
148
  ...self,
141
149
  agents,
142
150
  cwd: () => ctx?.cwd ?? current.cwd,
143
151
  busy: () => !(ctx?.isIdle() ?? true),
144
152
  sessionFile: () => ctx?.sessionManager.getSessionFile(),
145
153
  receive: (text, delivery, messageId) => {
146
- pi.sendMessage(customMessage(text, delivery, messageId), delivery === "write"
154
+ const as = created.stopped ? "write" : delivery;
155
+ pi.sendMessage(customMessage(text, as, messageId), as === "write"
147
156
  ? { deliverAs: "steer" }
148
- : { deliverAs: delivery, triggerTurn: true });
157
+ : { deliverAs: as, triggerTurn: true });
158
+ queue.update();
149
159
  },
150
160
  };
161
+ node = created;
151
162
  nodes.set(id, node);
152
163
  programs = new Programs(rootId, {
153
164
  appendEntry: (customType, data) => pi.appendEntry(customType, data),
@@ -158,6 +169,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
158
169
  panel.start(current);
159
170
  });
160
171
 
172
+ // The user's input ends what stopping a turn started (§2.2).
173
+ pi.on("input", () => {
174
+ if (node) node.stopped = false;
175
+ });
176
+
161
177
  pi.on("before_agent_start", (_event, current) => {
162
178
  const active = pi.getActiveTools();
163
179
  if (warnedBothScriptTools || !active.includes(PROGRAM_TOOL_NAME) || !active.includes(CODEMODE_TOOL_NAME)) return;
@@ -195,14 +211,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
195
211
  registerProgramTools(pi, { agentDir, settings, piCodemode, requireNode, programs: () => programs });
196
212
 
197
213
  pi.registerCommand("tasks", {
198
- description: "Hide or show the task panel",
199
- handler: async () => panel.toggle(),
200
- });
201
-
202
- pi.registerCommand("agents", {
203
- description: "Open an owned Agent to watch and talk to it",
204
- handler: async (_args, current) => {
205
- if (node) await openAgentViewer(current, requireNode(current), pi.getSettings().editorPaddingX ?? 0);
214
+ description: "Watch an Agent or Program; `/tasks panel` toggles the panel",
215
+ handler: async (args, current) => {
216
+ if (args.trim() === "panel") return panel.toggle();
217
+ if (args.trim()) {
218
+ current.ui.notify("Usage: /tasks [panel]", "error");
219
+ return;
220
+ }
221
+ if (!node) return;
222
+ browsing = true;
223
+ try {
224
+ await openTaskViewer(current, requireNode(current), programs, pi.getSettings().editorPaddingX ?? 0);
225
+ } finally {
226
+ browsing = false;
227
+ }
206
228
  },
207
229
  });
208
230
  }
@@ -27,8 +27,8 @@ export function loadCodemode(): Promise<Codemode> {
27
27
 
28
28
  /** Parts of Pi's `codemode` tool that its package entry does not export. */
29
29
  export interface PiCodemode {
30
- /** Pi's `codemode` tool definition; `program` reuses its `prepareLoadout`. */
31
- createCodemodeToolDefinition(options: Record<string, unknown>): ToolDefinition;
30
+ /** Pi's `codemode` tool definition; `program` reuses its `prepareLoadout` and, since Pi 1.2.0, `reachesTools`. */
31
+ createCodemodeToolDefinition(options: Record<string, unknown>): ToolDefinition & { reachesTools?: (input: { code: unknown }) => "all" | string[] };
32
32
  createCodemodeDescription(tools: unknown[], options?: Record<string, unknown>): string;
33
33
  /** The `codemode` parameter schema, by which the MCP extension recognizes the tool. */
34
34
  codemodeSchema: unknown;