@tshu-w/pi-agents 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +87 -42
- package/docs/HOSTS.md +111 -0
- package/docs/PROGRAM.md +39 -114
- package/docs/SPEC.md +109 -161
- package/package.json +7 -5
- package/src/agents/agents.ts +129 -55
- package/src/agents/registry.ts +62 -10
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +41 -13
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +30 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +380 -0
- package/src/index.ts +44 -16
- package/src/output.ts +5 -1
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/loadout.ts +18 -3
- package/src/programs/programs.ts +128 -20
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +15 -7
- package/src/programs/tool.ts +172 -16
- package/src/render-call.ts +113 -15
- package/src/roots/guard.mjs +8 -2
- package/src/roots/paths.mjs +4 -4
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/message.ts +24 -0
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +47 -0
- package/src/ui/viewer.ts +182 -38
- package/src/workbench.mjs +91 -51
- package/docs/WORKBENCH.md +0 -167
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Multi-agent runtime for [Pi](https://pi.dev): hand work to other agents and keep going while they run.
|
|
4
4
|
|
|
5
|
-
https://github.com/user-attachments/assets/
|
|
5
|
+
https://github.com/user-attachments/assets/52dd1de6-bf26-45ea-8775-2b1a1a187f4f
|
|
6
6
|
|
|
7
7
|
<details>
|
|
8
8
|
<summary>Play by play</summary>
|
|
9
9
|
|
|
10
10
|
1. Ask for three background reviews. Three Agents start, and the task panel lists them.
|
|
11
11
|
2. Ask about `res.redirect` while they run. The first review finishes, and its notification arrives.
|
|
12
|
-
3. `/
|
|
12
|
+
3. `/tasks` opens `review-application` to watch it work; `Ctrl+C` returns.
|
|
13
13
|
4. The other notifications arrive, and the main Agent reports each review.
|
|
14
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
15
|
6. `Ctrl+Z` detaches and leaves the Session running. `pd agents` opens the workbench, and `Space` previews the Session.
|
|
@@ -19,9 +19,9 @@ https://github.com/user-attachments/assets/c1d48bc4-3b36-438e-976b-f76ae586ffdd
|
|
|
19
19
|
</details>
|
|
20
20
|
|
|
21
21
|
- **Async agents**: Agents run in parallel in the background and notify their parent when done.
|
|
22
|
-
- **
|
|
22
|
+
- **Dynamic workflows**: Extends Pi's [codemode](https://pi.dev/docs/latest/codemode) to recursively split long-horizon tasks into calls the model handles well, as in [RLM](https://alexzhang13.github.io/blog/2026/harness/).
|
|
23
23
|
- **Inter-agent messaging**: Agents message each other, even in other Pi instances.
|
|
24
|
-
- **
|
|
24
|
+
- **Persistent agents**: Agents keep running after the terminal closes, with a workbench to manage them.
|
|
25
25
|
|
|
26
26
|
## Install
|
|
27
27
|
|
|
@@ -30,7 +30,7 @@ npm install -g @tshu-w/pi-agents
|
|
|
30
30
|
pi install "$(npm root -g)/@tshu-w/pi-agents"
|
|
31
31
|
```
|
|
32
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`.
|
|
33
|
+
Requires Pi 1.0.0 or later. Start Pi with `pd` (or `pi-agents`) in place of `pi`, and enable `program`, which replaces Pi's `codemode`, with `{ "defaultTools": ["+program"] }` in `settings.json`.
|
|
34
34
|
|
|
35
35
|
To use only the extension, without the workbench:
|
|
36
36
|
|
|
@@ -40,21 +40,23 @@ pi install npm:@tshu-w/pi-agents
|
|
|
40
40
|
|
|
41
41
|
## Usage
|
|
42
42
|
|
|
43
|
+
Ask Pi in plain words; it starts Agents and Programs when the work calls for them.
|
|
44
|
+
|
|
45
|
+
| Term | Meaning |
|
|
46
|
+
|---|---|
|
|
47
|
+
| Agent | A Pi conversation: yours, or one started with the `agent` tool |
|
|
48
|
+
| Program | JavaScript the model runs with the `program` tool to call tools and Agents |
|
|
49
|
+
| Workbench | `pd agents`: your recent Sessions, running or not, grouped by what they need from you |
|
|
50
|
+
|
|
43
51
|
| Command | Does |
|
|
44
52
|
|---|---|
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `/
|
|
48
|
-
| `/tasks` | Hides or shows the task panel |
|
|
53
|
+
| `/program <path> [args]` | Runs a saved Program script, with `args` as its input |
|
|
54
|
+
| `/tasks` | Opens an Agent or background Program to watch it; you can talk to Agents |
|
|
55
|
+
| `/tasks panel` | Hides or shows the task panel |
|
|
49
56
|
| `Ctrl+Z`, `/detach` | Leaves a Session running and returns to the shell |
|
|
50
57
|
| `pd agents` | Opens the workbench |
|
|
51
58
|
| `pd attach <session>` | Attaches to a Session, starting it if needed |
|
|
52
59
|
|
|
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
60
|
## Configuration
|
|
59
61
|
|
|
60
62
|
```json
|
|
@@ -65,17 +67,22 @@ pi install npm:@tshu-w/pi-agents
|
|
|
65
67
|
}
|
|
66
68
|
```
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
Each tree runs at most `maxConcurrent` child Agents at once and accepts at most `maxOutstanding` unfinished inputs. `extensions` lists extensions loaded by child Agents.
|
|
69
71
|
|
|
70
|
-
##
|
|
72
|
+
## Limitations
|
|
71
73
|
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
- [WORKBENCH.md](docs/WORKBENCH.md): the workbench, hosts, and the daemon
|
|
74
|
+
- Each Agent is one Pi Session, so branches within it are not separate Agents; use `/fork` or `/clone` to make one.
|
|
75
|
+
- A Session can be open in only one Pi instance at a time; quit the other or `pd attach` to it.
|
|
75
76
|
|
|
76
77
|
## Evaluation
|
|
77
78
|
|
|
78
|
-
On [BrowseComp](https://arxiv.org/abs/2504.12516), async agents improved 7 of 8 models by up to 9 points;
|
|
79
|
+
On [BrowseComp](https://arxiv.org/abs/2504.12516), async agents improved 7 of 8 models by up to 9 points; dynamic workflows kept accuracy with 20%+ fewer tokens.
|
|
80
|
+
|
|
81
|
+
## Docs
|
|
82
|
+
|
|
83
|
+
- [SPEC.md](docs/SPEC.md): Agents, messages, and limits
|
|
84
|
+
- [PROGRAM.md](docs/PROGRAM.md): Programs and their API
|
|
85
|
+
- [HOSTS.md](docs/HOSTS.md): hosted Sessions, the daemon, and the workbench
|
|
79
86
|
|
|
80
87
|
## License
|
|
81
88
|
|
package/bin/daemon.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { isAbsolute } from 'node:path';
|
|
6
6
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
-
import { startHost } from '../src/host/client.mjs';
|
|
7
|
+
import { settledHosts, startHost } from '../src/host/client.mjs';
|
|
8
8
|
import { acquireLock, pruneLocks, reserve } from '../src/roots/locks.mjs';
|
|
9
9
|
import { prepareDirectory, statePaths } from '../src/roots/paths.mjs';
|
|
10
10
|
import { findRoot } from '../src/roots/discovery.mjs';
|
|
@@ -25,6 +25,8 @@ pruneLocks(paths.locks);
|
|
|
25
25
|
const IDLE_MS = 30000;
|
|
26
26
|
const PUBLISH_MS = 250;
|
|
27
27
|
const RECHECK_MS = 1000;
|
|
28
|
+
const OCCUPIED_WAIT_MS = 5000;
|
|
29
|
+
const HOST_WAIT_MS = 3000;
|
|
28
30
|
const version = await codeVersion();
|
|
29
31
|
let waking = 0, delivering = 0, lastActivity = Date.now(), retiring = false, closing = false;
|
|
30
32
|
const stop = new AbortController();
|
|
@@ -49,22 +51,28 @@ async function savedModel(file, signal) {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/** The status of the Session's Pi once it accepts messages, checking that it runs the Session expected. */
|
|
55
|
+
async function accepting(root, signal) {
|
|
56
|
+
try {
|
|
57
|
+
const status = await request(paths.session(root.id), { action: 'status' }, { signal, timeoutMs: 1000 });
|
|
58
|
+
if (status.id !== root.id || status.cwd !== root.cwd) throw new Error('Awakened Agent identity or cwd does not match its Session');
|
|
59
|
+
if (status.availabilityError) throw new Error(status.availabilityError);
|
|
60
|
+
if (status.ready) return status;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
signal.throwIfAborted();
|
|
63
|
+
if (!['ENOENT', 'ECONNREFUSED', 'SOCKET_MODE'].includes(error.code)) throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
/** Waits until the woken Session accepts messages with its saved model. */
|
|
53
68
|
async function ready(root, model, signal) {
|
|
54
69
|
for (;;) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (status.
|
|
58
|
-
|
|
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;
|
|
70
|
+
const status = await accepting(root, signal);
|
|
71
|
+
if (status) {
|
|
72
|
+
if (model && (status.model?.provider !== model.provider || status.model?.modelId !== model.modelId)) {
|
|
73
|
+
throw new Error(`Saved model ${model.provider}/${model.modelId} was not restored; refusing model fallback.`);
|
|
64
74
|
}
|
|
65
|
-
|
|
66
|
-
signal.throwIfAborted();
|
|
67
|
-
if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error;
|
|
75
|
+
return;
|
|
68
76
|
}
|
|
69
77
|
await delay(50, undefined, { signal });
|
|
70
78
|
}
|
|
@@ -82,7 +90,17 @@ const router = createRouter({
|
|
|
82
90
|
if (typeof root.cwd !== 'string' || !root.cwd.trim() || !isAbsolute(root.cwd)) {
|
|
83
91
|
throw Object.assign(new Error(`Agent ${id} requires a nonempty absolute working directory; refusing cwd fallback.`), { code: 'INVALID_CWD' });
|
|
84
92
|
}
|
|
85
|
-
|
|
93
|
+
// A host still starting Pi in the Session's cwd, as when a terminal has just opened the Session, may be
|
|
94
|
+
// starting this Session. The wait leaves the rest of the wake time for starting the Session here.
|
|
95
|
+
await settledHosts(paths, host => host.session?.id === id,
|
|
96
|
+
{ starting: host => !host.session && host.cwd === root.cwd, timeoutMs: HOST_WAIT_MS });
|
|
97
|
+
// Another Pi may hold the Session briefly: one exiting releases it, one starting accepts the message once ready.
|
|
98
|
+
for (const deadline = performance.now() + OCCUPIED_WAIT_MS; ;) {
|
|
99
|
+
try { reserve(paths.locks, root.sessionFile, id).release(); break; }
|
|
100
|
+
catch (error) { if (error.code !== 'SESSION_OCCUPIED' || performance.now() > deadline) throw error; }
|
|
101
|
+
if (await accepting(root, combined)) return;
|
|
102
|
+
await delay(100, undefined, { signal: combined });
|
|
103
|
+
}
|
|
86
104
|
// Pi restores a Session's model before extensions register their providers; name it explicitly.
|
|
87
105
|
const model = await savedModel(root.sessionFile, combined);
|
|
88
106
|
const args = [...launch.args, '--session', root.sessionFile, ...(model ? ['--model', `${model.provider}/${model.modelId}`] : [])];
|
package/bin/host.mjs
CHANGED
|
@@ -64,7 +64,9 @@ mirror.loadAddon(serializer);
|
|
|
64
64
|
|
|
65
65
|
function start({ cols, rows }) {
|
|
66
66
|
mirror.resize(cols, rows);
|
|
67
|
-
|
|
67
|
+
// Pi learns of later attaches from the host, once it connects.
|
|
68
|
+
const attached = terminal ? '1' : '0';
|
|
69
|
+
child = pty.spawn(config.pi, config.args, { name: env.TERM || 'xterm-256color', cols, rows, cwd: config.cwd, env: { ...env, PI_AGENTS_HOST_ATTACHED: attached } });
|
|
68
70
|
child.onData(data => {
|
|
69
71
|
const text = completed(data);
|
|
70
72
|
trackModes(text);
|
|
@@ -73,6 +75,12 @@ function start({ cols, rows }) {
|
|
|
73
75
|
terminal?.socket.write(frame(FRAME.data, data));
|
|
74
76
|
});
|
|
75
77
|
child.onExit(({ exitCode }) => {
|
|
78
|
+
// No terminal saw why Pi failed, so its last screen goes to the log.
|
|
79
|
+
if (!attachedOnce && exitCode !== 0) {
|
|
80
|
+
const buffer = mirror.buffer.active;
|
|
81
|
+
const lines = Array.from({ length: buffer.length }, (_, i) => buffer.getLine(i)?.translateToString(true) ?? '');
|
|
82
|
+
console.error(`[pi-agents] Pi exited (${exitCode}) in ${config.cwd}:\n${lines.join('\n').trim()}`);
|
|
83
|
+
}
|
|
76
84
|
server.close();
|
|
77
85
|
rmSync(socketPath, { force: true });
|
|
78
86
|
// Pi's last output can arrive after its exit; let it through before the final frame.
|
package/bin/pd.mjs
CHANGED
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The `pd` command: runs
|
|
3
|
-
//
|
|
2
|
+
// The `pd` command: runs Pi in a host, attaching this terminal to interactive Pi and connecting
|
|
3
|
+
// non-interactive Pi to the hosted Session; `--no-session` and package commands run Pi directly.
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { accessSync, constants } from 'node:fs';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
-
import { join, resolve } from 'node:path';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
|
-
import {
|
|
9
|
+
import { styleText } from 'node:util';
|
|
10
|
+
import { attach, hostSessionFile, settledHosts, startHost } from '../src/host/client.mjs';
|
|
10
11
|
import { detachKey, detachMatcher } from '../src/host/keys.mjs';
|
|
11
12
|
import { discoverRoots, findRoot } from '../src/roots/discovery.mjs';
|
|
12
|
-
import {
|
|
13
|
+
import { defaultSessionDir, resolvePath, sessionDir, statePaths } from '../src/roots/paths.mjs';
|
|
13
14
|
import { piPackage } from '../src/pi.mjs';
|
|
14
15
|
import { markedFiles } from '../src/daemon/sessions.mjs';
|
|
16
|
+
import { argGroups } from '../src/host/rpc-client.mjs';
|
|
15
17
|
|
|
16
18
|
const real = process.env.PATH.split(':').map(dir => join(dir, 'pi')).find(file => {
|
|
17
19
|
try { accessSync(file, constants.X_OK); return true; } catch { return false; }
|
|
18
20
|
});
|
|
19
21
|
if (!real) {
|
|
20
|
-
process.stderr.write('pd needs Pi: install it so that pi is on PATH
|
|
22
|
+
process.stderr.write(`${error('pd needs Pi: install it so that pi is on PATH.')}\n`);
|
|
21
23
|
process.exit(1);
|
|
22
24
|
}
|
|
23
25
|
const args = process.argv.slice(2);
|
|
24
26
|
const paths = statePaths();
|
|
27
|
+
const dist = file => import(pathToFileURL(join(piPackage(real), 'dist', file)).href);
|
|
28
|
+
// Pi's own parsing, so that `pd` reads every argument as `pi` does.
|
|
29
|
+
const { parseArgs } = await dist('cli/args.js');
|
|
30
|
+
const { ENV_SESSION_DIR, getAgentDir } = await dist('config.js');
|
|
31
|
+
const parsed = parseArgs(args);
|
|
25
32
|
|
|
26
33
|
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
34
|
|
|
29
35
|
function runDirectly() {
|
|
30
36
|
const child = spawn(real, args, { stdio: 'inherit' });
|
|
@@ -39,60 +45,80 @@ function runDirectly() {
|
|
|
39
45
|
});
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
function
|
|
43
|
-
|
|
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
|
+
function error(text) {
|
|
49
|
+
return styleText('red', text, { stream: process.stderr });
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
/** Attaches the terminal to the host at `socket`; `send`, if given, sends messages once attached, and its error is reported once detached. */
|
|
54
|
+
async function attachAndReport(socket, send) {
|
|
52
55
|
const tui = await import(pathToFileURL(createRequire(join(piPackage(real), 'package.json')).resolve('@earendil-works/pi-tui')).href);
|
|
53
|
-
|
|
56
|
+
let sent;
|
|
57
|
+
const result = await attach(socket, { isDetach: detachMatcher(tui, detachKey(getAgentDir())), onAttach: () => { sent = send?.(); } });
|
|
58
|
+
const failure = await sent;
|
|
59
|
+
if (failure) process.stderr.write(`${error(failure)}\n`);
|
|
54
60
|
const id = result.session?.id;
|
|
55
|
-
if (result.reason === 'exited') process.exit(result.exitCode ?? 0);
|
|
61
|
+
if (result.reason === 'exited') process.exit(failure ? 1 : result.exitCode ?? 0);
|
|
56
62
|
if (result.reason === 'closed') {
|
|
57
|
-
process.stderr.write(`The host stopped unexpectedly. See ${paths.hostLog}\n`);
|
|
63
|
+
process.stderr.write(`${error(`The host stopped unexpectedly. See ${paths.hostLog}`)}\n`);
|
|
58
64
|
process.exit(1);
|
|
59
65
|
}
|
|
60
66
|
if (result.reason === 'takeover') process.stdout.write('Attached in another terminal.\n');
|
|
61
|
-
process.stdout.write(
|
|
62
|
-
process.exit(0);
|
|
67
|
+
process.stdout.write(`${styleText('dim', 'Pi keeps running. To reattach:')} pd attach ${id ?? '<session>'}\n`);
|
|
68
|
+
process.exit(failure ? 1 : 0);
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
/** The running host already holding the Session that `--session
|
|
66
|
-
async function runningTarget(
|
|
67
|
-
|
|
71
|
+
/** The running host already holding the Session that `--session`, `--session-id`, or `-c` would open. */
|
|
72
|
+
async function runningTarget() {
|
|
73
|
+
let running;
|
|
74
|
+
const { session, sessionId } = parsed;
|
|
68
75
|
if (session) {
|
|
69
76
|
const file = resolve(session);
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
77
|
+
running = host => host.session && (host.session.id.startsWith(session) || hostSessionFile(host) === file);
|
|
78
|
+
} else if (sessionId && parsed.fork === undefined) {
|
|
79
|
+
// With `--fork`, the ID names a new Session.
|
|
80
|
+
const file = await sessionById(sessionId);
|
|
81
|
+
// A Session without messages has no file yet; its host reports where it will be.
|
|
82
|
+
const directory = await sessionDirOption() ?? defaultSessionDir(process.cwd(), getAgentDir());
|
|
83
|
+
running = host => host.session && (file
|
|
84
|
+
? hostSessionFile(host) === file
|
|
85
|
+
: host.session.id === sessionId && dirname(hostSessionFile(host)) === directory && host.session.cwd === process.cwd());
|
|
86
|
+
} else if (parsed.continue) {
|
|
73
87
|
const file = await recentSession();
|
|
74
|
-
|
|
88
|
+
if (file) running = host => hostSessionFile(host) === file;
|
|
75
89
|
}
|
|
90
|
+
return running && (await settledHosts(paths, running)).find(running);
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
/** The Session `-c` continues, chosen by Pi's own `continueRecent` rules. */
|
|
79
94
|
async function recentSession() {
|
|
80
|
-
const { findMostRecentSession } = await
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const fallback = defaultSessionDir(process.cwd());
|
|
95
|
+
const { findMostRecentSession } = await dist('core/session-manager.js');
|
|
96
|
+
const configured = await sessionDirOption();
|
|
97
|
+
const fallback = defaultSessionDir(process.cwd(), getAgentDir());
|
|
84
98
|
const directory = configured ?? fallback;
|
|
85
99
|
// A configured directory may hold other cwds' Sessions.
|
|
86
100
|
return findMostRecentSession(directory, directory === fallback ? undefined : process.cwd()) ?? undefined;
|
|
87
101
|
}
|
|
88
102
|
|
|
103
|
+
/** The Session `--session-id` would open, found by Pi's own rules, if it exists. */
|
|
104
|
+
async function sessionById(id) {
|
|
105
|
+
const { SessionManager } = await dist('core/session-manager.js');
|
|
106
|
+
return SessionManager.findById(process.cwd(), id, await sessionDirOption());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The session directory configured for Pi in this cwd, as Pi resolves it: `--session-dir`, its environment variable, then its settings. */
|
|
110
|
+
async function sessionDirOption() {
|
|
111
|
+
const configured = parsed.sessionDir || process.env[ENV_SESSION_DIR] || (await dist('core/settings-manager.js')).SettingsManager.create(process.cwd(), getAgentDir()).getSessionDir();
|
|
112
|
+
return configured ? resolvePath(configured) : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
async function attachCommand(query) {
|
|
90
116
|
if (!query) {
|
|
91
|
-
process.stderr.write('Usage: pd attach <session
|
|
117
|
+
process.stderr.write(`${error('Usage: pd attach <session>')}\n`);
|
|
92
118
|
process.exit(2);
|
|
93
119
|
}
|
|
94
|
-
const
|
|
95
|
-
const hosted =
|
|
120
|
+
const wanted = host => host.session && (host.session.id.startsWith(query) || host.session.name === query);
|
|
121
|
+
const hosted = (await settledHosts(paths, wanted)).filter(wanted);
|
|
96
122
|
if (hosted.length === 1) return attachAndReport(hosted[0].socket);
|
|
97
123
|
let roots = [];
|
|
98
124
|
if (!hosted.length) {
|
|
@@ -104,8 +130,8 @@ async function attachCommand(query) {
|
|
|
104
130
|
const matches = hosted.length ? hosted.map(host => host.session) : roots;
|
|
105
131
|
if (matches.length !== 1) {
|
|
106
132
|
process.stderr.write(matches.length
|
|
107
|
-
? `"${query}" matches several Sessions
|
|
108
|
-
: `No Session matches "${query}"
|
|
133
|
+
? `${error(`"${query}" matches several Sessions:`)}\n${matches.map(match => ` ${match.id} ${match.name ?? ''}`).join('\n')}\n`
|
|
134
|
+
: `${error(`No Session matches "${query}".`)}\n`);
|
|
109
135
|
process.exit(1);
|
|
110
136
|
}
|
|
111
137
|
const [root] = roots;
|
|
@@ -116,6 +142,18 @@ async function attachCommand(query) {
|
|
|
116
142
|
return attachAndReport(socket);
|
|
117
143
|
}
|
|
118
144
|
|
|
145
|
+
/** Warns about the options a running Session does not apply. */
|
|
146
|
+
function warnIgnored(options) {
|
|
147
|
+
const ignored = argGroups(parseArgs, options).filter(([option]) => !['--session', '--session-id', '--session-dir', '-c', '--continue'].includes(option)).flat();
|
|
148
|
+
if (ignored.length) process.stderr.write(`${styleText('yellow', `The Session is already running; using it without applying: ${ignored.join(' ')}`, { stream: process.stderr })}\n`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Pi's mode for `args` when it is not interactive: 'text', 'json', or 'rpc'. */
|
|
152
|
+
function headlessMode() {
|
|
153
|
+
if (parsed.mode === 'json' || parsed.mode === 'rpc') return parsed.mode;
|
|
154
|
+
if (!interactive || parsed.print) return 'text';
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
120
158
|
if (args[0] === 'attach' && interactive) await attachCommand(args[1]);
|
|
121
159
|
else if (args[0] === 'agents' && interactive) {
|
|
@@ -123,17 +161,24 @@ else if (args[0] === 'agents' && interactive) {
|
|
|
123
161
|
await workbench({ pi: real, paths, sessionDir: sessionDir() });
|
|
124
162
|
process.exit(0);
|
|
125
163
|
}
|
|
126
|
-
else if (
|
|
164
|
+
else if (COMMANDS.has(args[0]) || parsed.help || parsed.version || parsed.export !== undefined || parsed.listModels !== undefined || parsed.noSession) {
|
|
165
|
+
runDirectly();
|
|
166
|
+
}
|
|
167
|
+
else if (headlessMode()) {
|
|
168
|
+
const { runHeadless } = await import('../src/host/rpc-client.mjs');
|
|
169
|
+
await runHeadless({ pi: real, paths, args, mode: headlessMode(), findRunning: runningTarget, warnIgnored });
|
|
170
|
+
}
|
|
127
171
|
else {
|
|
128
|
-
const running = await runningTarget(
|
|
172
|
+
const running = await runningTarget();
|
|
173
|
+
let send;
|
|
129
174
|
if (running) {
|
|
130
|
-
// Pi already runs this Session
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
175
|
+
// Pi already runs this Session: its messages reach it as follow-ups once attached, as Pi sends them once its TUI starts,
|
|
176
|
+
// and options that would start it differently do not apply.
|
|
177
|
+
const { startupMessages } = await import('../src/host/rpc-client.mjs');
|
|
178
|
+
send = await startupMessages({ pi: real, paths, args, running, warnIgnored });
|
|
134
179
|
}
|
|
135
180
|
const socket = running?.socket ?? await startHost(paths, {
|
|
136
181
|
pi: real, args, cwd: process.cwd(), cols: process.stdout.columns, rows: process.stdout.rows, waitForTerminal: true,
|
|
137
182
|
});
|
|
138
|
-
await attachAndReport(socket);
|
|
183
|
+
await attachAndReport(socket, send);
|
|
139
184
|
}
|
package/docs/HOSTS.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Hosted Sessions
|
|
2
|
+
|
|
3
|
+
## 1. Overview
|
|
4
|
+
|
|
5
|
+
`pd` runs each Pi Session in a host of its own, so the Session keeps running after its terminal closes, and terminals, scripts, and other programs can use it at once. The workbench, opened with `pd agents`, shows the user's root Sessions in one terminal view: from it the user opens a Session, reads its latest result, or sends it a message.
|
|
6
|
+
|
|
7
|
+
Three kinds of process take part:
|
|
8
|
+
|
|
9
|
+
- **Host**: one per Session. It runs Pi's full TUI in a pseudo-terminal and relays it to whichever terminal is attached.
|
|
10
|
+
- **Daemon**: one per user. It tracks Sessions, delivers messages, and starts hosts.
|
|
11
|
+
- **Workbench**: the daemon's terminal view, opened with `pd agents`.
|
|
12
|
+
|
|
13
|
+
Every running root Session reports its state to the daemon. The daemon starts when a Session or the workbench first needs it, and exits soon after its last connection closes.
|
|
14
|
+
|
|
15
|
+
Files live in `${XDG_STATE_HOME:-~/.local/state}/pi/agents/`:
|
|
16
|
+
|
|
17
|
+
- `workbench.json`: seen marks and which Sessions are listed.
|
|
18
|
+
- `host.log`, `daemon.log`: logs.
|
|
19
|
+
|
|
20
|
+
Sockets and Session locks go in `$XDG_RUNTIME_DIR/pi-agents/`. macOS has no `XDG_RUNTIME_DIR`, so there they go in `runtime/` under the state directory.
|
|
21
|
+
|
|
22
|
+
## 2. Setup
|
|
23
|
+
|
|
24
|
+
Install with npm to get the `pd` command, also named `pi-agents`, then have Pi load the extension from the same copy, so the command and the extension are always the same version:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install -g @tshu-w/pi-agents
|
|
28
|
+
pi install "$(npm root -g)/@tshu-w/pi-agents"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`pd` runs whichever `pi` is on `PATH`. Sessions started with plain `pi` also load the extension, but run outside hosts and end with their terminal.
|
|
32
|
+
|
|
33
|
+
## 3. Commands
|
|
34
|
+
|
|
35
|
+
- `pd [pi options]` starts Pi interactively in a new host and attaches the terminal. If the Session is already running in a host, `pd` attaches to it instead, sending it the messages as follow-ups and ignoring other options.
|
|
36
|
+
- `pd agents` opens the workbench.
|
|
37
|
+
- `pd attach <session>` attaches to a Session, starting it first if it is not running.
|
|
38
|
+
- Non-interactive starts (`-p`, `--mode json`, `--mode rpc`, piped stdin) work as in Pi, on the Session in a host, so several of them and an attached terminal can use one Session at once. Their commands act on that Session, as `abort` stops whichever turn is running. Unlike Pi:
|
|
39
|
+
- Dialogs are not sent to them. A dialog waits while a terminal is attached and is cancelled otherwise.
|
|
40
|
+
- Extensions see interactive mode, so what an extension writes to stdout does not reach them; `/program` prints as with `pi -p`.
|
|
41
|
+
- `new_session`, `switch_session`, `fork`, and `clone` move only the caller to the other Session.
|
|
42
|
+
- `-p` and `--mode json` wait until the Session is idle, then run their messages back to back. Their output covers their own turns, including input other clients add to them.
|
|
43
|
+
- `SIGINT`, `SIGTERM`, or `SIGHUP` (as from Ctrl+C, `kill`, or a closed terminal) makes `-p` and `--mode json` withdraw their prompts that have not started and stop their running turn, including input other clients added to it. Killed with `SIGKILL`, they leave their turn running.
|
|
44
|
+
- An RPC client that ends leaves its turn running. One whose Session stops opens it again; requests awaiting a response then fail.
|
|
45
|
+
- `--no-session` starts and package commands (`pd install`, `pd update`, ...) run Pi directly.
|
|
46
|
+
|
|
47
|
+
A host keeps the options and environment of the process that started it, as Pi reads them only at startup.
|
|
48
|
+
|
|
49
|
+
A Session running outside a host still appears in the workbench, but can be attached only after it exits.
|
|
50
|
+
|
|
51
|
+
## 4. Attach and detach
|
|
52
|
+
|
|
53
|
+
Only one terminal is attached at a time. A new terminal takes over, and the previous one is told how to reattach.
|
|
54
|
+
|
|
55
|
+
On attach, the host restores Pi's screen and the terminal modes Pi has set. Pi detects the colors and keyboard protocol of the first terminal that attaches, as it would without a host.
|
|
56
|
+
|
|
57
|
+
Closing the window, pressing `Ctrl+Z` (`pi-agents.detach`), or running `/detach` detaches. The Session keeps running, and the terminal prints:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
Pi keeps running. To reattach: pd attach <session>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A host disables Pi's suspend.
|
|
64
|
+
|
|
65
|
+
A detached Session exits after 10 minutes with no live work in its tree and no `pd --mode rpc` connected. A message to a Session that is not running starts it in a host, detached.
|
|
66
|
+
|
|
67
|
+
## 5. Workbench
|
|
68
|
+
|
|
69
|
+
The list shows running Sessions, Sessions that ran without a terminal (started here, woken by a message, or detached), Sessions opened here, and Sessions with an unseen turn. Older Sessions are reached with `/resume`.
|
|
70
|
+
|
|
71
|
+
Each row shows a root Session's name and what it did last. The name is the Session name, or else the first line of its first message. While the Session works, the row shows the running tool; otherwise, the start of its last reply. Running owned Agents and Programs are counted, as in `3 agents running`.
|
|
72
|
+
|
|
73
|
+
Rows are grouped by state, newest first in each group:
|
|
74
|
+
|
|
75
|
+
- **Needs attention**: a dialog waiting for the user, or a finished turn the user has not seen. A turn that failed after Pi's retries, or a host that exited abnormally, is marked failed.
|
|
76
|
+
- **Working**: the Session or its tree has live work.
|
|
77
|
+
- **Completed**: everything else.
|
|
78
|
+
|
|
79
|
+
While a Session works, its icon spins. Otherwise the shape tells whether Pi is still running (`●`) or has exited (`○`), and the color marks a finished (green) or failed (red) turn, or a dialog waiting for the user (yellow). The header shows Pi's logo, the size of each group, and key hints.
|
|
80
|
+
|
|
81
|
+
A turn counts as seen once it has been shown in an attached terminal or a preview. Sessions from before the workbench start out seen. A Session that holds its lock but is not reporting to the daemon, as while its Pi exits, shows as unknown (`?`).
|
|
82
|
+
|
|
83
|
+
Keys:
|
|
84
|
+
|
|
85
|
+
- `Enter` (`tui.select.confirm`) attaches; detaching returns here.
|
|
86
|
+
- `Space` previews the selected Session below the list: the conversation since the last compaction, with thinking left out, and each run of tool calls, branch summary, and custom message folded to one line, such as `... (6 tool calls: bash, read, edit)`. The preview follows the Session as it works. The input keeps `@<session>` at its start, so a message goes to the previewed Session. In the preview:
|
|
87
|
+
- With no message typed, `Enter` attaches, as `Ctrl+O` (`app.tools.expand`) always does.
|
|
88
|
+
- It scrolls as fullscreen mode does; with no message typed, `↑`/`↓` (`tui.select.up`/`tui.select.down`) scroll too.
|
|
89
|
+
- `Esc` (`tui.select.cancel`) clears a typed message; with none typed, it or `Space` closes the preview, as `Ctrl+Z` always does.
|
|
90
|
+
- `Ctrl+X` stops the current turn, as `app.interrupt` does.
|
|
91
|
+
- `Ctrl+D` (`app.session.delete`) removes the selected Session from the list. Its file stays, and `/resume` brings it back. An idle Session in a host that no terminal is attached to quits first; any other running Session must quit itself.
|
|
92
|
+
- `Ctrl+Z` (`pi-agents.detach`) exits the workbench, as detaching leaves a Session; every Session keeps running.
|
|
93
|
+
|
|
94
|
+
The input at the bottom:
|
|
95
|
+
|
|
96
|
+
- `@` on an empty input opens Pi's session picker. The chosen Session is selected in the list, and the input starts with `@<session>`; the text after it goes to that Session as a user message.
|
|
97
|
+
- `/resume` opens Pi's session picker over all directories, newest first, and attaches the chosen Session.
|
|
98
|
+
- `/quit` exits the workbench, as do `Esc` and `Ctrl+C` (`app.clear`) twice on an empty input.
|
|
99
|
+
- Any other text starts a new Session in the current directory with Pi's default settings, detached.
|
|
100
|
+
|
|
101
|
+
## 6. Upgrades
|
|
102
|
+
|
|
103
|
+
A host keeps running the version it started with until its Session exits. When the daemon sees a newer pi-agents version, it restarts once idle, and Sessions reconnect to the new daemon.
|
|
104
|
+
|
|
105
|
+
## 7. Platform
|
|
106
|
+
|
|
107
|
+
macOS and Linux.
|
|
108
|
+
|
|
109
|
+
## Not in this version
|
|
110
|
+
|
|
111
|
+
Attaching several terminals to one Session, Sessions on other machines, model-written summaries, and a confirmation before quitting while work is running. Keeping Sessions alive across host crashes and reboots needs a durable runtime, which [Pi Durable](https://earendil.com/posts/pi-durable/) explores.
|