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