@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 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,30 +1,36 @@
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, 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 { agentDir, configuredSessionDir, defaultSessionDir, resolvePath, sessionDir, statePaths } from '../src/roots/paths.mjs';
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.\n');
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 option(name, alias) {
43
- for (let i = 0; i < args.length; i++) {
44
- if (args[i] === '--') return;
45
- if (args[i] === name || args[i] === alias) return args[i + 1] ?? '';
46
- if (args[i].startsWith(`${name}=`)) return args[i].slice(name.length + 1);
47
- }
48
+ function error(text) {
49
+ return styleText('red', text, { stream: process.stderr });
48
50
  }
49
51
 
50
52
 
51
- async function attachAndReport(socket) {
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
- const result = await attach(socket, { isDetach: detachMatcher(tui, detachKey(agentDir())) });
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(`Pi keeps running. To reattach, run pd attach ${id ?? '<session>'}\n`);
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` or `-c` would open. */
66
- async function runningTarget(hosts) {
67
- const session = option('--session');
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
- return hosts.find(host => host.session && (host.session.id.startsWith(session) || host.session.file === file));
71
- }
72
- if (args.includes('-c') || args.includes('--continue')) {
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
- return file && hosts.find(host => host.session?.file === file);
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 import(pathToFileURL(join(piPackage(real), 'dist/core/session-manager.js')).href);
81
- const flag = option('--session-dir');
82
- const configured = flag ? resolvePath(flag) : configuredSessionDir();
83
- const fallback = defaultSessionDir(process.cwd());
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>\n');
117
+ process.stderr.write(`${error('Usage: pd attach <session>')}\n`);
92
118
  process.exit(2);
93
119
  }
94
- const hosts = await listHosts(paths);
95
- const hosted = hosts.filter(host => host.session && (host.session.id.startsWith(query) || host.session.name === query));
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:\n${matches.map(match => ` ${match.id} ${match.name ?? ''}`).join('\n')}\n`
108
- : `No Session matches "${query}".\n`);
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 (!interactive || COMMANDS.has(args[0]) || args.some(arg => DIRECT_FLAGS.has(arg.split('=')[0]))) runDirectly();
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(await listHosts(paths));
172
+ const running = await runningTarget();
173
+ let send;
129
174
  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`);
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.