@tshu-w/pi-agents 0.0.0-stage → 0.1.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/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 +167 -0
- package/package.json +64 -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 +136 -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 +606 -0
package/README.md
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-agents
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Multi-agent runtime for [Pi](https://pi.dev): hand work to other agents and keep going while they run.
|
|
4
|
+
|
|
5
|
+
https://github.com/user-attachments/assets/c1d48bc4-3b36-438e-976b-f76ae586ffdd
|
|
6
|
+
|
|
7
|
+
<details>
|
|
8
|
+
<summary>Play by play</summary>
|
|
9
|
+
|
|
10
|
+
1. Ask for three background reviews. Three Agents start, and the task panel lists them.
|
|
11
|
+
2. Ask about `res.redirect` while they run. The first review finishes, and its notification arrives.
|
|
12
|
+
3. `/agents` opens `review-application` to watch it work; `Ctrl+C` returns.
|
|
13
|
+
4. The other notifications arrive, and the main Agent reports each review.
|
|
14
|
+
5. Ask for a summary of each file in `lib/`. A background Program starts one Agent per file; the panel shows three running and the rest queued.
|
|
15
|
+
6. `Ctrl+Z` detaches and leaves the Session running. `pd agents` opens the workbench, and `Space` previews the Session.
|
|
16
|
+
7. Type a message in the preview to send it to the Session.
|
|
17
|
+
8. `Enter` attaches to the Session with the reply.
|
|
18
|
+
|
|
19
|
+
</details>
|
|
20
|
+
|
|
21
|
+
- **Async agents**: Agents run in parallel in the background and notify their parent when done.
|
|
22
|
+
- **Programmatic delegation**: Split long-horizon tasks in code into [calls the model handles well](https://alexzhang13.github.io/blog/2026/harness/), as in [RLM](https://alexzhang13.github.io/blog/2025/rlm/).
|
|
23
|
+
- **Inter-agent messaging**: Agents message each other, even in other Pi instances.
|
|
24
|
+
- **Workbench**: All your Sessions in one view; keep them running after the terminal closes.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install -g @tshu-w/pi-agents
|
|
30
|
+
pi install "$(npm root -g)/@tshu-w/pi-agents"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Pi 1.0.0 or later. Start Pi with `pd` (or `pi-agents`) in place of `pi`, and enable `program` with `{ "defaultTools": ["+program"] }` in `settings.json`.
|
|
34
|
+
|
|
35
|
+
To use only the extension, without the workbench:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pi install npm:@tshu-w/pi-agents
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
| Command | Does |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `agent` tool | Starts, messages, and waits for Agents |
|
|
46
|
+
| `program` tool | Runs JavaScript that calls tools and Agents |
|
|
47
|
+
| `/agents` | Opens an Agent in the tree to watch and talk to it |
|
|
48
|
+
| `/tasks` | Hides or shows the task panel |
|
|
49
|
+
| `Ctrl+Z`, `/detach` | Leaves a Session running and returns to the shell |
|
|
50
|
+
| `pd agents` | Opens the workbench |
|
|
51
|
+
| `pd attach <session>` | Attaches to a Session, starting it if needed |
|
|
52
|
+
|
|
53
|
+
## Limitations
|
|
54
|
+
|
|
55
|
+
- Each Agent is one Pi Session; branches are not separate Agents. Use fork/clone to create independent Agents.
|
|
56
|
+
- A Session can be open in only one Pi instance at a time; quit the other or `pd attach` to it.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
"pi-agents": {
|
|
62
|
+
"maxConcurrent": 3,
|
|
63
|
+
"maxOutstanding": 8,
|
|
64
|
+
"extensions": []
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Concurrency limits per root, and extensions loaded by child Agents.
|
|
69
|
+
|
|
70
|
+
## Docs
|
|
71
|
+
|
|
72
|
+
- [SPEC.md](docs/SPEC.md): Agents, messages, and limits
|
|
73
|
+
- [PROGRAM.md](docs/PROGRAM.md): Programs and their API
|
|
74
|
+
- [WORKBENCH.md](docs/WORKBENCH.md): the workbench, hosts, and the daemon
|
|
75
|
+
|
|
76
|
+
## Evaluation
|
|
77
|
+
|
|
78
|
+
On [BrowseComp](https://arxiv.org/abs/2504.12516), async agents improved 7 of 8 models by up to 9 points; programmatic delegation kept accuracy with 20%+ fewer tokens.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
[AGPL-3.0](LICENSE)
|
package/bin/daemon.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The per-user daemon: tracks root Sessions, routes messages between them, and starts a host for
|
|
2
|
+
// a Session that is offline. Runs while a Session or the workbench is connected, exits once idle
|
|
3
|
+
// otherwise, and restarts once idle after pi-agents is updated.
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { isAbsolute } from 'node:path';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { startHost } from '../src/host/client.mjs';
|
|
8
|
+
import { acquireLock, pruneLocks, reserve } from '../src/roots/locks.mjs';
|
|
9
|
+
import { prepareDirectory, statePaths } from '../src/roots/paths.mjs';
|
|
10
|
+
import { findRoot } from '../src/roots/discovery.mjs';
|
|
11
|
+
import { listen, request } from '../src/roots/transport.mjs';
|
|
12
|
+
import { recoverSocket } from '../src/roots/socket-recovery.mjs';
|
|
13
|
+
import { createSessions } from '../src/daemon/sessions.mjs';
|
|
14
|
+
import { createRouter } from '../src/daemon/router.mjs';
|
|
15
|
+
import { codeVersion } from '../src/daemon/version.mjs';
|
|
16
|
+
|
|
17
|
+
const [sessionDir] = process.argv.slice(2);
|
|
18
|
+
if (!sessionDir) throw new Error('Daemon requires the session directory');
|
|
19
|
+
const paths = statePaths();
|
|
20
|
+
await prepareDirectory(paths.runtime);
|
|
21
|
+
let lock;
|
|
22
|
+
try { lock = acquireLock({ stateDir: paths.locks, sessionId: 'daemon' }); }
|
|
23
|
+
catch (error) { if (error.code === 'SESSION_OCCUPIED') process.exit(0); throw error; }
|
|
24
|
+
pruneLocks(paths.locks);
|
|
25
|
+
const IDLE_MS = 30000;
|
|
26
|
+
const PUBLISH_MS = 250;
|
|
27
|
+
const RECHECK_MS = 1000;
|
|
28
|
+
const version = await codeVersion();
|
|
29
|
+
let waking = 0, delivering = 0, lastActivity = Date.now(), retiring = false, closing = false;
|
|
30
|
+
const stop = new AbortController();
|
|
31
|
+
const sessions = await createSessions(paths, sessionDir);
|
|
32
|
+
const workbenches = new Set();
|
|
33
|
+
const channels = new Map();
|
|
34
|
+
|
|
35
|
+
/** The model Pi restores for the Session: the last one set or used on its active branch, which ends at its last entry. */
|
|
36
|
+
async function savedModel(file, signal) {
|
|
37
|
+
const entries = new Map();
|
|
38
|
+
let leaf;
|
|
39
|
+
for (const line of (await readFile(file, { encoding: 'utf8', signal })).split('\n')) {
|
|
40
|
+
let entry;
|
|
41
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
42
|
+
if (typeof entry?.id !== 'string' || entry.type === 'session') continue;
|
|
43
|
+
entries.set(entry.id, entry);
|
|
44
|
+
leaf = entry;
|
|
45
|
+
}
|
|
46
|
+
for (let entry = leaf; entry; entry = entries.get(entry.parentId)) {
|
|
47
|
+
if (entry.type === 'model_change') return { provider: entry.provider, modelId: entry.modelId };
|
|
48
|
+
if (entry.type === 'message' && entry.message?.role === 'assistant') return { provider: entry.message.provider, modelId: entry.message.model };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Waits until the woken Session accepts messages with its saved model. */
|
|
53
|
+
async function ready(root, model, signal) {
|
|
54
|
+
for (;;) {
|
|
55
|
+
try {
|
|
56
|
+
const status = await request(paths.session(root.id), { action: 'status' }, { signal, timeoutMs: 1000 });
|
|
57
|
+
if (status.id !== root.id || status.cwd !== root.cwd) throw new Error('Awakened Agent identity or cwd does not match its Session');
|
|
58
|
+
if (status.availabilityError) throw new Error(status.availabilityError);
|
|
59
|
+
if (status.ready) {
|
|
60
|
+
if (model && (status.model?.provider !== model.provider || status.model?.modelId !== model.modelId)) {
|
|
61
|
+
throw new Error(`Saved model ${model.provider}/${model.modelId} was not restored; refusing model fallback.`);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
signal.throwIfAborted();
|
|
67
|
+
if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error;
|
|
68
|
+
}
|
|
69
|
+
await delay(50, undefined, { signal });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const router = createRouter({
|
|
74
|
+
deliverToSession: (message, options) => request(paths.session(message.recipient), { action: 'deliver', message }, options),
|
|
75
|
+
async wake(id, { signal, launch }) {
|
|
76
|
+
if (!launch) throw Object.assign(new Error(`Agent ${id} is offline`), { code: 'OFFLINE' });
|
|
77
|
+
waking++;
|
|
78
|
+
try {
|
|
79
|
+
const combined = AbortSignal.any([signal, stop.signal]);
|
|
80
|
+
const root = await findRoot(sessionDir, id, { extraFiles: sessions.files(), signal: combined });
|
|
81
|
+
if (!root) throw Object.assign(new Error(`No root Agent ${id}`), { code: 'UNKNOWN_AGENT' });
|
|
82
|
+
if (typeof root.cwd !== 'string' || !root.cwd.trim() || !isAbsolute(root.cwd)) {
|
|
83
|
+
throw Object.assign(new Error(`Agent ${id} requires a nonempty absolute working directory; refusing cwd fallback.`), { code: 'INVALID_CWD' });
|
|
84
|
+
}
|
|
85
|
+
reserve(paths.locks, root.sessionFile, id).release();
|
|
86
|
+
// Pi restores a Session's model before extensions register their providers; name it explicitly.
|
|
87
|
+
const model = await savedModel(root.sessionFile, combined);
|
|
88
|
+
const args = [...launch.args, '--session', root.sessionFile, ...(model ? ['--model', `${model.provider}/${model.modelId}`] : [])];
|
|
89
|
+
const socket = await startHost(paths, { pi: launch.command, args, cwd: root.cwd, env: launch.env });
|
|
90
|
+
try { await ready(root, model, combined); }
|
|
91
|
+
catch (error) {
|
|
92
|
+
// A Session that cannot take the message does not stay running unseen.
|
|
93
|
+
try { process.kill(Number(/h-(\d+)\.sock$/.exec(socket)[1]), 'SIGTERM'); } catch {}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
} finally { waking--; lastActivity = Date.now(); }
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
let publishing;
|
|
101
|
+
function changed() {
|
|
102
|
+
if (publishing || !workbenches.size) return;
|
|
103
|
+
publishing = delay(PUBLISH_MS).then(async () => {
|
|
104
|
+
publishing = undefined;
|
|
105
|
+
const list = await sessions.list();
|
|
106
|
+
for (const workbench of workbenches) workbench.send({ type: 'sessions', sessions: list });
|
|
107
|
+
// A Pi that reported its end holds the Session lock until it exits, which nothing else announces.
|
|
108
|
+
if (list.some(row => row.status === 'unknown')) setTimeout(changed, RECHECK_MS).unref();
|
|
109
|
+
}).catch(error => console.error(`[pi-agents] Listing Sessions failed: ${error.message}`));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const text = value => typeof value === 'string' ? value : undefined;
|
|
113
|
+
function sessionState(value) {
|
|
114
|
+
if (!value || typeof value !== 'object' || typeof value.id !== 'string' || !value.id) return;
|
|
115
|
+
const turn = value.turn && Number.isFinite(value.turn.at) ? { at: value.turn.at, failed: value.turn.failed === true } : undefined;
|
|
116
|
+
return {
|
|
117
|
+
id: value.id, sessionFile: text(value.sessionFile), cwd: text(value.cwd), name: text(value.name), title: text(value.title),
|
|
118
|
+
host: text(value.host), attached: value.attached === true, working: value.working === true, blocked: value.blocked === true,
|
|
119
|
+
tool: text(value.tool), reply: text(value.reply), agents: Number.isSafeInteger(value.agents) ? value.agents : 0,
|
|
120
|
+
...(turn ? { turn } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function openSession(channel) {
|
|
125
|
+
const reported = new Set();
|
|
126
|
+
channel.onMessage(message => {
|
|
127
|
+
if (message?.type === 'state') {
|
|
128
|
+
const state = sessionState(message.state);
|
|
129
|
+
if (!state) return;
|
|
130
|
+
reported.add(state.id);
|
|
131
|
+
channels.set(state.id, channel);
|
|
132
|
+
sessions.update(state);
|
|
133
|
+
} else if (message?.type === 'end' && typeof message.id === 'string' && reported.delete(message.id)) {
|
|
134
|
+
if (channels.get(message.id) === channel) channels.delete(message.id);
|
|
135
|
+
sessions.end(message.id);
|
|
136
|
+
} else return;
|
|
137
|
+
changed();
|
|
138
|
+
});
|
|
139
|
+
channel.signal.addEventListener('abort', () => {
|
|
140
|
+
lastActivity = Date.now();
|
|
141
|
+
if (closing) return;
|
|
142
|
+
for (const id of reported) {
|
|
143
|
+
if (channels.get(id) === channel) channels.delete(id);
|
|
144
|
+
sessions.end(id, { abnormal: true });
|
|
145
|
+
}
|
|
146
|
+
changed();
|
|
147
|
+
}, { once: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function openWorkbench(channel) {
|
|
151
|
+
workbenches.add(channel);
|
|
152
|
+
channel.onMessage(message => {
|
|
153
|
+
if (typeof message?.id !== 'string') return;
|
|
154
|
+
if (message.type === 'interrupt') channels.get(message.id)?.send({ type: 'interrupt', id: message.id });
|
|
155
|
+
else if (message.type === 'show' || message.type === 'hide' || message.type === 'see') {
|
|
156
|
+
sessions[message.type](message.id);
|
|
157
|
+
changed();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
channel.signal.addEventListener('abort', () => { workbenches.delete(channel); lastActivity = Date.now(); }, { once: true });
|
|
161
|
+
void sessions.list().then(list => channel.send({ type: 'sessions', sessions: list }),
|
|
162
|
+
error => console.error(`[pi-agents] Listing Sessions failed: ${error.message}`));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await recoverSocket(paths.daemon);
|
|
166
|
+
const server = await listen(paths.daemon, {
|
|
167
|
+
serialize: false,
|
|
168
|
+
status: () => ({ ready: true, pid: process.pid, version }),
|
|
169
|
+
async accept(message, options) {
|
|
170
|
+
delivering++;
|
|
171
|
+
try { return await router.accept(message, options); }
|
|
172
|
+
finally { delivering--; }
|
|
173
|
+
},
|
|
174
|
+
stream(input, channel) {
|
|
175
|
+
lastActivity = Date.now();
|
|
176
|
+
// A newly started Pi may run updated code; this daemon then hands over once idle.
|
|
177
|
+
void codeVersion().then(current => { if (current !== version) retiring = true; }, () => {});
|
|
178
|
+
if (input.role === 'workbench') openWorkbench(channel);
|
|
179
|
+
else openSession(channel);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const idleTimer = setInterval(() => {
|
|
184
|
+
if (closing || waking || delivering) return;
|
|
185
|
+
if (retiring) return shutdown(server.close());
|
|
186
|
+
if (Date.now() - lastActivity < IDLE_MS) return;
|
|
187
|
+
const closed = server.closeIfIdle(IDLE_MS);
|
|
188
|
+
if (closed) shutdown(closed);
|
|
189
|
+
}, 1000);
|
|
190
|
+
|
|
191
|
+
// Connected Sessions reconnect to the next daemon.
|
|
192
|
+
function shutdown(closed = server.close()) {
|
|
193
|
+
if (closing) return;
|
|
194
|
+
closing = true;
|
|
195
|
+
clearInterval(idleTimer);
|
|
196
|
+
stop.abort();
|
|
197
|
+
void closed.finally(async () => {
|
|
198
|
+
await sessions.saved();
|
|
199
|
+
lock.release();
|
|
200
|
+
process.exit(0);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
process.once('SIGTERM', () => shutdown());
|
|
204
|
+
process.once('SIGINT', () => shutdown());
|
package/bin/host.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Runs one interactive Pi in a pseudo-terminal and relays it to at most one attached terminal.
|
|
2
|
+
// Started detached by `pi` or the daemon; exits when Pi exits.
|
|
3
|
+
import { rmSync } from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { FRAME, frame, json, readFrames } from '../src/host/frames.mjs';
|
|
7
|
+
import { prepareDirectory, statePaths } from '../src/roots/paths.mjs';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const pty = require('@lydell/node-pty');
|
|
11
|
+
const { Terminal } = require('@xterm/headless');
|
|
12
|
+
const { SerializeAddon } = require('@xterm/addon-serialize');
|
|
13
|
+
|
|
14
|
+
const config = JSON.parse(process.env.PI_AGENTS_HOST_CONFIG);
|
|
15
|
+
const paths = statePaths();
|
|
16
|
+
const socketPath = paths.host(process.pid);
|
|
17
|
+
|
|
18
|
+
// Terminal modes Pi sets that the screen mirror does not keep; replayed to each newly attached terminal.
|
|
19
|
+
const MODE = /\x1b\[>\d*u|\x1b\[<\d*u|\x1b\[>4;\d*m|\x1b\[\?(?:2031|2004|1000|1002|1003|1006|1004|25)[hl]|\x1b\](?:0|2|7501);[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
20
|
+
const modes = new Map();
|
|
21
|
+
function trackModes(data) {
|
|
22
|
+
for (const [sequence] of data.matchAll(MODE)) {
|
|
23
|
+
if (sequence.startsWith('\x1b[<')) modes.delete('keyboard');
|
|
24
|
+
else if (sequence.startsWith('\x1b[>4;')) modes.set('modifyOtherKeys', sequence);
|
|
25
|
+
else if (sequence.startsWith('\x1b[>')) modes.set('keyboard', sequence);
|
|
26
|
+
else if (sequence.startsWith('\x1b[?')) modes.set(sequence.slice(0, -1), sequence);
|
|
27
|
+
// Program status (OSC 7501): Pi reports it only on change, so a terminal attaching later needs the last one.
|
|
28
|
+
else if (sequence.startsWith('\x1b]7501;state=clear')) modes.delete('status');
|
|
29
|
+
else if (sequence.startsWith('\x1b]7501;')) modes.set('status', sequence);
|
|
30
|
+
else modes.set('title', sequence);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pi's startup queries: keyboard protocol, program status, device attributes, and colors. Pi waits for the
|
|
35
|
+
// replies, so those it sent before any terminal attached are passed to the first one.
|
|
36
|
+
const QUERY = /\x1b\[\?u|\x1b\[c|\x1b\]7501;\?[^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\](?:10|11|4;\d+);\?(?:\x07|\x1b\\)/g;
|
|
37
|
+
let queries = '';
|
|
38
|
+
let attachedOnce = false;
|
|
39
|
+
|
|
40
|
+
// Pi's output may split a control sequence across chunks; its unfinished end waits for the next chunk.
|
|
41
|
+
const SEQUENCE = /^(?:\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))/;
|
|
42
|
+
let partial = '';
|
|
43
|
+
function completed(data) {
|
|
44
|
+
const text = partial + data;
|
|
45
|
+
const start = Math.max(text.lastIndexOf('\x1b['), text.lastIndexOf('\x1b]'));
|
|
46
|
+
const end = start >= 0 && !SEQUENCE.test(text.slice(start)) ? start : text.endsWith('\x1b') ? text.length - 1 : text.length;
|
|
47
|
+
partial = text.length - end > 4096 ? '' : text.slice(end);
|
|
48
|
+
return text.slice(0, end);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const COLOR_SCHEME_REPORT = /\x1b\[\?997;([12])n/;
|
|
52
|
+
const COLOR_SCHEME_WAIT_MS = 300;
|
|
53
|
+
let colorScheme = '1';
|
|
54
|
+
let session, terminal, control, child;
|
|
55
|
+
|
|
56
|
+
await prepareDirectory(paths.runtime);
|
|
57
|
+
rmSync(socketPath, { force: true });
|
|
58
|
+
|
|
59
|
+
const env = { ...process.env, PI_AGENTS_HOST: socketPath, PI_AGENTS_HOST_PID: String(process.pid) };
|
|
60
|
+
delete env.PI_AGENTS_HOST_CONFIG;
|
|
61
|
+
const mirror = new Terminal({ cols: config.cols, rows: config.rows, allowProposedApi: true, scrollback: 1000 });
|
|
62
|
+
const serializer = new SerializeAddon();
|
|
63
|
+
mirror.loadAddon(serializer);
|
|
64
|
+
|
|
65
|
+
function start({ cols, rows }) {
|
|
66
|
+
mirror.resize(cols, rows);
|
|
67
|
+
child = pty.spawn(config.pi, config.args, { name: env.TERM || 'xterm-256color', cols, rows, cwd: config.cwd, env });
|
|
68
|
+
child.onData(data => {
|
|
69
|
+
const text = completed(data);
|
|
70
|
+
trackModes(text);
|
|
71
|
+
if (!attachedOnce) for (const [query] of text.matchAll(QUERY)) queries += query;
|
|
72
|
+
mirror.write(data);
|
|
73
|
+
terminal?.socket.write(frame(FRAME.data, data));
|
|
74
|
+
});
|
|
75
|
+
child.onExit(({ exitCode }) => {
|
|
76
|
+
server.close();
|
|
77
|
+
rmSync(socketPath, { force: true });
|
|
78
|
+
// Pi's last output can arrive after its exit; let it through before the final frame.
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
terminal?.socket.end(frame(FRAME.exited, { session, exitCode }));
|
|
81
|
+
setTimeout(() => process.exit(0), 100).unref();
|
|
82
|
+
}, 100);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// Started for a terminal, Pi waits for it, so its startup queries reach the terminal.
|
|
86
|
+
if (!config.waitForTerminal) start(config);
|
|
87
|
+
else setTimeout(() => { if (!child) process.exit(0); }, 10000).unref();
|
|
88
|
+
|
|
89
|
+
function resize({ cols, rows }) {
|
|
90
|
+
mirror.resize(cols, rows);
|
|
91
|
+
child.resize(cols, rows);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Moves the cursor below the screen's content, so what the terminal prints next does not overwrite it.
|
|
95
|
+
function belowContent() {
|
|
96
|
+
const buffer = mirror.buffer.active;
|
|
97
|
+
if (buffer.type !== 'normal') return '';
|
|
98
|
+
const cursor = buffer.baseY + buffer.cursorY;
|
|
99
|
+
let last = buffer.length - 1;
|
|
100
|
+
while (last > cursor && !buffer.getLine(last)?.translateToString(true).trim()) last--;
|
|
101
|
+
return last > cursor ? `\x1b[${last - cursor}B` : '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function detach(reason) {
|
|
105
|
+
terminal.socket.write(frame(FRAME.data, belowContent()));
|
|
106
|
+
terminal.socket.end(frame(FRAME.detached, { reason, session }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setAttached(next) {
|
|
110
|
+
if (terminal && next !== terminal) detach('takeover');
|
|
111
|
+
terminal = next;
|
|
112
|
+
control?.write(frame(FRAME.control, { type: 'attached', attached: Boolean(terminal) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function attach(socket, size) {
|
|
116
|
+
const next = { socket, colorReply: false };
|
|
117
|
+
setAttached(next);
|
|
118
|
+
const first = !attachedOnce;
|
|
119
|
+
attachedOnce = true;
|
|
120
|
+
if (!child) return start(size);
|
|
121
|
+
resize(size);
|
|
122
|
+
socket.write(frame(FRAME.data, '\x1b[H\x1b[2J' + serializer.serialize() + [...modes.values()].join('') + (first ? queries : '') + partial));
|
|
123
|
+
queries = '';
|
|
124
|
+
// Pi re-queries the terminal's colors when it receives a color scheme report.
|
|
125
|
+
if (modes.get('\x1b[?2031') === '\x1b[?2031h') {
|
|
126
|
+
socket.write(frame(FRAME.data, '\x1b[?996n'));
|
|
127
|
+
setTimeout(() => { if (terminal === next && !next.colorReply) child.write(`\x1b[?997;${colorScheme}n`); }, COLOR_SCHEME_WAIT_MS);
|
|
128
|
+
}
|
|
129
|
+
// A size change makes Pi redraw, which brings back images the mirror cannot keep.
|
|
130
|
+
child.resize(Math.max(size.cols - 1, 1), size.rows);
|
|
131
|
+
setTimeout(() => { if (terminal === next) child.resize(size.cols, size.rows); }, 100);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function status() {
|
|
135
|
+
return { pid: process.pid, cwd: config.cwd, session, attached: Boolean(terminal) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const server = net.createServer(socket => {
|
|
139
|
+
socket.on('error', () => {});
|
|
140
|
+
readFrames(socket, (type, body) => {
|
|
141
|
+
if (type === FRAME.status) socket.end(frame(FRAME.status, status()));
|
|
142
|
+
else if (type === FRAME.attach) attach(socket, json(body));
|
|
143
|
+
else if (type === FRAME.resize && terminal?.socket === socket && child) resize(json(body));
|
|
144
|
+
else if (type === FRAME.detached && terminal?.socket === socket) {
|
|
145
|
+
detach('detach');
|
|
146
|
+
setAttached(undefined);
|
|
147
|
+
}
|
|
148
|
+
else if (type === FRAME.data && terminal?.socket === socket && child) {
|
|
149
|
+
const reply = body.toString('latin1').match(COLOR_SCHEME_REPORT);
|
|
150
|
+
if (reply) { terminal.colorReply = true; colorScheme = reply[1]; }
|
|
151
|
+
child.write(body);
|
|
152
|
+
} else if (type === FRAME.control) {
|
|
153
|
+
control = socket;
|
|
154
|
+
const message = json(body);
|
|
155
|
+
if (message.type === 'session') session = message.session;
|
|
156
|
+
else if (message.type === 'detach' && terminal) {
|
|
157
|
+
detach('detach');
|
|
158
|
+
setAttached(undefined);
|
|
159
|
+
}
|
|
160
|
+
socket.write(frame(FRAME.control, { type: 'attached', attached: Boolean(terminal) }));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
socket.on('close', () => {
|
|
164
|
+
if (terminal?.socket === socket) setAttached(undefined);
|
|
165
|
+
if (control === socket) control = undefined;
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
server.listen(socketPath);
|
|
169
|
+
process.on('SIGHUP', () => {});
|
|
170
|
+
process.on('SIGTERM', () => child ? child.kill('SIGTERM') : process.exit(0));
|
package/bin/pd.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `pd` command: runs interactive Pi in a host and attaches this terminal; everything else
|
|
3
|
+
// runs Pi directly.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { accessSync, constants } from 'node:fs';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { attach, listHosts, startHost } from '../src/host/client.mjs';
|
|
10
|
+
import { detachKey, detachMatcher } from '../src/host/keys.mjs';
|
|
11
|
+
import { discoverRoots, findRoot } from '../src/roots/discovery.mjs';
|
|
12
|
+
import { agentDir, configuredSessionDir, defaultSessionDir, resolvePath, sessionDir, statePaths } from '../src/roots/paths.mjs';
|
|
13
|
+
import { piPackage } from '../src/pi.mjs';
|
|
14
|
+
import { markedFiles } from '../src/daemon/sessions.mjs';
|
|
15
|
+
|
|
16
|
+
const real = process.env.PATH.split(':').map(dir => join(dir, 'pi')).find(file => {
|
|
17
|
+
try { accessSync(file, constants.X_OK); return true; } catch { return false; }
|
|
18
|
+
});
|
|
19
|
+
if (!real) {
|
|
20
|
+
process.stderr.write('pd needs Pi: install it so that pi is on PATH.\n');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const paths = statePaths();
|
|
25
|
+
|
|
26
|
+
const COMMANDS = new Set(['install', 'remove', 'uninstall', 'update', 'list', 'config', 'auth', 'mcp']);
|
|
27
|
+
const DIRECT_FLAGS = new Set(['-p', '--print', '--mode', '-h', '--help', '-v', '--version', '--export', '--list-models', '--no-session']);
|
|
28
|
+
|
|
29
|
+
function runDirectly() {
|
|
30
|
+
const child = spawn(real, args, { stdio: 'inherit' });
|
|
31
|
+
// The terminal delivers Ctrl+C to Pi too; Pi decides what it means.
|
|
32
|
+
process.on('SIGINT', () => {});
|
|
33
|
+
child.on('exit', (code, signal) => {
|
|
34
|
+
if (signal) {
|
|
35
|
+
process.removeAllListeners(signal);
|
|
36
|
+
process.kill(process.pid, signal);
|
|
37
|
+
}
|
|
38
|
+
else process.exit(code ?? 1);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function option(name, alias) {
|
|
43
|
+
for (let i = 0; i < args.length; i++) {
|
|
44
|
+
if (args[i] === '--') return;
|
|
45
|
+
if (args[i] === name || args[i] === alias) return args[i + 1] ?? '';
|
|
46
|
+
if (args[i].startsWith(`${name}=`)) return args[i].slice(name.length + 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async function attachAndReport(socket) {
|
|
52
|
+
const tui = await import(pathToFileURL(createRequire(join(piPackage(real), 'package.json')).resolve('@earendil-works/pi-tui')).href);
|
|
53
|
+
const result = await attach(socket, { isDetach: detachMatcher(tui, detachKey(agentDir())) });
|
|
54
|
+
const id = result.session?.id;
|
|
55
|
+
if (result.reason === 'exited') process.exit(result.exitCode ?? 0);
|
|
56
|
+
if (result.reason === 'closed') {
|
|
57
|
+
process.stderr.write(`The host stopped unexpectedly. See ${paths.hostLog}\n`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
if (result.reason === 'takeover') process.stdout.write('Attached in another terminal.\n');
|
|
61
|
+
process.stdout.write(`Pi keeps running. To reattach, run pd attach ${id ?? '<session>'}\n`);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The running host already holding the Session that `--session` or `-c` would open. */
|
|
66
|
+
async function runningTarget(hosts) {
|
|
67
|
+
const session = option('--session');
|
|
68
|
+
if (session) {
|
|
69
|
+
const file = resolve(session);
|
|
70
|
+
return hosts.find(host => host.session && (host.session.id.startsWith(session) || host.session.file === file));
|
|
71
|
+
}
|
|
72
|
+
if (args.includes('-c') || args.includes('--continue')) {
|
|
73
|
+
const file = await recentSession();
|
|
74
|
+
return file && hosts.find(host => host.session?.file === file);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The Session `-c` continues, chosen by Pi's own `continueRecent` rules. */
|
|
79
|
+
async function recentSession() {
|
|
80
|
+
const { findMostRecentSession } = await import(pathToFileURL(join(piPackage(real), 'dist/core/session-manager.js')).href);
|
|
81
|
+
const flag = option('--session-dir');
|
|
82
|
+
const configured = flag ? resolvePath(flag) : configuredSessionDir();
|
|
83
|
+
const fallback = defaultSessionDir(process.cwd());
|
|
84
|
+
const directory = configured ?? fallback;
|
|
85
|
+
// A configured directory may hold other cwds' Sessions.
|
|
86
|
+
return findMostRecentSession(directory, directory === fallback ? undefined : process.cwd()) ?? undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function attachCommand(query) {
|
|
90
|
+
if (!query) {
|
|
91
|
+
process.stderr.write('Usage: pd attach <session>\n');
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
const hosts = await listHosts(paths);
|
|
95
|
+
const hosted = hosts.filter(host => host.session && (host.session.id.startsWith(query) || host.session.name === query));
|
|
96
|
+
if (hosted.length === 1) return attachAndReport(hosted[0].socket);
|
|
97
|
+
let roots = [];
|
|
98
|
+
if (!hosted.length) {
|
|
99
|
+
const extraFiles = await markedFiles(paths);
|
|
100
|
+
const exact = await findRoot(sessionDir(), query, { extraFiles });
|
|
101
|
+
roots = exact ? [exact] : (await discoverRoots(sessionDir(), { extraFiles }))
|
|
102
|
+
.filter(root => root.id.startsWith(query) || root.name === query);
|
|
103
|
+
}
|
|
104
|
+
const matches = hosted.length ? hosted.map(host => host.session) : roots;
|
|
105
|
+
if (matches.length !== 1) {
|
|
106
|
+
process.stderr.write(matches.length
|
|
107
|
+
? `"${query}" matches several Sessions:\n${matches.map(match => ` ${match.id} ${match.name ?? ''}`).join('\n')}\n`
|
|
108
|
+
: `No Session matches "${query}".\n`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
const [root] = roots;
|
|
112
|
+
const socket = await startHost(paths, {
|
|
113
|
+
pi: real, args: ['--session', root.sessionFile], cwd: root.cwd,
|
|
114
|
+
cols: process.stdout.columns, rows: process.stdout.rows, waitForTerminal: true,
|
|
115
|
+
});
|
|
116
|
+
return attachAndReport(socket);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
120
|
+
if (args[0] === 'attach' && interactive) await attachCommand(args[1]);
|
|
121
|
+
else if (args[0] === 'agents' && interactive) {
|
|
122
|
+
const { workbench } = await import('../src/workbench.mjs');
|
|
123
|
+
await workbench({ pi: real, paths, sessionDir: sessionDir() });
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
else if (!interactive || COMMANDS.has(args[0]) || args.some(arg => DIRECT_FLAGS.has(arg.split('=')[0]))) runDirectly();
|
|
127
|
+
else {
|
|
128
|
+
const running = await runningTarget(await listHosts(paths));
|
|
129
|
+
if (running) {
|
|
130
|
+
// Pi already runs this Session, so options that would start it differently do not apply.
|
|
131
|
+
const ignored = args.filter((arg, i) => !['--session', '--session-dir', '-c', '--continue'].includes(arg)
|
|
132
|
+
&& !arg.startsWith('--session=') && !['--session', '--session-dir'].includes(args[i - 1]));
|
|
133
|
+
if (ignored.length) process.stderr.write(`The Session is already running; attaching without applying: ${ignored.join(' ')}\n`);
|
|
134
|
+
}
|
|
135
|
+
const socket = running?.socket ?? await startHost(paths, {
|
|
136
|
+
pi: real, args, cwd: process.cwd(), cols: process.stdout.columns, rows: process.stdout.rows, waitForTerminal: true,
|
|
137
|
+
});
|
|
138
|
+
await attachAndReport(socket);
|
|
139
|
+
}
|