@tshu-w/pi-agents 0.1.0 → 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.
- package/README.md +27 -20
- package/bin/daemon.mjs +33 -15
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +52 -38
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +10 -6
- package/src/agents/agents.ts +103 -30
- package/src/agents/registry.ts +30 -3
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +10 -5
- package/src/daemon/client.mjs +4 -1
- package/src/daemon/sessions.mjs +7 -0
- package/src/host/client.mjs +25 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +360 -0
- package/src/index.ts +36 -14
- 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/programs.ts +78 -11
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +11 -4
- package/src/programs/tool.ts +172 -16
- package/src/roots/guard.mjs +8 -2
- 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/panel.ts +39 -7
- package/src/ui/queue.ts +46 -0
- package/src/ui/viewer.ts +119 -11
- package/src/workbench.mjs +78 -31
package/src/host/extension.ts
CHANGED
|
@@ -3,7 +3,7 @@ import net from "node:net";
|
|
|
3
3
|
import { treeMetadata } from "../agents/registry.ts";
|
|
4
4
|
import { FRAME, frame, json, readFrames } from "./frames.mjs";
|
|
5
5
|
|
|
6
|
-
const IDLE_EXIT_MS =
|
|
6
|
+
const IDLE_EXIT_MS = 10 * 60_000;
|
|
7
7
|
|
|
8
8
|
// Only the host's own child runs in it; children of Pi inherit the variables but have another parent.
|
|
9
9
|
// They stay in the environment so a Pi that replaces itself with execve finds its host again.
|
|
@@ -12,20 +12,21 @@ const hostSocket = process.env.PI_AGENTS_HOST && process.env.PI_AGENTS_HOST_PID
|
|
|
12
12
|
export interface Host {
|
|
13
13
|
socket: string;
|
|
14
14
|
attached(): boolean;
|
|
15
|
-
onChange(listener: () => void): void;
|
|
15
|
+
onChange(listener: () => void): () => void;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Connects Pi to the host it runs in: reports the current Session, detaches on request,
|
|
20
|
-
* disables suspend, and exits after the tree has been idle and detached for a while.
|
|
20
|
+
* disables suspend, and exits after the tree has been idle and detached, with no RPC client, for a while.
|
|
21
21
|
* Returns the host, if Pi runs in one.
|
|
22
22
|
*/
|
|
23
|
-
export function hostExtension(pi: ExtensionAPI, { treeIdle }: { treeIdle: () => boolean }): Host | undefined {
|
|
23
|
+
export function hostExtension(pi: ExtensionAPI, { treeIdle, rpcClients }: { treeIdle: () => boolean; rpcClients: () => number }): Host | undefined {
|
|
24
24
|
if (!hostSocket) return;
|
|
25
25
|
const listeners: Array<() => void> = [];
|
|
26
26
|
|
|
27
27
|
let ctx: ExtensionContext | undefined;
|
|
28
|
-
|
|
28
|
+
// Whether a terminal was attached when Pi started, until the host reports a change.
|
|
29
|
+
let attached = process.env.PI_AGENTS_HOST_ATTACHED !== "0";
|
|
29
30
|
let idleSince: number | undefined;
|
|
30
31
|
let socket: net.Socket | undefined;
|
|
31
32
|
const connect = (path: string): net.Socket => {
|
|
@@ -43,32 +44,55 @@ export function hostExtension(pi: ExtensionAPI, { treeIdle }: { treeIdle: () =>
|
|
|
43
44
|
const report = () => {
|
|
44
45
|
if (!ctx) return;
|
|
45
46
|
const file = ctx.sessionManager.getSessionFile();
|
|
46
|
-
send({ type: "session", session: { id: ctx.sessionManager.getSessionId(), name: pi.getSessionName(), file } });
|
|
47
|
+
send({ type: "session", session: { id: ctx.sessionManager.getSessionId(), name: pi.getSessionName(), file, cwd: ctx.cwd } });
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
// Pi suspends by stopping its process group; a host has no shell to resume it, so resume at once.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!ctx || attached || !ctx.isIdle() || ctx.hasPendingMessages() || !treeIdle()) {
|
|
51
|
+
const resume = () => process.kill(process.pid, "SIGCONT");
|
|
52
|
+
const checkIdle = () => {
|
|
53
|
+
if (!ctx || attached || !ctx.isIdle() || ctx.hasPendingMessages() || !treeIdle() || rpcClients() > 0) {
|
|
54
54
|
idleSince = undefined;
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
57
|
idleSince ??= Date.now();
|
|
58
58
|
if (Date.now() - idleSince >= IDLE_EXIT_MS) ctx.shutdown();
|
|
59
|
-
}
|
|
60
|
-
timer
|
|
59
|
+
};
|
|
60
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
61
61
|
|
|
62
62
|
pi.on("session_start", (_event, current) => {
|
|
63
63
|
// Owned Agents run in the same process; only the root speaks for the host.
|
|
64
64
|
if (treeMetadata(current)) return;
|
|
65
65
|
ctx = current;
|
|
66
66
|
socket ??= connect(hostSocket);
|
|
67
|
+
if (!timer) {
|
|
68
|
+
process.on("SIGTSTP", resume);
|
|
69
|
+
timer = setInterval(checkIdle, 1000);
|
|
70
|
+
timer.unref();
|
|
71
|
+
}
|
|
67
72
|
report();
|
|
68
73
|
});
|
|
69
74
|
pi.on("session_info_changed", () => report());
|
|
70
|
-
|
|
75
|
+
// A reload or another Session loads this extension again, which takes over.
|
|
76
|
+
pi.on("session_shutdown", () => {
|
|
77
|
+
ctx = undefined;
|
|
78
|
+
idleSince = undefined;
|
|
79
|
+
clearInterval(timer);
|
|
80
|
+
timer = undefined;
|
|
81
|
+
process.off("SIGTSTP", resume);
|
|
82
|
+
socket?.destroy();
|
|
83
|
+
socket = undefined;
|
|
84
|
+
});
|
|
71
85
|
|
|
72
86
|
pi.registerCommand("detach", { description: "Detach this terminal; Pi keeps running", handler: async () => send({ type: "detach" }) });
|
|
73
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
socket: hostSocket,
|
|
89
|
+
attached: () => attached,
|
|
90
|
+
onChange: (listener) => {
|
|
91
|
+
listeners.push(listener);
|
|
92
|
+
return () => {
|
|
93
|
+
const at = listeners.indexOf(listener);
|
|
94
|
+
if (at >= 0) listeners.splice(at, 1);
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
74
98
|
}
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
const as = created.stopped ? "write" : delivery;
|
|
155
|
+
pi.sendMessage(customMessage(text, as, messageId), as === "write"
|
|
147
156
|
? { deliverAs: "steer" }
|
|
148
|
-
: { deliverAs:
|
|
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: "
|
|
199
|
-
handler: async () =>
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (node)
|
|
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
|
}
|
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;
|