@tshu-w/pi-agents 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +48 -36
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +7 -5
- package/src/agents/agents.ts +103 -30
- package/src/agents/registry.ts +30 -3
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +10 -5
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +24 -4
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +360 -0
- package/src/index.ts +36 -14
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/programs.ts +78 -11
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +11 -4
- package/src/programs/tool.ts +172 -16
- package/src/roots/guard.mjs +8 -2
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +46 -0
- package/src/ui/viewer.ts +119 -11
- package/src/workbench.mjs +48 -20
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,12 +1,13 @@
|
|
|
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, 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
13
|
import { agentDir, configuredSessionDir, defaultSessionDir, resolvePath, sessionDir, statePaths } from '../src/roots/paths.mjs';
|
|
@@ -17,14 +18,14 @@ const real = process.env.PATH.split(':').map(dir => join(dir, 'pi')).find(file =
|
|
|
17
18
|
try { accessSync(file, constants.X_OK); return true; } catch { return false; }
|
|
18
19
|
});
|
|
19
20
|
if (!real) {
|
|
20
|
-
process.stderr.write('pd needs Pi: install it so that pi is on PATH
|
|
21
|
+
process.stderr.write(`${error('pd needs Pi: install it so that pi is on PATH.')}\n`);
|
|
21
22
|
process.exit(1);
|
|
22
23
|
}
|
|
23
24
|
const args = process.argv.slice(2);
|
|
24
25
|
const paths = statePaths();
|
|
25
26
|
|
|
26
27
|
const COMMANDS = new Set(['install', 'remove', 'uninstall', 'update', 'list', 'config', 'auth', 'mcp']);
|
|
27
|
-
const DIRECT_FLAGS = new Set(['-
|
|
28
|
+
const DIRECT_FLAGS = new Set(['-h', '--help', '-v', '--version', '--export', '--list-models', '--no-session']);
|
|
28
29
|
|
|
29
30
|
function runDirectly() {
|
|
30
31
|
const child = spawn(real, args, { stdio: 'inherit' });
|
|
@@ -39,6 +40,10 @@ function runDirectly() {
|
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function error(text) {
|
|
44
|
+
return styleText('red', text, { stream: process.stderr });
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
function option(name, alias) {
|
|
43
48
|
for (let i = 0; i < args.length; i++) {
|
|
44
49
|
if (args[i] === '--') return;
|
|
@@ -48,51 +53,75 @@ function option(name, alias) {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
/** Attaches the terminal to the host at `socket`; `send`, if given, sends messages once attached, and its error is reported once detached. */
|
|
57
|
+
async function attachAndReport(socket, send) {
|
|
52
58
|
const tui = await import(pathToFileURL(createRequire(join(piPackage(real), 'package.json')).resolve('@earendil-works/pi-tui')).href);
|
|
53
|
-
|
|
59
|
+
let sent;
|
|
60
|
+
const result = await attach(socket, { isDetach: detachMatcher(tui, detachKey(agentDir())), onAttach: () => { sent = send?.(); } });
|
|
61
|
+
const failure = await sent;
|
|
62
|
+
if (failure) process.stderr.write(`${error(failure)}\n`);
|
|
54
63
|
const id = result.session?.id;
|
|
55
|
-
if (result.reason === 'exited') process.exit(result.exitCode ?? 0);
|
|
64
|
+
if (result.reason === 'exited') process.exit(failure ? 1 : result.exitCode ?? 0);
|
|
56
65
|
if (result.reason === 'closed') {
|
|
57
|
-
process.stderr.write(`The host stopped unexpectedly. See ${paths.hostLog}\n`);
|
|
66
|
+
process.stderr.write(`${error(`The host stopped unexpectedly. See ${paths.hostLog}`)}\n`);
|
|
58
67
|
process.exit(1);
|
|
59
68
|
}
|
|
60
69
|
if (result.reason === 'takeover') process.stdout.write('Attached in another terminal.\n');
|
|
61
|
-
process.stdout.write(
|
|
62
|
-
process.exit(0);
|
|
70
|
+
process.stdout.write(`${styleText('dim', 'Pi keeps running. To reattach:')} pd attach ${id ?? '<session>'}\n`);
|
|
71
|
+
process.exit(failure ? 1 : 0);
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
/** The running host already holding the Session that `--session
|
|
66
|
-
async function runningTarget(
|
|
74
|
+
/** The running host already holding the Session that `--session`, `--session-id`, or `-c` would open. */
|
|
75
|
+
async function runningTarget() {
|
|
76
|
+
let running;
|
|
67
77
|
const session = option('--session');
|
|
78
|
+
const sessionId = option('--session-id');
|
|
68
79
|
if (session) {
|
|
69
80
|
const file = resolve(session);
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
81
|
+
running = host => host.session && (host.session.id.startsWith(session) || host.session.file === file);
|
|
82
|
+
} else if (sessionId && option('--fork') === undefined) {
|
|
83
|
+
// With `--fork`, the ID names a new Session.
|
|
84
|
+
const file = await sessionById(sessionId);
|
|
85
|
+
// A Session without messages has no file yet; its host reports where it will be.
|
|
86
|
+
const directory = sessionDirOption() ?? defaultSessionDir(process.cwd());
|
|
87
|
+
running = host => host.session && (file
|
|
88
|
+
? host.session.file === file
|
|
89
|
+
: host.session.id === sessionId && dirname(host.session.file) === directory && host.session.cwd === process.cwd());
|
|
90
|
+
} else if (args.includes('-c') || args.includes('--continue')) {
|
|
73
91
|
const file = await recentSession();
|
|
74
|
-
|
|
92
|
+
if (file) running = host => host.session?.file === file;
|
|
75
93
|
}
|
|
94
|
+
return running && (await settledHosts(paths, running)).find(running);
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
/** The Session `-c` continues, chosen by Pi's own `continueRecent` rules. */
|
|
79
98
|
async function recentSession() {
|
|
80
99
|
const { findMostRecentSession } = await import(pathToFileURL(join(piPackage(real), 'dist/core/session-manager.js')).href);
|
|
81
|
-
const
|
|
82
|
-
const configured = flag ? resolvePath(flag) : configuredSessionDir();
|
|
100
|
+
const configured = sessionDirOption();
|
|
83
101
|
const fallback = defaultSessionDir(process.cwd());
|
|
84
102
|
const directory = configured ?? fallback;
|
|
85
103
|
// A configured directory may hold other cwds' Sessions.
|
|
86
104
|
return findMostRecentSession(directory, directory === fallback ? undefined : process.cwd()) ?? undefined;
|
|
87
105
|
}
|
|
88
106
|
|
|
107
|
+
/** The Session `--session-id` would open, found by Pi's own rules, if it exists. */
|
|
108
|
+
async function sessionById(id) {
|
|
109
|
+
const { SessionManager } = await import(pathToFileURL(join(piPackage(real), 'dist/core/session-manager.js')).href);
|
|
110
|
+
return SessionManager.findById(process.cwd(), id, sessionDirOption());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sessionDirOption() {
|
|
114
|
+
const flag = option('--session-dir');
|
|
115
|
+
return flag ? resolvePath(flag) : configuredSessionDir();
|
|
116
|
+
}
|
|
117
|
+
|
|
89
118
|
async function attachCommand(query) {
|
|
90
119
|
if (!query) {
|
|
91
|
-
process.stderr.write('Usage: pd attach <session
|
|
120
|
+
process.stderr.write(`${error('Usage: pd attach <session>')}\n`);
|
|
92
121
|
process.exit(2);
|
|
93
122
|
}
|
|
94
|
-
const
|
|
95
|
-
const hosted =
|
|
123
|
+
const wanted = host => host.session && (host.session.id.startsWith(query) || host.session.name === query);
|
|
124
|
+
const hosted = (await settledHosts(paths, wanted)).filter(wanted);
|
|
96
125
|
if (hosted.length === 1) return attachAndReport(hosted[0].socket);
|
|
97
126
|
let roots = [];
|
|
98
127
|
if (!hosted.length) {
|
|
@@ -104,8 +133,8 @@ async function attachCommand(query) {
|
|
|
104
133
|
const matches = hosted.length ? hosted.map(host => host.session) : roots;
|
|
105
134
|
if (matches.length !== 1) {
|
|
106
135
|
process.stderr.write(matches.length
|
|
107
|
-
? `"${query}" matches several Sessions
|
|
108
|
-
: `No Session matches "${query}"
|
|
136
|
+
? `${error(`"${query}" matches several Sessions:`)}\n${matches.map(match => ` ${match.id} ${match.name ?? ''}`).join('\n')}\n`
|
|
137
|
+
: `${error(`No Session matches "${query}".`)}\n`);
|
|
109
138
|
process.exit(1);
|
|
110
139
|
}
|
|
111
140
|
const [root] = roots;
|
|
@@ -116,6 +145,20 @@ async function attachCommand(query) {
|
|
|
116
145
|
return attachAndReport(socket);
|
|
117
146
|
}
|
|
118
147
|
|
|
148
|
+
/** Warns about the options a running Session does not apply. */
|
|
149
|
+
function warnIgnored(options) {
|
|
150
|
+
const ignored = options.filter((arg, i) => !['--session', '--session-id', '--session-dir', '-c', '--continue'].includes(arg)
|
|
151
|
+
&& !arg.startsWith('--session=') && !arg.startsWith('--session-id=') && !['--session', '--session-id', '--session-dir'].includes(options[i - 1]));
|
|
152
|
+
if (ignored.length) process.stderr.write(`${styleText('yellow', `The Session is already running; using it without applying: ${ignored.join(' ')}`, { stream: process.stderr })}\n`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Pi's mode for `args` when it is not interactive: 'text', 'json', or 'rpc'. */
|
|
156
|
+
function headlessMode() {
|
|
157
|
+
const mode = option('--mode');
|
|
158
|
+
if (mode === 'json' || mode === 'rpc') return mode;
|
|
159
|
+
if (!interactive || args.includes('-p') || args.includes('--print')) return 'text';
|
|
160
|
+
}
|
|
161
|
+
|
|
119
162
|
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
120
163
|
if (args[0] === 'attach' && interactive) await attachCommand(args[1]);
|
|
121
164
|
else if (args[0] === 'agents' && interactive) {
|
|
@@ -123,17 +166,22 @@ else if (args[0] === 'agents' && interactive) {
|
|
|
123
166
|
await workbench({ pi: real, paths, sessionDir: sessionDir() });
|
|
124
167
|
process.exit(0);
|
|
125
168
|
}
|
|
126
|
-
else if (
|
|
169
|
+
else if (COMMANDS.has(args[0]) || args.some(arg => DIRECT_FLAGS.has(arg.split('=')[0]))) runDirectly();
|
|
170
|
+
else if (headlessMode()) {
|
|
171
|
+
const { runHeadless } = await import('../src/host/rpc-client.mjs');
|
|
172
|
+
await runHeadless({ pi: real, paths, args, mode: headlessMode(), findRunning: runningTarget, warnIgnored });
|
|
173
|
+
}
|
|
127
174
|
else {
|
|
128
|
-
const running = await runningTarget(
|
|
175
|
+
const running = await runningTarget();
|
|
176
|
+
let send;
|
|
129
177
|
if (running) {
|
|
130
|
-
// Pi already runs this Session
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
178
|
+
// Pi already runs this Session: its messages reach it as follow-ups once attached, as Pi sends them once its TUI starts,
|
|
179
|
+
// and options that would start it differently do not apply.
|
|
180
|
+
const { startupMessages } = await import('../src/host/rpc-client.mjs');
|
|
181
|
+
send = await startupMessages({ pi: real, paths, args, running, warnIgnored });
|
|
134
182
|
}
|
|
135
183
|
const socket = running?.socket ?? await startHost(paths, {
|
|
136
184
|
pi: real, args, cwd: process.cwd(), cols: process.stdout.columns, rows: process.stdout.rows, waitForTerminal: true,
|
|
137
185
|
});
|
|
138
|
-
await attachAndReport(socket);
|
|
186
|
+
await attachAndReport(socket, send);
|
|
139
187
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Hosted Sessions
|
|
2
2
|
|
|
3
3
|
## 1. Overview
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
`pd` runs each Pi Session in a host of its own, so the Session keeps running
|
|
6
|
+
after its terminal closes, and terminals, scripts, and other programs can use
|
|
7
|
+
it at once. The workbench, opened with `pd agents`, shows the user's root
|
|
8
|
+
Sessions in one terminal view: from it the user opens a Session, reads its
|
|
9
|
+
latest result, or sends it a message.
|
|
8
10
|
|
|
9
11
|
Three kinds of process take part:
|
|
10
12
|
|
|
@@ -15,13 +17,12 @@ Three kinds of process take part:
|
|
|
15
17
|
- **Workbench**: the daemon's terminal view, opened with `pd agents`.
|
|
16
18
|
|
|
17
19
|
Every running root Session reports its state to the daemon. The daemon starts
|
|
18
|
-
when a Session or the workbench first needs it, and exits
|
|
19
|
-
|
|
20
|
+
when a Session or the workbench first needs it, and exits soon after its last
|
|
21
|
+
connection closes.
|
|
20
22
|
|
|
21
23
|
Files live in `${XDG_STATE_HOME:-~/.local/state}/pi/agents/`:
|
|
22
24
|
|
|
23
|
-
- `workbench.json`: seen marks and which Sessions are listed.
|
|
24
|
-
versioned.
|
|
25
|
+
- `workbench.json`: seen marks and which Sessions are listed.
|
|
25
26
|
- `host.log`, `daemon.log`: logs.
|
|
26
27
|
|
|
27
28
|
Sockets and Session locks go in `$XDG_RUNTIME_DIR/pi-agents/`. macOS has no
|
|
@@ -43,15 +44,34 @@ load the extension, but run outside hosts and end with their terminal.
|
|
|
43
44
|
|
|
44
45
|
- `pd [pi options]` starts Pi interactively in a new host and attaches the
|
|
45
46
|
terminal. If the Session is already running in a host, `pd` attaches to it
|
|
46
|
-
instead.
|
|
47
|
+
instead, sending it the messages as follow-ups and ignoring other options.
|
|
47
48
|
- `pd agents` opens the workbench.
|
|
48
49
|
- `pd attach <session>` attaches to a Session, starting it first if it is not
|
|
49
50
|
running.
|
|
50
|
-
- Non-interactive starts (`-p`, `--mode`, piped stdin)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
- Non-interactive starts (`-p`, `--mode json`, `--mode rpc`, piped stdin) work
|
|
52
|
+
as in Pi, on the Session in a host, so several of them and an attached
|
|
53
|
+
terminal can use one Session at once. Their commands act on that Session,
|
|
54
|
+
as `abort` stops whichever turn is running. Unlike Pi:
|
|
55
|
+
- Dialogs are not sent to them. A dialog waits while a terminal is attached
|
|
56
|
+
and is cancelled otherwise.
|
|
57
|
+
- Extensions see interactive mode, so what an extension writes to stdout
|
|
58
|
+
does not reach them; `/program` prints as with `pi -p`.
|
|
59
|
+
- `new_session`, `switch_session`, `fork`, and `clone` move only the caller
|
|
60
|
+
to the other Session.
|
|
61
|
+
- `-p` and `--mode json` wait until the Session is idle, then run their
|
|
62
|
+
messages back to back. Their output covers their own turns, including
|
|
63
|
+
input other clients add to them.
|
|
64
|
+
- `SIGINT`, `SIGTERM`, or `SIGHUP` (as from Ctrl+C, `kill`, or a closed
|
|
65
|
+
terminal) makes `-p` and `--mode json` withdraw their prompts that have
|
|
66
|
+
not started and stop their running turn, including input other clients
|
|
67
|
+
added to it. Killed with `SIGKILL`, they leave their turn running.
|
|
68
|
+
- An RPC client that ends leaves its turn running. One whose Session stops
|
|
69
|
+
opens it again; requests awaiting a response then fail.
|
|
70
|
+
- `--no-session` starts and package commands (`pd install`, `pd update`, ...)
|
|
71
|
+
run Pi directly.
|
|
72
|
+
|
|
73
|
+
A host keeps the options and environment of the process that started it, as
|
|
74
|
+
Pi reads them only at startup.
|
|
55
75
|
|
|
56
76
|
A Session running outside a host still appears in the workbench, but can be
|
|
57
77
|
attached only after it exits.
|
|
@@ -61,26 +81,20 @@ attached only after it exits.
|
|
|
61
81
|
Only one terminal is attached at a time. A new terminal takes over, and the
|
|
62
82
|
previous one is told how to reattach.
|
|
63
83
|
|
|
64
|
-
On attach, the host restores
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
A host started from a terminal waits for that terminal to attach before it
|
|
69
|
-
starts Pi, so Pi detects the terminal's colors and keyboard protocol just as it
|
|
70
|
-
would without a host. A host started in the background passes the queries Pi
|
|
71
|
-
sent at startup to the first terminal that attaches, and Pi finishes detecting
|
|
72
|
-
then.
|
|
84
|
+
On attach, the host restores Pi's screen and the terminal modes Pi has set.
|
|
85
|
+
Pi detects the colors and keyboard protocol of the first terminal that
|
|
86
|
+
attaches, as it would without a host.
|
|
73
87
|
|
|
74
88
|
Closing the window, pressing `Ctrl+Z` (the `pi-agents.detach` keybinding), or
|
|
75
89
|
running `/detach` detaches. The Session keeps running, and the terminal prints:
|
|
76
90
|
|
|
77
|
-
Pi keeps running. To reattach
|
|
91
|
+
Pi keeps running. To reattach: pd attach <session>
|
|
78
92
|
|
|
79
|
-
A host disables
|
|
80
|
-
detaches in its place.
|
|
93
|
+
A host disables Pi's suspend.
|
|
81
94
|
|
|
82
|
-
A detached Session exits
|
|
83
|
-
message to a Session that is not running starts it
|
|
95
|
+
A detached Session exits after 10 minutes with no live work in its tree and no
|
|
96
|
+
`pd --mode rpc` connected. A message to a Session that is not running starts it
|
|
97
|
+
in a host, detached.
|
|
84
98
|
|
|
85
99
|
## 5. Workbench
|
|
86
100
|
|
|
@@ -95,7 +109,7 @@ Running owned Agents and Programs are counted, as in `3 agents running`.
|
|
|
95
109
|
|
|
96
110
|
Rows are grouped by state, newest first in each group:
|
|
97
111
|
|
|
98
|
-
- **Needs
|
|
112
|
+
- **Needs attention**: a dialog waiting for the user, or a finished turn the user
|
|
99
113
|
has not seen. A turn that failed after Pi's retries, or a host that exited
|
|
100
114
|
abnormally, is marked failed.
|
|
101
115
|
- **Working**: the Session or its tree has live work.
|
|
@@ -109,8 +123,7 @@ shows Pi's logo, the size of each group, and key hints.
|
|
|
109
123
|
A turn counts as seen once it has been shown in an attached terminal or a
|
|
110
124
|
preview. Sessions
|
|
111
125
|
from before the workbench start out seen. A Session that holds its lock but is
|
|
112
|
-
not reporting to the daemon, as while its Pi exits, shows as unknown (`?`)
|
|
113
|
-
daemon checks it again every second until that changes.
|
|
126
|
+
not reporting to the daemon, as while its Pi exits, shows as unknown (`?`).
|
|
114
127
|
|
|
115
128
|
Keys:
|
|
116
129
|
|
|
@@ -123,9 +136,10 @@ Keys:
|
|
|
123
136
|
previewed Session. In the preview:
|
|
124
137
|
- `Enter` sends a typed message; with none typed, it attaches.
|
|
125
138
|
- `Ctrl+O` (`app.tools.expand`) attaches.
|
|
126
|
-
-
|
|
127
|
-
scroll
|
|
139
|
+
- It scrolls as fullscreen mode does; with no message typed, `↑`/`↓`
|
|
140
|
+
scroll too.
|
|
128
141
|
- `Esc` clears a typed message; with none typed, it closes the preview.
|
|
142
|
+
- `Ctrl+Z` closes the preview.
|
|
129
143
|
- `Ctrl+X` stops the current turn, as `app.interrupt` does.
|
|
130
144
|
- `Ctrl+D` removes the selected Session from the list. Its file stays, and
|
|
131
145
|
`/resume` brings it back. An idle Session in a host that no terminal is
|
|
@@ -154,9 +168,7 @@ Sessions reconnect to the new daemon.
|
|
|
154
168
|
|
|
155
169
|
## 7. Platform
|
|
156
170
|
|
|
157
|
-
macOS and Linux.
|
|
158
|
-
`@lydell/node-pty`, and a Session lock is a file that records the process
|
|
159
|
-
holding it.
|
|
171
|
+
macOS and Linux.
|
|
160
172
|
|
|
161
173
|
## Not in this version
|
|
162
174
|
|