@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.
- package/LICENSE +661 -0
- package/README.md +81 -2
- package/bin/daemon.mjs +204 -0
- package/bin/host.mjs +170 -0
- package/bin/pd.mjs +139 -0
- package/docs/PROGRAM.md +310 -0
- package/docs/SPEC.md +427 -0
- package/docs/WORKBENCH.md +165 -0
- package/package.json +62 -4
- package/src/agents/agents.ts +882 -0
- package/src/agents/registry.ts +257 -0
- package/src/agents/scheduler.ts +204 -0
- package/src/agents/search.ts +131 -0
- package/src/agents/tool.ts +205 -0
- package/src/daemon/client.mjs +84 -0
- package/src/daemon/router.mjs +103 -0
- package/src/daemon/sessions.mjs +129 -0
- package/src/daemon/version.mjs +19 -0
- package/src/host/client.mjs +139 -0
- package/src/host/extension.ts +74 -0
- package/src/host/frames.mjs +34 -0
- package/src/host/keys.mjs +19 -0
- package/src/index.ts +208 -0
- package/src/output.ts +56 -0
- package/src/params.ts +4 -0
- package/src/pi.mjs +16 -0
- package/src/programs/codemode.ts +74 -0
- package/src/programs/execute.ts +131 -0
- package/src/programs/host-patches.ts +128 -0
- package/src/programs/loadout.ts +99 -0
- package/src/programs/programs.ts +284 -0
- package/src/programs/sandbox.ts +90 -0
- package/src/programs/scope.ts +94 -0
- package/src/programs/tool.ts +198 -0
- package/src/render-call.ts +90 -0
- package/src/roots/discovery.mjs +113 -0
- package/src/roots/guard.mjs +227 -0
- package/src/roots/lock-extension.mjs +167 -0
- package/src/roots/locks.mjs +167 -0
- package/src/roots/paths.mjs +66 -0
- package/src/roots/report.ts +156 -0
- package/src/roots/runtime.ts +174 -0
- package/src/roots/snapshot.mjs +46 -0
- package/src/roots/socket-recovery.mjs +21 -0
- package/src/roots/transport.mjs +341 -0
- package/src/ui/panel.ts +96 -0
- package/src/ui/viewer.ts +445 -0
- package/src/workbench.mjs +587 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open, readdir, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { FRAME, frame, json, readFrames } from './frames.mjs';
|
|
9
|
+
import { prepareDirectory } from '../roots/paths.mjs';
|
|
10
|
+
|
|
11
|
+
const HOST = fileURLToPath(new URL('../../bin/host.mjs', import.meta.url));
|
|
12
|
+
const START_TIMEOUT_MS = 10000;
|
|
13
|
+
// Undo the terminal modes, progress, and program status Pi may have set, so the shell gets a plain terminal back.
|
|
14
|
+
const RESET = '\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2031l\x1b[?25h\x1b[0m\x1b]7501;state=clear\x1b\\\x1b]9;4;0\x07';
|
|
15
|
+
const ALT_SCREEN = /\x1b\[\?1049([hl])/g;
|
|
16
|
+
// Splits terminal input into key sequences: CSI and SS3 sequences, Alt+key, or a single character.
|
|
17
|
+
const KEYS = /\x1b\[[0-?]*[ -/]*[@-~]|\x1bO[\s\S]|\x1b?[\s\S]/g;
|
|
18
|
+
|
|
19
|
+
function hostStatus(path) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const socket = net.connect(path);
|
|
22
|
+
socket.setTimeout(1000, () => socket.destroy(Object.assign(new Error('Host status timed out'), { code: 'ETIMEDOUT' })));
|
|
23
|
+
socket.on('error', reject);
|
|
24
|
+
socket.on('close', () => reject(Object.assign(new Error('Host closed without a status'), { code: 'ECONNRESET' })));
|
|
25
|
+
socket.on('connect', () => socket.write(frame(FRAME.status)));
|
|
26
|
+
readFrames(socket, (type, body) => { if (type === FRAME.status) { resolve(json(body)); socket.destroy(); } });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function alive(pid) {
|
|
31
|
+
try { process.kill(pid, 0); return true; }
|
|
32
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Status of every running host; removes sockets left by hosts that died. */
|
|
36
|
+
export async function listHosts(paths) {
|
|
37
|
+
let names;
|
|
38
|
+
try { names = await readdir(paths.runtime); }
|
|
39
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
40
|
+
const hosts = [];
|
|
41
|
+
await Promise.all(names.map(async name => {
|
|
42
|
+
const match = /^h-(\d+)\.sock$/.exec(name);
|
|
43
|
+
if (!match) return;
|
|
44
|
+
const path = join(paths.runtime, name);
|
|
45
|
+
try { hosts.push({ ...await hostStatus(path), socket: path }); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (['ECONNREFUSED', 'ENOENT'].includes(error.code) && !alive(Number(match[1]))) await unlink(path).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
}));
|
|
50
|
+
return hosts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Starts a detached host running `pi args` in `cwd` and resolves to its socket once it accepts connections.
|
|
55
|
+
* With `waitForTerminal`, Pi starts when the first terminal attaches.
|
|
56
|
+
*/
|
|
57
|
+
export async function startHost(paths, { pi, args, cwd, cols = 120, rows = 40, env = process.env, waitForTerminal = false }) {
|
|
58
|
+
// spawn reports a missing cwd as ENOENT, which callers would mistake for a missing socket.
|
|
59
|
+
if (!(await stat(cwd).catch(() => undefined))?.isDirectory()) {
|
|
60
|
+
throw Object.assign(new Error(`Working directory ${cwd} does not exist`), { code: 'INVALID_CWD' });
|
|
61
|
+
}
|
|
62
|
+
await prepareDirectory(paths.runtime);
|
|
63
|
+
await prepareDirectory(paths.state);
|
|
64
|
+
const logPath = paths.hostLog;
|
|
65
|
+
const log = await open(logPath, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
66
|
+
let child, failure;
|
|
67
|
+
try {
|
|
68
|
+
child = spawn(process.execPath, [HOST], {
|
|
69
|
+
cwd, detached: true, stdio: ['ignore', log.fd, log.fd],
|
|
70
|
+
env: { ...env, PI_AGENTS_HOST_CONFIG: JSON.stringify({ pi, args, cwd, cols, rows, waitForTerminal }) },
|
|
71
|
+
});
|
|
72
|
+
// A spawn error, such as a missing cwd, is emitted before the log closes.
|
|
73
|
+
child.on('error', error => { failure = error; });
|
|
74
|
+
child.once('exit', code => { failure ??= new Error(`Host exited (${code}). See ${logPath}`); });
|
|
75
|
+
} finally { await log.close(); }
|
|
76
|
+
child.unref();
|
|
77
|
+
const socket = paths.host(child.pid);
|
|
78
|
+
const deadline = performance.now() + START_TIMEOUT_MS;
|
|
79
|
+
for (;;) {
|
|
80
|
+
if (failure) throw failure;
|
|
81
|
+
try { await hostStatus(socket); return socket; }
|
|
82
|
+
catch (error) { if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error; }
|
|
83
|
+
if (performance.now() > deadline) throw new Error(`Host did not start. See ${logPath}`);
|
|
84
|
+
await delay(25);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Attaches this process's terminal to a host until the host detaches it or Pi exits.
|
|
90
|
+
* The detach key detaches here, before Pi sees it, so it works whatever Pi shows.
|
|
91
|
+
* Resolves to { reason: 'detach' | 'takeover' | 'exited' | 'closed', session }.
|
|
92
|
+
*/
|
|
93
|
+
export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = process.stdout }) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const socket = net.connect(socketPath);
|
|
96
|
+
const size = () => ({ cols: stdout.columns, rows: stdout.rows });
|
|
97
|
+
const onResize = () => socket.write(frame(FRAME.resize, size()));
|
|
98
|
+
const onInput = data => {
|
|
99
|
+
// The workbench's stdin is decoded as UTF-8; latin1 keeps a Buffer's bytes.
|
|
100
|
+
const encoding = typeof data === 'string' ? 'utf8' : 'latin1';
|
|
101
|
+
const keys = data.toString(encoding).match(KEYS) ?? [];
|
|
102
|
+
const at = keys.findIndex(isDetach);
|
|
103
|
+
if (at < 0) return socket.write(frame(FRAME.data, data));
|
|
104
|
+
if (at > 0) socket.write(frame(FRAME.data, Buffer.from(keys.slice(0, at).join(''), encoding)));
|
|
105
|
+
socket.write(frame(FRAME.detached));
|
|
106
|
+
};
|
|
107
|
+
let result, connected = false, altScreen = false;
|
|
108
|
+
function finish() {
|
|
109
|
+
stdout.off('resize', onResize);
|
|
110
|
+
stdin.off('data', onInput);
|
|
111
|
+
if (connected) {
|
|
112
|
+
stdin.setRawMode(false);
|
|
113
|
+
stdin.pause();
|
|
114
|
+
// An exiting Pi restores the terminal itself.
|
|
115
|
+
// Leaving the alternate screen returns the cursor to the line below the command; otherwise it ends a line.
|
|
116
|
+
if (result?.reason !== 'exited') stdout.write(altScreen ? `\x1b[?1049l${RESET}` : `${RESET}\r\n`);
|
|
117
|
+
}
|
|
118
|
+
resolve(result ?? { reason: 'closed' });
|
|
119
|
+
}
|
|
120
|
+
socket.on('connect', () => {
|
|
121
|
+
connected = true;
|
|
122
|
+
stdin.setRawMode(true);
|
|
123
|
+
stdin.resume();
|
|
124
|
+
stdin.on('data', onInput);
|
|
125
|
+
stdout.on('resize', onResize);
|
|
126
|
+
socket.write(frame(FRAME.attach, size()));
|
|
127
|
+
});
|
|
128
|
+
readFrames(socket, (type, body) => {
|
|
129
|
+
if (type === FRAME.data) {
|
|
130
|
+
for (const [, mode] of body.toString('latin1').matchAll(ALT_SCREEN)) altScreen = mode === 'h';
|
|
131
|
+
stdout.write(body);
|
|
132
|
+
}
|
|
133
|
+
else if (type === FRAME.detached) result = { ...json(body) };
|
|
134
|
+
else if (type === FRAME.exited) result = { reason: 'exited', ...json(body) };
|
|
135
|
+
});
|
|
136
|
+
socket.on('error', error => { if (!connected) reject(error); });
|
|
137
|
+
socket.on('close', finish);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { treeMetadata } from "../agents/registry.ts";
|
|
4
|
+
import { FRAME, frame, json, readFrames } from "./frames.mjs";
|
|
5
|
+
|
|
6
|
+
const IDLE_EXIT_MS = 5 * 60_000;
|
|
7
|
+
|
|
8
|
+
// Only the host's own child runs in it; children of Pi inherit the variables but have another parent.
|
|
9
|
+
// They stay in the environment so a Pi that replaces itself with execve finds its host again.
|
|
10
|
+
const hostSocket = process.env.PI_AGENTS_HOST && process.env.PI_AGENTS_HOST_PID === String(process.ppid) ? process.env.PI_AGENTS_HOST : undefined;
|
|
11
|
+
|
|
12
|
+
export interface Host {
|
|
13
|
+
socket: string;
|
|
14
|
+
attached(): boolean;
|
|
15
|
+
onChange(listener: () => void): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
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.
|
|
21
|
+
* Returns the host, if Pi runs in one.
|
|
22
|
+
*/
|
|
23
|
+
export function hostExtension(pi: ExtensionAPI, { treeIdle }: { treeIdle: () => boolean }): Host | undefined {
|
|
24
|
+
if (!hostSocket) return;
|
|
25
|
+
const listeners: Array<() => void> = [];
|
|
26
|
+
|
|
27
|
+
let ctx: ExtensionContext | undefined;
|
|
28
|
+
let attached = true;
|
|
29
|
+
let idleSince: number | undefined;
|
|
30
|
+
let socket: net.Socket | undefined;
|
|
31
|
+
const connect = (path: string): net.Socket => {
|
|
32
|
+
const connection = net.connect(path);
|
|
33
|
+
connection.on("error", () => {});
|
|
34
|
+
connection.unref();
|
|
35
|
+
readFrames(connection, (type: number, body: Buffer) => {
|
|
36
|
+
if (type !== FRAME.control || json(body).type !== "attached" || json(body).attached === attached) return;
|
|
37
|
+
attached = json(body).attached;
|
|
38
|
+
for (const listener of listeners) listener();
|
|
39
|
+
});
|
|
40
|
+
return connection;
|
|
41
|
+
};
|
|
42
|
+
const send = (message: object): void => { socket?.write(frame(FRAME.control, message as never)); };
|
|
43
|
+
const report = () => {
|
|
44
|
+
if (!ctx) return;
|
|
45
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
46
|
+
send({ type: "session", session: { id: ctx.sessionManager.getSessionId(), name: pi.getSessionName(), file } });
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Pi suspends by stopping its process group; a host has no shell to resume it, so resume at once.
|
|
50
|
+
process.on("SIGTSTP", () => process.kill(process.pid, "SIGCONT"));
|
|
51
|
+
|
|
52
|
+
const timer = setInterval(() => {
|
|
53
|
+
if (!ctx || attached || !ctx.isIdle() || ctx.hasPendingMessages() || !treeIdle()) {
|
|
54
|
+
idleSince = undefined;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
idleSince ??= Date.now();
|
|
58
|
+
if (Date.now() - idleSince >= IDLE_EXIT_MS) ctx.shutdown();
|
|
59
|
+
}, 1000);
|
|
60
|
+
timer.unref();
|
|
61
|
+
|
|
62
|
+
pi.on("session_start", (_event, current) => {
|
|
63
|
+
// Owned Agents run in the same process; only the root speaks for the host.
|
|
64
|
+
if (treeMetadata(current)) return;
|
|
65
|
+
ctx = current;
|
|
66
|
+
socket ??= connect(hostSocket);
|
|
67
|
+
report();
|
|
68
|
+
});
|
|
69
|
+
pi.on("session_info_changed", () => report());
|
|
70
|
+
pi.on("session_shutdown", () => { ctx = undefined; });
|
|
71
|
+
|
|
72
|
+
pi.registerCommand("detach", { description: "Detach this terminal; Pi keeps running", handler: async () => send({ type: "detach" }) });
|
|
73
|
+
return { socket: hostSocket, attached: () => attached, onChange: (listener) => { listeners.push(listener); } };
|
|
74
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Frames on a host socket: one type byte, a 32-bit big-endian length, then the payload.
|
|
2
|
+
export const FRAME = {
|
|
3
|
+
data: 0, // terminal input to the host, Pi output to the terminal
|
|
4
|
+
attach: 1, // { cols, rows }
|
|
5
|
+
resize: 2, // { cols, rows }
|
|
6
|
+
status: 3, // request: empty; reply: host status
|
|
7
|
+
detached: 4, // host to terminal: { reason: 'detach' | 'takeover', session }; terminal to host: empty, to detach
|
|
8
|
+
exited: 5, // { session }
|
|
9
|
+
control: 6, // Pi to host: { type: 'session', session } | { type: 'detach' }; host to Pi: { type: 'attached', attached }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function frame(type, payload = '') {
|
|
13
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(typeof payload === 'string' ? payload : JSON.stringify(payload));
|
|
14
|
+
const header = Buffer.alloc(5);
|
|
15
|
+
header[0] = type;
|
|
16
|
+
header.writeUInt32BE(body.length, 1);
|
|
17
|
+
return Buffer.concat([header, body]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readFrames(socket, onFrame) {
|
|
21
|
+
let buffer = Buffer.alloc(0);
|
|
22
|
+
socket.on('data', chunk => {
|
|
23
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
24
|
+
while (buffer.length >= 5) {
|
|
25
|
+
const length = buffer.readUInt32BE(1);
|
|
26
|
+
if (buffer.length < 5 + length) break;
|
|
27
|
+
const type = buffer[0], body = buffer.subarray(5, 5 + length);
|
|
28
|
+
buffer = buffer.subarray(5 + length);
|
|
29
|
+
onFrame(type, body);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const json = body => JSON.parse(body.toString('utf8'));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_DETACH_KEY = 'ctrl+z';
|
|
5
|
+
|
|
6
|
+
/** The `pi-agents.detach` keybinding in `keybindings.json`, or its default. */
|
|
7
|
+
export function detachKey(agentDir) {
|
|
8
|
+
try {
|
|
9
|
+
const value = JSON.parse(readFileSync(join(agentDir, 'keybindings.json'), 'utf8'))['pi-agents.detach'];
|
|
10
|
+
if (typeof value === 'string') return value;
|
|
11
|
+
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
|
12
|
+
} catch { /* missing keybindings use the default */ }
|
|
13
|
+
return DEFAULT_DETACH_KEY;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Whether a key sequence presses the detach key; `tui` is Pi's pi-tui, which parses keys. */
|
|
17
|
+
export function detachMatcher(tui, key) {
|
|
18
|
+
return sequence => !tui.isKeyRelease(sequence) && tui.matchesKey(sequence, key);
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AgentSession,
|
|
3
|
+
AgentSessionRuntime,
|
|
4
|
+
ExtensionRunner,
|
|
5
|
+
getAgentDir,
|
|
6
|
+
parseSessionEntries,
|
|
7
|
+
SessionManager,
|
|
8
|
+
VERSION,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { Agents, type Limits } from "./agents/agents.ts";
|
|
15
|
+
import {
|
|
16
|
+
customMessage,
|
|
17
|
+
liveWork,
|
|
18
|
+
messageText,
|
|
19
|
+
rememberRoots,
|
|
20
|
+
visibleIds,
|
|
21
|
+
nodes,
|
|
22
|
+
receivedMessageIds,
|
|
23
|
+
treeIdle,
|
|
24
|
+
treeMetadata,
|
|
25
|
+
type AgentNode,
|
|
26
|
+
} from "./agents/registry.ts";
|
|
27
|
+
import { registerAgentTool } from "./agents/tool.ts";
|
|
28
|
+
import { hostExtension } from "./host/extension.ts";
|
|
29
|
+
import { CODEMODE_TOOL_NAME, loadPiCodemode } from "./programs/codemode.ts";
|
|
30
|
+
import { PROGRAM_TOOL_NAME } from "./programs/execute.ts";
|
|
31
|
+
import { installHostPatches } from "./programs/host-patches.ts";
|
|
32
|
+
import { applyProgramOnlyFlag, PROGRAM_ONLY_FLAG } from "./programs/loadout.ts";
|
|
33
|
+
import { addProgramFiles, Programs } from "./programs/programs.ts";
|
|
34
|
+
import { registerProgramTools } from "./programs/tool.ts";
|
|
35
|
+
import { installGuard } from "./roots/guard.mjs";
|
|
36
|
+
import lockExtension from "./roots/lock-extension.mjs";
|
|
37
|
+
import { statePaths } from "./roots/paths.mjs";
|
|
38
|
+
import { createReporter } from "./roots/report.ts";
|
|
39
|
+
import { createRootRuntime } from "./roots/runtime.ts";
|
|
40
|
+
import { createPanel } from "./ui/panel.ts";
|
|
41
|
+
import { openAgentViewer } from "./ui/viewer.ts";
|
|
42
|
+
|
|
43
|
+
const DEFAULT_LIMITS: Limits = { maxConcurrent: 3, maxOutstanding: 8 };
|
|
44
|
+
|
|
45
|
+
interface Settings extends Limits {
|
|
46
|
+
extensions: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reads the "pi-agents" section of the global settings.json. */
|
|
50
|
+
export function readSettings(agentDir: string): Settings {
|
|
51
|
+
let section: Record<string, unknown> = {};
|
|
52
|
+
try {
|
|
53
|
+
const document = JSON.parse(readFileSync(path.join(agentDir, "settings.json"), "utf8")) as Record<string, unknown>;
|
|
54
|
+
const value = document["pi-agents"];
|
|
55
|
+
if (value && typeof value === "object") section = value as Record<string, unknown>;
|
|
56
|
+
} catch { /* missing settings use the defaults */ }
|
|
57
|
+
const settings: Settings = { ...DEFAULT_LIMITS, extensions: [] };
|
|
58
|
+
const { maxConcurrent = DEFAULT_LIMITS.maxConcurrent, maxOutstanding = DEFAULT_LIMITS.maxOutstanding, extensions = [] } = section;
|
|
59
|
+
const positive = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
60
|
+
if (positive(maxConcurrent) && positive(maxOutstanding) && maxConcurrent <= maxOutstanding) {
|
|
61
|
+
settings.maxConcurrent = maxConcurrent;
|
|
62
|
+
settings.maxOutstanding = maxOutstanding;
|
|
63
|
+
} else {
|
|
64
|
+
console.warn(`[pi-agents] invalid maxConcurrent/maxOutstanding ${JSON.stringify({ maxConcurrent, maxOutstanding })}; using ${DEFAULT_LIMITS.maxConcurrent}/${DEFAULT_LIMITS.maxOutstanding}`);
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(extensions) && extensions.every((entry) => typeof entry === "string")) {
|
|
67
|
+
settings.extensions = [...new Set(extensions.map((entry) => path.resolve(agentDir, entry)))];
|
|
68
|
+
} else {
|
|
69
|
+
console.warn("[pi-agents] invalid extensions; expected an array of paths");
|
|
70
|
+
}
|
|
71
|
+
return settings;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const MIN_PI_VERSION = "1.0.0";
|
|
75
|
+
/** How long a root process may take to clean up on exit before it is killed. */
|
|
76
|
+
const EXIT_GRACE_MS = 30_000;
|
|
77
|
+
|
|
78
|
+
function olderThan(version: string, minimum: string): boolean {
|
|
79
|
+
const a = version.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
80
|
+
const b = minimum.split(".").map(Number);
|
|
81
|
+
for (let i = 0; i < b.length; i++) if (a[i] !== b[i]) return (a[i] ?? 0) < b[i];
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
86
|
+
if (olderThan(VERSION, MIN_PI_VERSION)) throw new Error(`pi-agents requires Pi ${MIN_PI_VERSION} or later, found ${VERSION}`);
|
|
87
|
+
const agentDir = getAgentDir();
|
|
88
|
+
const settings = readSettings(agentDir);
|
|
89
|
+
const piCodemode = await loadPiCodemode();
|
|
90
|
+
let node: AgentNode | undefined;
|
|
91
|
+
let programs: Programs | undefined;
|
|
92
|
+
let ctx: ExtensionContext | undefined;
|
|
93
|
+
const panel = createPanel(() => node);
|
|
94
|
+
|
|
95
|
+
installGuard({ SessionManager, AgentSession, AgentSessionRuntime, parseSessionEntries, stateDir: statePaths().locks });
|
|
96
|
+
installHostPatches(AgentSession, ExtensionRunner, piCodemode.codemodeSchema);
|
|
97
|
+
const roots = createRootRuntime(pi, {
|
|
98
|
+
receive: async (message) => {
|
|
99
|
+
const delivery = message.deliverAs ?? "followUp";
|
|
100
|
+
// The user's message from the workbench arrives as if typed in Pi's editor.
|
|
101
|
+
if (message.user) return pi.sendUserMessage(message.body, { deliverAs: delivery === "steer" ? "steer" : "followUp" });
|
|
102
|
+
// The header names the sender by a short ID unique among the roots this root sees.
|
|
103
|
+
rememberRoots((await roots.roots()).map((root) => root.id));
|
|
104
|
+
if (node) node.receive(messageText(message.sender!, false, delivery, message.body, visibleIds(node.scopeId, true)), delivery, message.id);
|
|
105
|
+
},
|
|
106
|
+
treeIdle: () => node === undefined || treeIdle(node.rootId),
|
|
107
|
+
receivedIds: receivedMessageIds,
|
|
108
|
+
});
|
|
109
|
+
lockExtension(pi, { start: (current: ExtensionContext) => roots.start(current), stop: () => roots.stop(), runner: ExtensionRunner });
|
|
110
|
+
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 });
|
|
113
|
+
// Event bus handlers run synchronously, so an extension that emits a query object reads the answer right after emit.
|
|
114
|
+
pi.events.on("busy:query", (query) => {
|
|
115
|
+
if (node && !treeIdle(node.rootId)) (query as { busy: boolean }).busy = true;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const requireNode = (current: ExtensionContext): AgentNode => {
|
|
119
|
+
if (!node) throw new Error("pi-agents is not initialized");
|
|
120
|
+
ctx = current;
|
|
121
|
+
node.agents.setContext(current);
|
|
122
|
+
return node;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
pi.registerFlag(PROGRAM_ONLY_FLAG, { type: "boolean", description: "Present tools as with codemode.mode \"only\": scripts reach them through program" });
|
|
126
|
+
|
|
127
|
+
let warnedBothScriptTools = false;
|
|
128
|
+
pi.on("session_start", (_event, current) => {
|
|
129
|
+
applyProgramOnlyFlag(pi);
|
|
130
|
+
warnedBothScriptTools = false;
|
|
131
|
+
ctx = current;
|
|
132
|
+
const id = current.sessionManager.getSessionId();
|
|
133
|
+
const metadata = treeMetadata(current);
|
|
134
|
+
const rootId = metadata?.rootId ?? id;
|
|
135
|
+
const self = { id, rootId, scopeId: metadata?.scopeId ?? rootId, ownerId: metadata?.ownerId, name: () => pi.getSessionName() };
|
|
136
|
+
const agents = new Agents(pi, self, agentDir, settings, settings.extensions);
|
|
137
|
+
agents.setContext(current);
|
|
138
|
+
agents.restore(current);
|
|
139
|
+
node = {
|
|
140
|
+
...self,
|
|
141
|
+
agents,
|
|
142
|
+
cwd: () => ctx?.cwd ?? current.cwd,
|
|
143
|
+
busy: () => !(ctx?.isIdle() ?? true),
|
|
144
|
+
sessionFile: () => ctx?.sessionManager.getSessionFile(),
|
|
145
|
+
receive: (text, delivery, messageId) => {
|
|
146
|
+
pi.sendMessage(customMessage(text, delivery, messageId), delivery === "write"
|
|
147
|
+
? { deliverAs: "steer" }
|
|
148
|
+
: { deliverAs: delivery, triggerTurn: true });
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
nodes.set(id, node);
|
|
152
|
+
programs = new Programs(rootId, {
|
|
153
|
+
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
154
|
+
notify: (text) => agents.notify(text),
|
|
155
|
+
changed: () => panel.update(),
|
|
156
|
+
});
|
|
157
|
+
programs.restore(current);
|
|
158
|
+
panel.start(current);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pi.on("before_agent_start", (_event, current) => {
|
|
162
|
+
const active = pi.getActiveTools();
|
|
163
|
+
if (warnedBothScriptTools || !active.includes(PROGRAM_TOOL_NAME) || !active.includes(CODEMODE_TOOL_NAME)) return;
|
|
164
|
+
warnedBothScriptTools = true;
|
|
165
|
+
current.ui.notify("Both `program` and `codemode` are active and do a similar job; disable `codemode` to avoid declaring the script API twice.", "warning");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
pi.on("session_before_compact", (event) => {
|
|
169
|
+
addProgramFiles(event.branchEntries as never, event.preparation.firstKeptEntryId, event.preparation.fileOps);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
pi.on("session_shutdown", async (event) => {
|
|
173
|
+
const closing = node;
|
|
174
|
+
if (!closing) return;
|
|
175
|
+
if (event.reason === "quit" && closing.ownerId === undefined) {
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
console.error(`[pi-agents] Cleanup did not finish within ${EXIT_GRACE_MS / 1000}s; killing the process.`);
|
|
178
|
+
process.kill(process.pid, "SIGKILL");
|
|
179
|
+
}, EXIT_GRACE_MS).unref();
|
|
180
|
+
}
|
|
181
|
+
panel.stop();
|
|
182
|
+
node = undefined;
|
|
183
|
+
const stopping = programs;
|
|
184
|
+
programs = undefined;
|
|
185
|
+
try {
|
|
186
|
+
const results = await Promise.allSettled([stopping?.shutdown(), closing.agents.shutdown()]);
|
|
187
|
+
const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
188
|
+
if (errors.length > 0) throw new Error(errors.map((error) => error instanceof Error ? error.message : String(error)).join("\n"));
|
|
189
|
+
} finally {
|
|
190
|
+
if (nodes.get(closing.id) === closing) nodes.delete(closing.id);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
registerAgentTool(pi, { limits: settings, roots, requireNode });
|
|
195
|
+
registerProgramTools(pi, { agentDir, settings, piCodemode, requireNode, programs: () => programs });
|
|
196
|
+
|
|
197
|
+
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);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MAX_BYTES,
|
|
6
|
+
DEFAULT_MAX_LINES,
|
|
7
|
+
formatSize,
|
|
8
|
+
truncateHead,
|
|
9
|
+
type TruncationResult,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export interface Truncation {
|
|
13
|
+
truncation: TruncationResult;
|
|
14
|
+
fullOutputPath: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BoundedText {
|
|
18
|
+
text: string;
|
|
19
|
+
/** The kept prefix of the text, without the notice. */
|
|
20
|
+
kept: string;
|
|
21
|
+
details?: Truncation;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Apply Pi's output limits, saving the full text to a file named in the notice. */
|
|
25
|
+
export function boundText(text: string, prefix: string): BoundedText {
|
|
26
|
+
const truncation = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
27
|
+
if (!truncation.truncated) return { text, kept: text };
|
|
28
|
+
const fullOutputPath = join(mkdtempSync(join(tmpdir(), `${prefix}-`)), "output.txt");
|
|
29
|
+
writeFileSync(fullOutputPath, text, "utf8");
|
|
30
|
+
const summary = truncation.firstLineExceedsLimit
|
|
31
|
+
? `Line 1 is ${formatSize(Buffer.byteLength(text.split("\n")[0]!, "utf8"))}, exceeds ${formatSize(truncation.maxBytes)} limit.`
|
|
32
|
+
: `Showing lines 1-${truncation.outputLines} of ${truncation.totalLines}${truncation.truncatedBy === "bytes" ? ` (${formatSize(truncation.maxBytes)} limit)` : ""}.`;
|
|
33
|
+
return {
|
|
34
|
+
text: `${truncation.content}\n\n[${summary} Full output: ${fullOutputPath}]`,
|
|
35
|
+
kept: truncation.content,
|
|
36
|
+
details: { truncation, fullOutputPath },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bounds result blocks joined by blank lines. The block that crosses the limit keeps its closing
|
|
42
|
+
* tag; `shown` counts the blocks that appear, and the caller names the omitted ones.
|
|
43
|
+
*/
|
|
44
|
+
export function boundBlocks(blocks: string[], tag: string, prefix: string): BoundedText & { shown: number } {
|
|
45
|
+
const bounded = boundText(blocks.join("\n\n"), prefix);
|
|
46
|
+
if (!bounded.details) return { ...bounded, shown: blocks.length };
|
|
47
|
+
const kept = bounded.kept;
|
|
48
|
+
let start = 0;
|
|
49
|
+
const shown = blocks.filter((block) => {
|
|
50
|
+
const visible = start < kept.length;
|
|
51
|
+
start += block.length + 2;
|
|
52
|
+
return visible;
|
|
53
|
+
}).length;
|
|
54
|
+
const open = kept.lastIndexOf(`<${tag} `) > kept.lastIndexOf(`</${tag}>`);
|
|
55
|
+
return { ...bounded, text: `${kept}${open ? `\n</${tag}>` : ""}${bounded.text.slice(kept.length)}`, shown };
|
|
56
|
+
}
|
package/src/params.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export function rejectFields(params: Record<string, unknown>, action: string, fields: string[]): void {
|
|
2
|
+
const present = fields.filter((field) => params[field] !== undefined);
|
|
3
|
+
if (present.length > 0) throw new Error(`${action} does not accept: ${present.join(", ")}`);
|
|
4
|
+
}
|
package/src/pi.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// pd and the workbench run outside Pi, so they load Pi's modules from the package of the `pi` they run.
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const PACKAGE = '@earendil-works/pi-coding-agent';
|
|
6
|
+
|
|
7
|
+
/** Pi's package directory, found from its executable: an npm bin link or a Homebrew wrapper. */
|
|
8
|
+
export function piPackage(executable) {
|
|
9
|
+
for (let dir = dirname(realpathSync(executable)); ; dir = dirname(dir)) {
|
|
10
|
+
for (const candidate of [dir, join(dir, 'lib/node_modules', PACKAGE), join(dir, 'libexec/lib/node_modules', PACKAGE)]) {
|
|
11
|
+
try { if (JSON.parse(readFileSync(join(candidate, 'package.json'), 'utf8')).name === PACKAGE) return candidate; }
|
|
12
|
+
catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
13
|
+
}
|
|
14
|
+
if (dirname(dir) === dir) throw new Error(`Cannot find ${PACKAGE} from ${executable}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { getKeybindings } from "@earendil-works/pi-tui";
|
|
6
|
+
import { getPackageDir, type AgentToolResult, type AgentToolUpdateCallback, type ExtensionToolContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
type Codemode = typeof import("@earendil-works/pi-codemode");
|
|
9
|
+
|
|
10
|
+
export const CODEMODE_TOOL_NAME = "codemode";
|
|
11
|
+
|
|
12
|
+
let loaded: Promise<Codemode> | undefined;
|
|
13
|
+
|
|
14
|
+
/** Imports a package from Pi's own dependencies rather than the copy Pi aliases for extensions. */
|
|
15
|
+
function importPiDependency(name: string) {
|
|
16
|
+
const dirs = createRequire(join(getPackageDir(), "package.json")).resolve.paths(name) ?? [];
|
|
17
|
+
const entry = dirs.map((dir) => join(dir, name, "dist/index.js")).find(existsSync);
|
|
18
|
+
if (!entry) throw new Error(`pi-agents requires Pi with ${name}`);
|
|
19
|
+
return import(pathToFileURL(entry).href);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Pi ships `@earendil-works/pi-codemode` but does not alias it for extensions, so load Pi's copy. */
|
|
23
|
+
export function loadCodemode(): Promise<Codemode> {
|
|
24
|
+
loaded ??= importPiDependency("@earendil-works/pi-codemode");
|
|
25
|
+
return loaded;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parts of Pi's `codemode` tool that its package entry does not export. */
|
|
29
|
+
export interface PiCodemode {
|
|
30
|
+
/** Pi's `codemode` tool definition; `program` reuses its `prepareLoadout`. */
|
|
31
|
+
createCodemodeToolDefinition(options: Record<string, unknown>): ToolDefinition;
|
|
32
|
+
createCodemodeDescription(tools: unknown[], options?: Record<string, unknown>): string;
|
|
33
|
+
/** The `codemode` parameter schema, by which the MCP extension recognizes the tool. */
|
|
34
|
+
codemodeSchema: unknown;
|
|
35
|
+
executeCodemode(
|
|
36
|
+
toolCallId: string,
|
|
37
|
+
input: { code: string },
|
|
38
|
+
signal: AbortSignal | undefined,
|
|
39
|
+
onUpdate: AgentToolUpdateCallback<unknown> | undefined,
|
|
40
|
+
ctx: ExtensionToolContext,
|
|
41
|
+
options: Record<string, unknown>,
|
|
42
|
+
): Promise<AgentToolResult<unknown> & { isError?: boolean }>;
|
|
43
|
+
/** Renders a script result: its nested calls, then its output without the header. */
|
|
44
|
+
renderResult(result: unknown, options: unknown, theme: unknown, context: unknown): unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let internals: Promise<PiCodemode> | undefined;
|
|
48
|
+
|
|
49
|
+
/** Loads those parts from Pi's `dist/` by path, so Programs match Pi's script API. */
|
|
50
|
+
export function loadPiCodemode(): Promise<PiCodemode> {
|
|
51
|
+
internals ??= (async () => {
|
|
52
|
+
const dist = join(getPackageDir(), "dist");
|
|
53
|
+
const load = (file: string) => import(pathToFileURL(join(dist, file)).href);
|
|
54
|
+
const [tool, execute, renderer, tui] = await Promise.all([
|
|
55
|
+
load("extensions/codemode/tool.js"),
|
|
56
|
+
load("extensions/codemode/execute.js"),
|
|
57
|
+
load("extensions/codemode/renderer.js"),
|
|
58
|
+
importPiDependency("@earendil-works/pi-tui"),
|
|
59
|
+
]);
|
|
60
|
+
return {
|
|
61
|
+
createCodemodeToolDefinition: tool.createCodemodeToolDefinition,
|
|
62
|
+
createCodemodeDescription: tool.createCodemodeDescription,
|
|
63
|
+
codemodeSchema: tool.codemodeSchema,
|
|
64
|
+
executeCodemode: execute.executeCodemode,
|
|
65
|
+
// A bundled Pi keeps its keybindings in its embedded pi-tui, which `dist/` does not import;
|
|
66
|
+
// share them so key hints name the configured keys.
|
|
67
|
+
renderResult(...args: unknown[]) {
|
|
68
|
+
tui.setKeybindings(getKeybindings());
|
|
69
|
+
return renderer.codemodeRenderers.renderResult(...args);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
})();
|
|
73
|
+
return internals;
|
|
74
|
+
}
|