@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 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/c1d48bc4-3b36-438e-976b-f76ae586ffdd
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. `/agents` opens `review-application` to watch it work; `Ctrl+C` returns.
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
- - **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/).
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
- - **Workbench**: All your Sessions in one view; keep them running after the terminal closes.
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
- | `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 |
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
- Concurrency limits per root, and extensions loaded by child Agents.
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
- ## Docs
72
+ ## Limitations
71
73
 
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
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; programmatic delegation kept accuracy with 20%+ fewer tokens.
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
- 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;
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
- } catch (error) {
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
- reserve(paths.locks, root.sessionFile, id).release();
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
- child = pty.spawn(config.pi, config.args, { name: env.TERM || 'xterm-256color', cols, rows, cwd: config.cwd, env });
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 interactive Pi in a host and attaches this terminal; everything else
3
- // runs Pi directly.
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 { attach, listHosts, startHost } from '../src/host/client.mjs';
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.\n');
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(['-p', '--print', '--mode', '-h', '--help', '-v', '--version', '--export', '--list-models', '--no-session']);
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
- async function attachAndReport(socket) {
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
- const result = await attach(socket, { isDetach: detachMatcher(tui, detachKey(agentDir())) });
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(`Pi keeps running. To reattach, run pd attach ${id ?? '<session>'}\n`);
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` or `-c` would open. */
66
- async function runningTarget(hosts) {
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
- return hosts.find(host => host.session && (host.session.id.startsWith(session) || host.session.file === file));
71
- }
72
- if (args.includes('-c') || args.includes('--continue')) {
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
- return file && hosts.find(host => host.session?.file === file);
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 flag = option('--session-dir');
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>\n');
120
+ process.stderr.write(`${error('Usage: pd attach <session>')}\n`);
92
121
  process.exit(2);
93
122
  }
94
- const hosts = await listHosts(paths);
95
- const hosted = hosts.filter(host => host.session && (host.session.id.startsWith(query) || host.session.name === query));
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:\n${matches.map(match => ` ${match.id} ${match.name ?? ''}`).join('\n')}\n`
108
- : `No Session matches "${query}".\n`);
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 (!interactive || COMMANDS.has(args[0]) || args.some(arg => DIRECT_FLAGS.has(arg.split('=')[0]))) runDirectly();
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(await listHosts(paths));
175
+ const running = await runningTarget();
176
+ let send;
129
177
  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`);
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
- # Pi Agents Workbench
1
+ # Hosted Sessions
2
2
 
3
3
  ## 1. Overview
4
4
 
5
- The workbench shows the user's root Sessions in one terminal view. From it the
6
- user opens a Session, reads its latest result, or sends it a message. Sessions
7
- started with `pd` keep running after their terminal closes.
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 30 seconds after its
19
- last connection closes.
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. The file is
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) and package commands
51
- (`pd install`, `pd update`, ...) run Pi directly.
52
-
53
- A host inherits the environment of the process that asked for it, because Pi
54
- reads terminal features from the environment once, at startup.
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 the screen and the terminal modes Pi has set
65
- (keyboard protocols, bracketed paste, mouse, title, program status). Images
66
- come back when the host then has Pi redraw.
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, run pd attach <session>
91
+ Pi keeps running. To reattach: pd attach <session>
78
92
 
79
- A host disables suspend, since a suspended Pi would stay stopped; `Ctrl+Z`
80
- detaches in its place.
93
+ A host disables Pi's suspend.
81
94
 
82
- A detached Session exits once its tree has had no live work for 5 minutes. A
83
- message to a Session that is not running starts it in a host, detached.
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 input**: a dialog waiting for the user, or a finished turn the user
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 (`?`); the
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
- - The page keys of fullscreen mode (`tui.altScreen.pageUp`/`pageDown`)
127
- scroll; with no message typed, `↑`/`↓` scroll too.
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. Every dependency is pure JavaScript or prebuilt: hosts use
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