@tshu-w/pi-agents 0.2.2 → 0.3.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
@@ -21,7 +21,7 @@ https://github.com/user-attachments/assets/52dd1de6-bf26-45ea-8775-2b1a1a187f4f
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
- - **Persistent agents**: Agents keep running after the terminal closes, with a workbench to manage them.
24
+ - **Persistent agents**: Agents keep running after the terminal closes, with a workbench to manage them and a main Agent to talk to.
25
25
 
26
26
  ## Install
27
27
 
@@ -55,6 +55,7 @@ Ask Pi in plain words; it starts Agents and Programs when the work calls for the
55
55
  | `/tasks panel` | Hides or shows the task panel |
56
56
  | `Ctrl+Z`, `/detach` | Leaves a Session running and returns to the shell |
57
57
  | `pd agents` | Opens the workbench |
58
+ | `pd main` | Opens the main Agent, which hands your work to other Agents |
58
59
  | `pd attach <session>` | Attaches to a Session, starting it if needed |
59
60
 
60
61
  ## Configuration
package/bin/pd.mjs CHANGED
@@ -2,15 +2,15 @@
2
2
  // The `pd` command: runs Pi in a host, attaching this terminal to interactive Pi and connecting
3
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
- import { accessSync, constants } from 'node:fs';
5
+ import { accessSync, constants, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
6
6
  import { createRequire } from 'node:module';
7
7
  import { dirname, join, resolve } from 'node:path';
8
- import { pathToFileURL } from 'node:url';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { styleText } from 'node:util';
10
10
  import { attach, hostSessionFile, settledHosts, startHost } from '../src/host/client.mjs';
11
11
  import { detachKey, detachMatcher } from '../src/host/keys.mjs';
12
12
  import { discoverRoots, findRoot } from '../src/roots/discovery.mjs';
13
- import { defaultSessionDir, resolvePath, sessionDir, statePaths } from '../src/roots/paths.mjs';
13
+ import { defaultSessionDir, prepareDirectory, resolvePath, sessionDir, statePaths } from '../src/roots/paths.mjs';
14
14
  import { piPackage } from '../src/pi.mjs';
15
15
  import { markedFiles } from '../src/daemon/sessions.mjs';
16
16
  import { argGroups } from '../src/host/rpc-client.mjs';
@@ -22,13 +22,13 @@ if (!real) {
22
22
  process.stderr.write(`${error('pd needs Pi: install it so that pi is on PATH.')}\n`);
23
23
  process.exit(1);
24
24
  }
25
- const args = process.argv.slice(2);
25
+ let args = process.argv.slice(2);
26
26
  const paths = statePaths();
27
27
  const dist = file => import(pathToFileURL(join(piPackage(real), 'dist', file)).href);
28
28
  // Pi's own parsing, so that `pd` reads every argument as `pi` does.
29
29
  const { parseArgs } = await dist('cli/args.js');
30
30
  const { ENV_SESSION_DIR, getAgentDir } = await dist('config.js');
31
- const parsed = parseArgs(args);
31
+ let parsed = parseArgs(args);
32
32
 
33
33
  const COMMANDS = new Set(['install', 'remove', 'uninstall', 'update', 'list', 'config', 'auth', 'mcp']);
34
34
 
@@ -154,6 +154,40 @@ function headlessMode() {
154
154
  if (!interactive || parsed.print) return 'text';
155
155
  }
156
156
 
157
+ /** Prepares the main Agent's directory, whose `AGENTS.md` links to the main Agent's instructions, and continues its Session there. */
158
+ async function openMain() {
159
+ await prepareDirectory(paths.main);
160
+ const link = join(paths.main, 'AGENTS.md');
161
+ const instructions = fileURLToPath(new URL('../src/main-agent.md', import.meta.url));
162
+ let target;
163
+ try { target = readlinkSync(link); }
164
+ catch (failure) {
165
+ // A file put in place of the link stays.
166
+ if (failure.code !== 'ENOENT' && failure.code !== 'EINVAL') throw failure;
167
+ target = failure.code === 'EINVAL' ? instructions : undefined;
168
+ }
169
+ // The link follows this copy of pi-agents; another `pd main` may be making it at the same time.
170
+ if (target !== instructions) {
171
+ try { if (target !== undefined) unlinkSync(link); }
172
+ catch (failure) { if (failure.code !== 'ENOENT') throw failure; }
173
+ try { symlinkSync(instructions, link); }
174
+ catch (failure) { if (failure.code !== 'EEXIST') throw failure; }
175
+ }
176
+ process.chdir(paths.main);
177
+ const options = args.slice(1);
178
+ // A running main Agent may have no file yet for `-c` to find.
179
+ const here = host => host.session?.cwd === process.cwd();
180
+ const running = (await settledHosts(paths, here)).filter(here);
181
+ args = running.length === 1 ? ['--session', hostSessionFile(running[0]), ...options] : ['-c', ...options];
182
+ parsed = parseArgs(args);
183
+ // A new main Agent is named, so other Agents can address it; options go first, as messages may follow `--`.
184
+ if (!running.length && parsed.name === undefined && !(await recentSession())) {
185
+ args.unshift('--name', 'main');
186
+ parsed = parseArgs(args);
187
+ }
188
+ }
189
+
190
+ if (args[0] === 'main') await openMain();
157
191
  const interactive = process.stdin.isTTY && process.stdout.isTTY;
158
192
  if (args[0] === 'attach' && interactive) await attachCommand(args[1]);
159
193
  else if (args[0] === 'agents' && interactive) {
package/docs/HOSTS.md CHANGED
@@ -16,6 +16,7 @@ Files live in `${XDG_STATE_HOME:-~/.local/state}/pi/agents/`:
16
16
 
17
17
  - `workbench.json`: seen marks and which Sessions are listed.
18
18
  - `host.log`, `daemon.log`: logs.
19
+ - `main/`: the main Agent's working directory. Its `AGENTS.md` links to the main Agent's instructions in pi-agents.
19
20
 
20
21
  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
 
@@ -35,6 +36,7 @@ pi install "$(npm root -g)/@tshu-w/pi-agents"
35
36
  - `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
37
  - `pd agents` opens the workbench.
37
38
  - `pd attach <session>` attaches to a Session, starting it first if it is not running.
39
+ - `pd main [pi options]` opens the main Agent, which hands work to other Agents: it runs `pd -c [pi options]` in `main/`, named `main`.
38
40
  - 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
41
  - Dialogs are not sent to them. A dialog waits while a terminal is attached and is cancelled otherwise.
40
42
  - Extensions see interactive mode, so what an extension writes to stdout does not reach them; `/program` prints as with `pi -p`.
package/docs/SPEC.md CHANGED
@@ -302,7 +302,7 @@ Tasks (<N> live, /tasks to view)
302
302
  +<N> more
303
303
  ```
304
304
 
305
- The live work is the owned Agents that are not `idle`, and the running background Programs with their Agents that are not `idle`. The rows list them as a tree in the order they started, each under its owner and each Program under its caller, with the owners needed to keep the tree. Rows that do not fit are counted in the last row. The time is how long the Agent's current turn or the Program has run, and the cost what the replies in that turn have cost, or for a Program, those of all the Agents under it. When a reply has no price, the cost is the input and output tokens instead, like `7.3M tok`.
305
+ The live work is the Agents under the current Agent that are not `idle`, including Programs' Agents, and the running background Programs. The rows list them as a tree in the order they started, each under its owner and each Program under its caller, with the owners needed to keep the tree. Rows that do not fit are counted in the last row. The time is how long the Agent's current turn or the Program has run, and the cost what the replies in that turn have cost, or for a Program, those of all the Agents under it, once there is any. When a reply has no price, the cost is the input and output tokens instead, like `7.3M tok`.
306
306
 
307
307
  The panel updates as states change, and its times each second. It is shown while it has a row. `/tasks panel` hides or shows it.
308
308
 
@@ -339,7 +339,7 @@ Expanding (`app.tools.expand`) shows them as Pi shows them by default.
339
339
 
340
340
  ### Task viewer
341
341
 
342
- `/tasks` lists the Agents in the current Agent's tree, including its Programs' Agents, and its background Programs, live ones first and newest first, with their state and latest activity, and opens the selected one in a viewer. An Agent not owned by the current Agent is named with its owners, as `<name> ‹ <owner> ‹ …`, where a Program is `Program <id>`. The viewer follows an Agent's conversation as Pi's chat shows it, or a Program as it shows in the foreground, while the current Session keeps running.
342
+ `/tasks` lists the Agents under the current Agent, including Programs' Agents, its background Programs, and the running ones of those Agents, live ones first and newest first, with their state and latest activity, and opens the selected one in a viewer. A foreground Program is watched in its caller's conversation. An Agent or Program not owned by the current Agent is named with its owners, as `<name> ‹ <owner> ‹ …`, where a Program is `Program <id>`. The viewer follows an Agent's conversation as Pi's chat shows it, or a Program as it shows in the foreground, while the current Session keeps running.
343
343
 
344
344
  Text entered in an Agent's viewer is the user's input to it, sent as in Pi's editor; a Program takes no input.
345
345
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tshu-w/pi-agents",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Multi-agent runtime for Pi: persistent async agents, dynamic workflows, and inter-agent messaging",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -1,6 +1,7 @@
1
1
  /** The Agents loaded in this process, shared across trees: who sees whom, how targets resolve, and how messages are addressed. */
2
2
  import type { Usage } from "@earendil-works/pi-ai";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Programs } from "../programs/programs.ts";
4
5
  import type { Agents } from "./agents.ts";
5
6
  import { TreeScheduler } from "./scheduler.ts";
6
7
 
@@ -30,6 +31,8 @@ export interface AgentNode {
30
31
  busy(): boolean;
31
32
  sessionFile(): string | undefined;
32
33
  agents: Agents;
34
+ /** An Agent's background Programs, which the task viewer lists. */
35
+ programs?: Programs;
33
36
  /** Delivers a message to this node; used only for root Agents. */
34
37
  receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender): void;
35
38
  /** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
@@ -118,19 +121,35 @@ export function programsOf(node: AgentNode): AgentNode[] {
118
121
  .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
119
122
  }
120
123
 
121
- /** The node's running owned Agents and background Programs, with those under them. */
122
- export function liveWork(node: AgentNode): number {
123
- let count = 0;
124
- for (const entry of node.agents.owned()) {
125
- if (entry.state !== "idle") count += 1;
124
+ /** An owned Agent or a Program in the user's view of a tree, with the work under it. */
125
+ export type Task = { entry: Entry; owner: AgentNode; tasks: Task[] } | { program: AgentNode; tasks: Task[] };
126
+
127
+ /**
128
+ * The work under a node as the task panel and the task viewer show it, oldest first: its owned Agents, and
129
+ * its Programs that run in the background or have Agents. A foreground Program without Agents shows only in
130
+ * its caller's conversation.
131
+ */
132
+ export function tasksOf(node: AgentNode): Task[] {
133
+ const tasks: Array<{ at: string; task: Task }> = node.agents.owned().map((entry) => {
126
134
  const loaded = shared.nodes.get(entry.id);
127
- if (loaded) count += liveWork(loaded);
128
- }
135
+ return { at: entry.createdAt ?? "", task: { entry: { ...entry, ownerId: node.id }, owner: node, tasks: loaded ? tasksOf(loaded) : [] } };
136
+ });
129
137
  for (const program of programsOf(node)) {
130
- if (program.background) count += 1;
131
- count += liveWork(program);
138
+ const under = tasksOf(program);
139
+ if (program.background || under.length > 0) tasks.push({ at: program.createdAt ?? "", task: { program, tasks: under } });
132
140
  }
133
- return count;
141
+ return tasks.sort((a, b) => a.at.localeCompare(b.at)).map(({ task }) => task);
142
+ }
143
+
144
+ /** Whether a task is live work: an Agent that is not idle, or a background Program. */
145
+ export function taskLive(task: Task): boolean {
146
+ return "program" in task ? task.program.background === true : task.entry.state !== "idle";
147
+ }
148
+
149
+ /** The node's live work, with that under it. */
150
+ export function liveWork(node: AgentNode): number {
151
+ const count = (tasks: Task[]): number => tasks.reduce((sum, task) => sum + Number(taskLive(task)) + count(task.tasks), 0);
152
+ return count(tasksOf(node));
134
153
  }
135
154
 
136
155
  /** `Program <short ID>`, unique among its caller's running Programs. */
@@ -15,6 +15,35 @@ const RESET = '\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b
15
15
  const ALT_SCREEN = /\x1b\[\?1049([hl])/g;
16
16
  // Splits terminal input into key sequences: CSI and SS3 sequences, Alt+key, or a single character.
17
17
  const KEYS = /\x1b\[[0-?]*[ -/]*[@-~]|\x1bO[\s\S]|\x1b?[\s\S]/g;
18
+ // The terminal's reply to the device status query (`\x1b[5n`), which pi-tui does not send, unlike `\x1b[c`.
19
+ const STATUS_QUERY = '\x1b[5n';
20
+ const STATUS_OK = /\x1b\[0n/;
21
+ const DRAIN_TIMEOUT_MS = 1000;
22
+
23
+ /**
24
+ * Reads and drops the input the terminal sent before it took the output written so far, such as the release of the
25
+ * detach key in Pi's keyboard mode, so the shell does not get it. Over a slow link, such as SSH, it arrives late; the
26
+ * terminal answers a query after it, or the wait ends after `DRAIN_TIMEOUT_MS`.
27
+ */
28
+ export function drain(stdin, stdout) {
29
+ return new Promise(resolve => {
30
+ let input = '';
31
+ const done = () => {
32
+ clearTimeout(timer);
33
+ stdin.off('data', onData);
34
+ resolve();
35
+ };
36
+ const onData = data => {
37
+ input += data.toString('latin1');
38
+ if (STATUS_OK.test(input)) done();
39
+ // A reply split across chunks starts in the kept tail.
40
+ else input = input.slice(-64);
41
+ };
42
+ const timer = setTimeout(done, DRAIN_TIMEOUT_MS);
43
+ stdin.on('data', onData);
44
+ stdout.write(STATUS_QUERY);
45
+ });
46
+ }
18
47
 
19
48
  export function hostStatus(path) {
20
49
  return new Promise((resolve, reject) => {
@@ -107,9 +136,10 @@ export async function startHost(paths, { pi, args, cwd, cols = 120, rows = 40, e
107
136
  * Attaches this process's terminal to a host until the host detaches it or Pi exits.
108
137
  * The detach key detaches here, before Pi sees it, so it works whatever Pi shows.
109
138
  * `onAttach` runs once the host has attached the terminal and told Pi.
139
+ * With `drainInput`, late input is dropped before returning (see `drain`); a caller that reads the terminal next turns it off.
110
140
  * Resolves to { reason: 'detach' | 'takeover' | 'exited' | 'closed', session }.
111
141
  */
112
- export function attach(socketPath, { isDetach, onAttach, stdin = process.stdin, stdout = process.stdout }) {
142
+ export function attach(socketPath, { isDetach, onAttach, drainInput = true, stdin = process.stdin, stdout = process.stdout }) {
113
143
  return new Promise((resolve, reject) => {
114
144
  const socket = net.connect(socketPath);
115
145
  const size = () => ({ cols: stdout.columns, rows: stdout.rows });
@@ -124,16 +154,19 @@ export function attach(socketPath, { isDetach, onAttach, stdin = process.stdin,
124
154
  socket.write(frame(FRAME.detached));
125
155
  };
126
156
  let result, connected = false, altScreen = false, attached = false;
127
- function finish() {
157
+ async function finish() {
128
158
  stdout.off('resize', onResize);
129
159
  stdin.off('data', onInput);
130
160
  if (connected) {
131
- stdin.setRawMode(false);
132
- stdin.pause();
133
161
  // An exiting Pi restores the terminal itself.
134
162
  // Leaving the alternate screen returns the cursor to the line below the command; otherwise it ends a line.
135
163
  // Terminals keep a keyboard mode stack per screen, so the reset comes before leaving it.
136
- if (result?.reason !== 'exited') stdout.write(altScreen ? `${RESET}\x1b[?1049l` : `${RESET}\r\n`);
164
+ // The drain comes before leaving, so the screen changes once, when it ends.
165
+ if (result?.reason !== 'exited') stdout.write(RESET);
166
+ if (drainInput) await drain(stdin, stdout);
167
+ if (result?.reason !== 'exited') stdout.write(altScreen ? '\x1b[?1049l' : '\r\n');
168
+ stdin.setRawMode(false);
169
+ stdin.pause();
137
170
  }
138
171
  resolve(result ?? { reason: 'closed' });
139
172
  }
package/src/index.ts CHANGED
@@ -170,6 +170,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
170
170
  notify: (text) => agents.notify(text),
171
171
  changed: () => panel.update(),
172
172
  });
173
+ created.programs = programs;
173
174
  programs.restore(current);
174
175
  panel.start(current);
175
176
  });
@@ -227,7 +228,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
227
228
  if (!node) return;
228
229
  browsing = true;
229
230
  try {
230
- await openTaskViewer(current, requireNode(current), programs, pi.getSettings().editorPaddingX ?? 0);
231
+ await openTaskViewer(current, requireNode(current), pi.getSettings().editorPaddingX ?? 0);
231
232
  } finally {
232
233
  browsing = false;
233
234
  }
@@ -0,0 +1,8 @@
1
+ # Main Agent
2
+
3
+ You are the user's main Agent. The user talks only to you; you hand work to other Agents and report back.
4
+
5
+ - Handle a request yourself only if it takes at most three read-only tool calls; otherwise hand it to an Agent.
6
+ - Before spawning, `list` Agents: send work in an area an Agent already handles to that Agent.
7
+ - Spawn Agents in the directory the work belongs to, or `~` if none; never in your working directory.
8
+ - Relay an Agent's questions to the user, and its results as a short conclusion.
@@ -105,9 +105,9 @@ const sandboxExtras = new AsyncLocalStorage<SandboxExtras>();
105
105
  let sandboxPatch: Promise<void> | undefined;
106
106
 
107
107
  /**
108
- * Since Pi 1.3.0, its executor admits at most two codemode workers per process, which background
109
- * Programs waiting on Agents use up. A Program's worker is admitted on its own terms instead: any
110
- * number may run, and none starts while this many are still exiting after their scripts ended.
108
+ * Since Pi 1.3.0, its executor admits a limited number of codemode workers per process (two by default),
109
+ * which background Programs waiting on Agents use up. A Program's worker is admitted on its own terms
110
+ * instead: any number may run, and none starts while this many are still exiting after their scripts ended.
111
111
  */
112
112
  const MAX_EXITING_WORKERS = 2;
113
113
  const exitingWorkers = new Set<{ exited: boolean }>();
@@ -143,6 +143,26 @@ function programWorker(codemode: Awaited<ReturnType<typeof loadCodemode>>, wasmP
143
143
  };
144
144
  }
145
145
 
146
+ const WORKER_LIMIT_ENV = "PI_CODEMODE_MAX_WORKERS";
147
+
148
+ /**
149
+ * Since Pi 1.4.0, its executor also reserves one of its worker slots before a script starts and holds
150
+ * it until the script ends, so Programs waiting on Agents make later Programs wait. Its limit is raised
151
+ * while `start` calls the executor, which reserves before its first `await`, and restored as soon as the
152
+ * call returns. The Program still holds its slot, which only Programs share unless the host runs scripts
153
+ * through the same executor, as Pi from source or an SDK host does.
154
+ */
155
+ function admitAtOnce<T>(start: () => T): T {
156
+ const limit = process.env[WORKER_LIMIT_ENV];
157
+ process.env[WORKER_LIMIT_ENV] = String(Number.MAX_SAFE_INTEGER);
158
+ try {
159
+ return start();
160
+ } finally {
161
+ if (limit === undefined) delete process.env[WORKER_LIMIT_ENV];
162
+ else process.env[WORKER_LIMIT_ENV] = limit;
163
+ }
164
+ }
165
+
146
166
  async function patchSandbox(): Promise<void> {
147
167
  const [codemode, source] = await Promise.all([loadCodemode(), loadPiWorkerSource()]);
148
168
  const wasmPath = source.getQuickJSWasmPath();
@@ -269,11 +289,11 @@ export async function executeProgram(
269
289
  };
270
290
  let result: Awaited<ReturnType<typeof executeCodemode>>;
271
291
  try {
272
- result = await withSandboxExtras(extras, () => executeCodemode(toolCallId, { code: source }, signal, onUpdate, programCtx, {
292
+ result = await withSandboxExtras(extras, () => admitAtOnce(() => executeCodemode(toolCallId, { code: source }, signal, onUpdate, programCtx, {
273
293
  models: true,
274
294
  appendEntry: options.appendEntry,
275
295
  getToolNamespace: options.getToolNamespace,
276
- }));
296
+ })));
277
297
  } finally {
278
298
  if (!scriptEnded) extras.ended();
279
299
  }
@@ -26,6 +26,7 @@ export function statePaths(env = process.env) {
26
26
  workbench: join(state, 'workbench.json'),
27
27
  hostLog: join(state, 'host.log'),
28
28
  daemonLog: join(state, 'daemon.log'),
29
+ main: join(state, 'main'),
29
30
  host(pid) { return join(runtime, `h-${pid}.sock`); },
30
31
  locks: join(runtime, 'locks'),
31
32
  daemon: join(runtime, 'daemon.sock'),
package/src/ui/panel.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, TruncatedText } from "@earendil-works/pi-tui";
3
- import { label, nodes, onTreeChange, programLabel, programsOf, type AgentNode, type Spend } from "../agents/registry.ts";
3
+ import { label, liveWork, onTreeChange, programLabel, taskLive, tasksOf, type AgentNode, type Spend, type Task } from "../agents/registry.ts";
4
4
 
5
5
  const WIDGET_KEY = "pi-agents";
6
6
 
@@ -23,53 +23,38 @@ function cost({ cost, tokens, unpriced }: Spend): string {
23
23
  return tokens < 10_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M tok` : `${Math.round(tokens / 1_000_000)}M tok`;
24
24
  }
25
25
 
26
+ /** The cost of `spent`, once there is any. */
27
+ function spend(spent: Spend | undefined): string[] {
28
+ return spent && (spent.cost > 0 || spent.tokens > 0) ? [cost(spent)] : [];
29
+ }
30
+
26
31
  /**
27
- * The task panel: the Agent's live owned Agents and running background Programs as a tree, in the
28
- * order they started, with their Programs' live Agents under them, and how long each has run and what
29
- * it has cost. Empty when none.
32
+ * The task panel: the Agent's live work as a tree, in the order it started, with how long each has run
33
+ * and what it has cost. Empty when none.
30
34
  */
31
35
  export function panelLines(self: AgentNode, now = Date.now()): string[] {
32
- const rows: Array<{ line: string; parent?: number; shown: boolean }> = [];
33
36
  const ids = self.agents.ids();
34
- let live = 0;
35
- // A live row is listed with its owners, so the tree stays readable.
36
- const show = (index: number | undefined) => {
37
- for (let row = index === undefined ? undefined : rows[index]; row && !row.shown; row = row.parent === undefined ? undefined : rows[row.parent]) row.shown = true;
38
- };
39
- const visit = (node: AgentNode, level: number, parent?: number) => {
40
- const children = [
41
- ...node.agents.owned().map((entry) => ({ at: entry.createdAt ?? "", entry })),
42
- ...programsOf(node).filter((program) => program.background).map((program) => ({ at: program.createdAt ?? "", program })),
43
- ].sort((a, b) => a.at.localeCompare(b.at));
44
- for (const child of children) {
45
- const index = rows.length;
46
- if ("program" in child) {
47
- const since = Date.parse(child.program.createdAt ?? "");
48
- const time = since ? [elapsed(since, now), ...(child.program.spent ? [cost(child.program.spent)] : [])] : [];
49
- rows.push({ line: `${" ".repeat(level)}${[programLabel(child.program), "running", ...time].join(" ")}`, parent, shown: false });
50
- live += 1;
51
- show(index);
52
- visit(child.program, level + 1, index);
53
- continue;
54
- }
55
- const { entry } = child;
56
- const own = node.agents.counts(entry.id);
57
- const pending = own?.pending ? [`${own.pending} pending`] : [];
58
- const time = own?.since ? [elapsed(own.since, now), ...(own.spent ? [cost(own.spent)] : [])] : [];
59
- rows.push({ line: `${" ".repeat(level)}Agent ${[label(entry, ids), entry.state, ...time, ...pending].join(" ")}`, parent, shown: false });
60
- if (entry.state !== "idle") {
61
- live += 1;
62
- show(index);
63
- }
64
- const loaded = nodes.get(entry.id);
65
- if (loaded) visit(loaded, level + 1, index);
37
+ const line = (task: Task): string => {
38
+ if ("program" in task) {
39
+ const since = Date.parse(task.program.createdAt ?? "");
40
+ const time = since ? [elapsed(since, now), ...spend(task.program.spent)] : [];
41
+ return [programLabel(task.program), "running", ...time].join(" ");
66
42
  }
43
+ const { entry, owner } = task;
44
+ const own = owner.agents.counts(entry.id);
45
+ const pending = own?.pending ? [`${own.pending} pending`] : [];
46
+ const time = own?.since ? [elapsed(own.since, now), ...spend(own.spent)] : [];
47
+ return `Agent ${[label(entry, ids), entry.state, ...time, ...pending].join(" ")}`;
67
48
  };
68
- visit(self, 0);
69
- const lines = rows.filter((row) => row.shown).map((row) => row.line);
49
+ // A row is listed while it is live or has live work under it, so the tree stays readable.
50
+ const rows = (tasks: Task[], level: number): string[] => tasks.flatMap((task) => {
51
+ const under = rows(task.tasks, level + 1);
52
+ return taskLive(task) || under.length > 0 ? [`${" ".repeat(level)}${line(task)}`, ...under] : [];
53
+ });
54
+ const lines = rows(tasksOf(self), 0);
70
55
  if (lines.length === 0) return [];
71
56
  if (lines.length > MAX_LINES - 1) lines.splice(MAX_LINES - 2, Infinity, `+${lines.length - (MAX_LINES - 2)} more`);
72
- return [`Tasks (${live} live, /tasks to view)`, ...lines.map((line) => ` ${line}`)];
57
+ return [`Tasks (${liveWork(self)} live, /tasks to view)`, ...lines.map((line) => ` ${line}`)];
73
58
  }
74
59
 
75
60
  /**
package/src/ui/viewer.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  getMarkdownTheme,
11
11
  getSelectListTheme,
12
12
  keyHint,
13
+ keyText,
13
14
  parseSkillBlock,
14
15
  SkillInvocationMessageComponent,
15
16
  ToolExecutionComponent,
@@ -24,7 +25,9 @@ import {
24
25
  Container,
25
26
  fuzzyFilter,
26
27
  Input,
28
+ isViewportTUI,
27
29
  Loader,
30
+ ScrollView,
28
31
  SelectList,
29
32
  Spacer,
30
33
  Text,
@@ -35,8 +38,12 @@ import {
35
38
  type KeybindingsManager,
36
39
  type SelectItem,
37
40
  type TUI,
41
+ type TuiMouseEvent,
42
+ type TuiMouseEventResult,
43
+ type ViewportTUI,
44
+ VStack,
38
45
  } from "@earendil-works/pi-tui";
39
- import { MESSAGE_TYPE, nodes, onTreeChange, programLabel, programsOf, type AgentNode, type Entry } from "../agents/registry.ts";
46
+ import { MESSAGE_TYPE, nodes, onTreeChange, programLabel, tasksOf, type AgentNode, type Entry, type Task } from "../agents/registry.ts";
40
47
  import { latestActivity } from "../agents/search.ts";
41
48
  import { PROGRAM_TOOL_NAME } from "../programs/execute.ts";
42
49
  import type { Programs } from "../programs/programs.ts";
@@ -46,6 +53,9 @@ import { execFileSync } from "node:child_process";
46
53
  type Message = AgentSession["messages"][number];
47
54
  type ToolResult = Parameters<ToolExecutionComponent["updateResult"]>[0];
48
55
  type StatusIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
56
+ type Conversation = NonNullable<ReturnType<AgentNode["agents"]["conversation"]>>;
57
+ type ToolCall = Extract<Extract<Message, { role: "assistant" }>["content"][number], { type: "toolCall" }>;
58
+ type Display = ReturnType<AgentViewer["display"]>;
49
59
 
50
60
  /** Stops the elapsed-time timer of a tool component the viewer drops; only a final result stops it otherwise. */
51
61
  function stopTimer(tool: ToolExecutionComponent): void {
@@ -87,6 +97,39 @@ function footerData(cwd: string, ctx: ExtensionContext): ReadonlyFooterDataProvi
87
97
  };
88
98
  }
89
99
 
100
+ /** The lines a scroll key of Pi's fullscreen mode moves the view up, negative for down, or undefined for another key. */
101
+ function scrollKey(keys: KeybindingsManager, data: string, page: number): number | undefined {
102
+ const moves = [
103
+ ["tui.altScreen.pageUp", page], ["tui.altScreen.pageDown", -page],
104
+ ["tui.altScreen.halfPageUp", Math.ceil(page / 2)], ["tui.altScreen.halfPageDown", -Math.ceil(page / 2)],
105
+ ["tui.altScreen.lineUp", 1], ["tui.altScreen.lineDown", -1],
106
+ ["tui.altScreen.top", Infinity], ["tui.altScreen.bottom", -Infinity],
107
+ ] as const;
108
+ return moves.find(([action]) => keys.matches(data, action))?.[1];
109
+ }
110
+
111
+ /**
112
+ * Shows a viewer in place of Pi's chat in fullscreen mode, and returns a function that restores it. The viewer takes
113
+ * Pi's layout, so its transcript above `dock` is browsed as Pi's transcript.
114
+ */
115
+ function mountViewer(tui: ViewportTUI, theme: Theme, ctx: ExtensionContext, transcript: Component, dock: Component[]): () => void {
116
+ const previous = (tui as unknown as { layoutRoot?: Component }).layoutRoot;
117
+ const view = new ScrollView(transcript, {
118
+ follow: "end",
119
+ primary: true,
120
+ overscroll: "chain",
121
+ scrollbar: agentSession(ctx.sessionManager)?.settingsManager.getFullscreenScrollbar() ?? "auto",
122
+ scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),
123
+ scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),
124
+ });
125
+ // As Pi's fullscreen layout: the transcript takes the room the dock leaves.
126
+ tui.setLayoutRoot(new VStack([
127
+ { component: view, basis: 0, grow: 1, shrink: 1, minSize: 1 },
128
+ { component: new VStack(dock.map((component) => ({ component, shrink: 1, minSize: 0 }))), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
129
+ ]));
130
+ return () => tui.setLayoutRoot(previous);
131
+ }
132
+
90
133
  /** The user's view of one owned Agent: its conversation, and an editor whose text goes to it. */
91
134
  class AgentViewer implements Component, Focusable {
92
135
  private readonly editor: CustomEditor;
@@ -94,15 +137,20 @@ class AgentViewer implements Component, Focusable {
94
137
  private readonly footer: FooterComponent;
95
138
  private session?: AgentSession;
96
139
  private streaming?: Message;
97
- /** The streaming message's component and tool calls from the last rebuild, which its updates change in place. */
98
- private live?: { component: AssistantMessageComponent; calls: string };
99
- /** Tool components without a result from the last rebuild. */
140
+ /** The component of the streaming assistant message and its tool calls, which its updates change in place. */
141
+ private live?: { component: AssistantMessageComponent; calls: Set<string> };
142
+ /** Tool components without a result. */
100
143
  private tools = new Map<string, ToolExecutionComponent>();
101
144
  private running = new Map<string, { partial?: ToolResult }>();
145
+ /** The conversation's messages the transcript shows, by their count and the last one. */
146
+ private shown = 0;
147
+ private last?: Message;
148
+ /** Streaming messages that ended in the transcript before reaching the conversation's messages. */
149
+ private ended = 0;
102
150
  private expanded = false;
103
- /** Lines scrolled up from the end. */
151
+ /** Lines scrolled up from the end, in regular mode. */
104
152
  private scroll = 0;
105
- private dirty = true;
153
+ private fullscreen: boolean;
106
154
  private status = "";
107
155
  private indicator?: WorkingStatus;
108
156
  /** Inputs sent from the viewer that the Agent has not received yet, as Pi shows queued messages. */
@@ -136,11 +184,21 @@ class AgentViewer implements Component, Focusable {
136
184
  this.editor.onAction("app.message.followUp", () => this.send(this.editor.getText(), "followUp"));
137
185
  this.editor.onAction("app.tools.expand", () => {
138
186
  this.expanded = !this.expanded;
139
- this.dirty = true;
187
+ // As Pi does, in place.
188
+ for (const child of this.transcript.children) (child as { setExpanded?: (expanded: boolean) => void }).setExpanded?.(this.expanded);
140
189
  this.tui.requestRender();
141
190
  });
142
191
  this.disposers.push(onTreeChange(() => this.refresh()));
143
192
  this.attach();
193
+ this.fullscreen = isViewportTUI(tui);
194
+ if (isViewportTUI(tui)) {
195
+ const part = (render: (width: number) => string[]): Component => ({ render, invalidate: () => this.invalidate() });
196
+ this.disposers.push(mountViewer(tui, theme, ctx, part((width) => this.renderTranscript(width)), [
197
+ part((width) => [" ".repeat(width), ...this.renderQueue(width)]),
198
+ this.editor,
199
+ part((width) => this.renderFooter(width)),
200
+ ]));
201
+ }
144
202
  }
145
203
 
146
204
  get focused(): boolean {
@@ -153,28 +211,20 @@ class AgentViewer implements Component, Focusable {
153
211
  }
154
212
 
155
213
  handleInput(data: string): void {
156
- const page = Math.max(1, this.tui.terminal.rows - 8);
157
- // The page keys of Pi's fullscreen mode.
158
- if (this.keys.matches(data, "tui.altScreen.pageUp")) this.scroll += page;
159
- else if (this.keys.matches(data, "tui.altScreen.pageDown")) this.scroll = Math.max(0, this.scroll - page);
214
+ const lines = this.fullscreen ? undefined : scrollKey(this.keys, data, Math.max(1, this.tui.terminal.rows - 8));
215
+ if (lines !== undefined) this.scroll = Math.max(0, this.scroll + lines);
160
216
  else this.editor.handleInput(data);
161
217
  this.tui.requestRender();
162
218
  }
163
219
 
220
+ /** In fullscreen mode, the viewer draws itself in place of Pi's chat (`mountViewer`); in regular mode, over the screen. */
164
221
  render(width: number): string[] {
165
- const conversation = this.owner.agents.conversation(this.entry.id);
166
- if (this.dirty) this.rebuild(conversation);
167
- this.syncStatus(conversation);
168
- const queue = this.outbox.map(({ text, delivery }) =>
169
- truncateToWidth(this.theme.fg("dim", ` ${delivery === "steer" ? "Steering" : "Follow-up"}: ${text.replace(/\s+/g, " ")}`), width));
222
+ if (this.fullscreen) return [];
223
+ const queue = this.renderQueue(width);
170
224
  const editor = this.editor.render(width);
171
- const [cwd = "", ...footer] = this.footer.render(width);
172
- // The way back sits at the right of the footer's first line, when it fits.
173
- const back = keyHint("app.clear", "back");
174
- const gap = width - visibleWidth(cwd) - visibleWidth(back);
175
- footer.unshift(gap >= 2 ? cwd + " ".repeat(gap) + back : cwd);
225
+ const footer = this.renderFooter(width);
176
226
  const budget = Math.max(1, this.tui.terminal.rows - queue.length - editor.length - footer.length - 1);
177
- const lines = this.transcript.render(width);
227
+ const lines = this.renderTranscript(width);
178
228
  this.scroll = Math.min(this.scroll, Math.max(0, lines.length - budget));
179
229
  const end = lines.length - this.scroll;
180
230
  const shown = lines.slice(Math.max(0, end - budget), end);
@@ -184,8 +234,34 @@ class AgentViewer implements Component, Focusable {
184
234
  return [...padding, ...shown, blank, ...queue, ...editor, ...footer];
185
235
  }
186
236
 
237
+ private renderTranscript(width: number): string[] {
238
+ this.prepare();
239
+ return this.transcript.render(width);
240
+ }
241
+
242
+ /** Brings the transcript, the outbox and the working status up to the conversation, before any of them renders. */
243
+ private prepare(): void {
244
+ const conversation = this.owner.agents.conversation(this.entry.id);
245
+ this.sync(conversation);
246
+ this.syncStatus(conversation);
247
+ }
248
+
249
+ private renderQueue(width: number): string[] {
250
+ this.prepare();
251
+ return this.outbox.map(({ text, delivery }) =>
252
+ truncateToWidth(this.theme.fg("dim", ` ${delivery === "steer" ? "Steering" : "Follow-up"}: ${text.replace(/\s+/g, " ")}`), width));
253
+ }
254
+
255
+ /** Pi's footer for the Agent, with the way back at the right of its first line when it fits, marked so it stands out. */
256
+ private renderFooter(width: number): string[] {
257
+ const [cwd = "", ...footer] = this.footer.render(width);
258
+ const back = this.theme.fg("accent", `${keyText("app.clear")} back`);
259
+ const gap = width - visibleWidth(cwd) - visibleWidth(back);
260
+ return [gap >= 2 ? cwd + " ".repeat(gap) + back : cwd, ...footer];
261
+ }
262
+
187
263
  invalidate(): void {
188
- this.dirty = true;
264
+ this.transcript.invalidate();
189
265
  this.editor.invalidate();
190
266
  }
191
267
 
@@ -206,7 +282,7 @@ class AgentViewer implements Component, Focusable {
206
282
  this.editor.addToHistory(trimmed);
207
283
  this.editor.setText("");
208
284
  this.scroll = 0;
209
- this.dirty = true;
285
+ this.tui.requestRender();
210
286
  } catch (error) {
211
287
  this.ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
212
288
  }
@@ -225,35 +301,32 @@ class AgentViewer implements Component, Focusable {
225
301
  const tool = this.running.get(event.toolCallId);
226
302
  if (tool) tool.partial = event.partialResult as ToolResult;
227
303
  } else if (event.type === "tool_execution_end") this.running.delete(event.toolCallId);
228
- if (!this.update(event)) this.dirty = true;
304
+ this.update(event);
229
305
  this.tui.requestRender();
230
306
  }));
231
307
  }
232
308
 
233
- /** Applies a streaming update to the shown components, as Pi does; false when the transcript needs a rebuild. */
234
- private update(event: AgentSessionEvent): boolean {
235
- if (this.dirty) return false;
236
- if (event.type === "message_update") {
237
- if (!this.live || event.message.role !== "assistant") return false;
238
- const calls = event.message.content.flatMap((part) => part.type === "toolCall" ? [part] : []);
239
- if (calls.map((call) => call.id).join("\n") !== this.live.calls) return false;
309
+ /** Applies a streaming update to the shown components in place, as Pi does; the next render adds new messages. */
310
+ private update(event: AgentSessionEvent): void {
311
+ if (event.type === "message_update" && this.live && event.message.role === "assistant") {
240
312
  this.live.component.updateContent(event.message, true);
241
- for (const call of calls) this.tools.get(call.id)?.updateArgs(call.arguments);
242
- return true;
243
- }
244
- if (event.type === "tool_execution_start" || event.type === "tool_execution_update") {
245
- const tool = this.tools.get(event.toolCallId);
246
- if (!tool) return false;
247
- if (event.type === "tool_execution_start") tool.markExecutionStarted();
248
- else tool.updateResult(event.partialResult as ToolResult, true);
249
- return true;
250
- }
251
- return false;
313
+ const conversation = this.owner.agents.conversation(this.entry.id);
314
+ for (const part of event.message.content) {
315
+ if (part.type !== "toolCall") continue;
316
+ if (this.live.calls.has(part.id)) this.tools.get(part.id)?.updateArgs(part.arguments);
317
+ else if (conversation) this.addTool(part, conversation, this.display(conversation), this.live.calls);
318
+ }
319
+ } else if (event.type === "message_end" && this.live && event.message.role === "assistant") {
320
+ // As Pi does, the streaming message's component ends with it, before the next message streams.
321
+ const conversation = this.owner.agents.conversation(this.entry.id);
322
+ if (conversation) this.end(event.message, conversation, this.display(conversation));
323
+ this.ended++;
324
+ } else if (event.type === "tool_execution_start") this.tools.get(event.toolCallId)?.markExecutionStarted();
325
+ else if (event.type === "tool_execution_update") this.tools.get(event.toolCallId)?.updateResult(event.partialResult as ToolResult, true);
252
326
  }
253
327
 
254
328
  private refresh(): void {
255
329
  this.attach();
256
- this.dirty = true;
257
330
  this.tui.requestRender();
258
331
  }
259
332
 
@@ -261,7 +334,7 @@ class AgentViewer implements Component, Focusable {
261
334
  * The working status in the editor's border, and its color from the Agent's thinking level, as in Pi: `Working`,
262
335
  * spinner and text in the border's color.
263
336
  */
264
- private syncStatus(conversation: ReturnType<AgentNode["agents"]["conversation"]>): void {
337
+ private syncStatus(conversation: Conversation | undefined): void {
265
338
  this.editor.borderColor = this.theme.getThinkingBorderColor((conversation?.thinkingLevel ?? "off") as never);
266
339
  const state = this.owner.agents.counts(this.entry.id)?.state;
267
340
  const text = state === "running" || state === "waiting" ? "Working" : "";
@@ -273,103 +346,145 @@ class AgentViewer implements Component, Focusable {
273
346
  this.editor.setWorkingStatusIndicator(this.indicator as unknown as StatusIndicator);
274
347
  }
275
348
 
276
- private rebuild(conversation: ReturnType<AgentNode["agents"]["conversation"]>): void {
277
- this.dirty = false;
278
- this.transcript.clear();
279
- this.live = undefined;
280
- for (const tool of this.tools.values()) stopTimer(tool);
281
- this.tools.clear();
282
- if (!conversation) return;
283
- const messages = [...conversation.messages];
349
+ /** The transcript's display settings: as Pi's chat shows messages, with the settings of the Session the viewer is in. */
350
+ private display(conversation: Conversation) {
351
+ const current = agentSession(this.ctx.sessionManager);
352
+ const settings = current?.settingsManager;
353
+ const pad = settings?.getOutputPad() ?? 1;
354
+ return {
355
+ settings,
356
+ markdown: settings ? { ...getMarkdownTheme(), codeBlockIndent: settings.getCodeBlockIndent() } : getMarkdownTheme(),
357
+ transformers: current?.extensionRunner.getMarkdownTransformers(),
358
+ pad,
359
+ runner: (conversation.session ?? current)?.extensionRunner,
360
+ images: { showImages: settings?.getShowImages() ?? false, imageWidthCells: settings?.getImageWidthCells(), outputPad: pad },
361
+ };
362
+ }
363
+
364
+ /** Adds the conversation's new messages to the transcript, and starts over when its earlier messages changed. */
365
+ private sync(conversation: Conversation | undefined): void {
366
+ const messages = conversation?.messages ?? [];
367
+ if (messages.length < this.shown || (this.shown > 0 && messages[this.shown - 1] !== this.last)) {
368
+ this.transcript.clear();
369
+ this.live = undefined;
370
+ for (const tool of this.tools.values()) stopTimer(tool);
371
+ this.tools.clear();
372
+ this.shown = 0;
373
+ this.ended = 0;
374
+ }
284
375
  // An input leaves the outbox once it is in the conversation, or when the Agent ends its work without it.
285
376
  const state = this.owner.agents.counts(this.entry.id)?.state;
286
377
  const working = state !== undefined && state !== "idle";
287
378
  this.outbox = this.outbox.filter(({ text, after }) => working && !messages.slice(after).some((message) =>
288
379
  message.role === "custom" && message.content === text && (message.details as { user?: boolean } | undefined)?.user));
289
- if (this.streaming && !messages.includes(this.streaming)) messages.push(this.streaming);
290
- // As Pi's chat shows them, with the display settings of the Session the viewer is in.
291
- const current = agentSession(this.ctx.sessionManager);
292
- const settings = current?.settingsManager;
293
- const markdown = settings ? { ...getMarkdownTheme(), codeBlockIndent: settings.getCodeBlockIndent() } : getMarkdownTheme();
294
- const transformers = current?.extensionRunner.getMarkdownTransformers();
295
- const pad = settings?.getOutputPad() ?? 1;
296
- const runner = (conversation.session ?? current)?.extensionRunner;
297
- const images = { showImages: settings?.getShowImages() ?? false, imageWidthCells: settings?.getImageWidthCells(), outputPad: pad };
298
- for (const message of messages) {
299
- if (message.role === "assistant") {
300
- const component = new AssistantMessageComponent(message, settings?.getHideThinkingBlock() ?? false, markdown, undefined, pad, transformers);
301
- if (message === this.streaming) {
302
- // As Pi does, so Markdown transformers know the message is still streaming.
303
- component.updateContent(message, true);
304
- const calls = message.content.flatMap((part) => part.type === "toolCall" ? [part.id] : []);
305
- this.live = { component, calls: calls.join("\n") };
306
- }
380
+ if (!conversation) return;
381
+ const streaming = this.streaming?.role === "assistant" && !this.live && !messages.includes(this.streaming) ? this.streaming : undefined;
382
+ if (messages.length === this.shown && !streaming) return;
383
+ const display = this.display(conversation);
384
+ for (const message of messages.slice(this.shown)) {
385
+ if (message.role !== "assistant") this.add(message, conversation, display);
386
+ // An ended streaming message is shown already.
387
+ else if (this.ended > 0) this.ended--;
388
+ else if (this.live) this.end(message, conversation, display);
389
+ else this.add(message, conversation, display);
390
+ }
391
+ this.shown = messages.length;
392
+ this.last = messages.at(-1);
393
+ if (streaming?.role === "assistant") {
394
+ const component = new AssistantMessageComponent(streaming, display.settings?.getHideThinkingBlock() ?? false, display.markdown, undefined, display.pad, display.transformers);
395
+ // As Pi does, so Markdown transformers know the message is still streaming.
396
+ component.updateContent(streaming, true);
397
+ this.transcript.addChild(component);
398
+ this.live = { component, calls: new Set() };
399
+ for (const part of streaming.content) if (part.type === "toolCall") this.addTool(part, conversation, display, this.live.calls);
400
+ }
401
+ }
402
+
403
+ /** Shows the streaming message whole as it ended. */
404
+ private end(message: Message, conversation: Conversation, display: Display): void {
405
+ if (!this.live || message.role !== "assistant") return;
406
+ this.live.component.updateContent(message);
407
+ for (const part of message.content) if (part.type === "toolCall" && !this.live.calls.has(part.id)) this.addTool(part, conversation, display);
408
+ this.live = undefined;
409
+ this.settle(message);
410
+ }
411
+
412
+ private addTool(call: ToolCall, conversation: Conversation, display: Display, calls?: Set<string>): void {
413
+ const tool = new ToolExecutionComponent(call.name, call.id, call.arguments, display.images,
414
+ conversation.session?.getToolDefinition(call.name), this.tui, conversation.cwd);
415
+ tool.setExpanded(this.expanded);
416
+ this.transcript.addChild(tool);
417
+ this.tools.set(call.id, tool);
418
+ calls?.add(call.id);
419
+ const running = this.running.get(call.id);
420
+ if (running) {
421
+ tool.markExecutionStarted();
422
+ if (running.partial) tool.updateResult(running.partial, true);
423
+ }
424
+ }
425
+
426
+ /** Ends the tool calls of an assistant message that stopped without running them. */
427
+ private settle(message: Message): void {
428
+ if (message.role !== "assistant" || (message.stopReason !== "aborted" && message.stopReason !== "error")) return;
429
+ const text = message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
430
+ for (const part of message.content) {
431
+ if (part.type !== "toolCall") continue;
432
+ this.tools.get(part.id)?.updateResult({ content: [{ type: "text", text }], isError: true });
433
+ this.tools.delete(part.id);
434
+ }
435
+ }
436
+
437
+ private add(message: Message, conversation: Conversation, display: Display): void {
438
+ const { markdown, transformers, pad, runner } = display;
439
+ if (message.role === "assistant") {
440
+ this.transcript.addChild(new AssistantMessageComponent(message, display.settings?.getHideThinkingBlock() ?? false, markdown, undefined, pad, transformers));
441
+ for (const part of message.content) if (part.type === "toolCall") this.addTool(part, conversation, display);
442
+ this.settle(message);
443
+ } else if (message.role === "toolResult") {
444
+ this.tools.get(message.toolCallId)?.updateResult(message);
445
+ this.tools.delete(message.toolCallId);
446
+ } else if (message.role === "user") {
447
+ const text = typeof message.content === "string"
448
+ ? message.content
449
+ : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
450
+ if (!text) return;
451
+ this.transcript.addChild(new Spacer(1));
452
+ const skill = parseSkillBlock(text);
453
+ if (skill) {
454
+ const component = new SkillInvocationMessageComponent(skill, markdown, pad);
455
+ component.setExpanded(this.expanded);
307
456
  this.transcript.addChild(component);
308
- for (const part of message.content) {
309
- if (part.type !== "toolCall") continue;
310
- const tool = new ToolExecutionComponent(part.name, part.id, part.arguments, images,
311
- conversation.session?.getToolDefinition(part.name), this.tui, conversation.cwd);
312
- tool.setExpanded(this.expanded);
313
- this.transcript.addChild(tool);
314
- if (message.stopReason === "aborted" || message.stopReason === "error") {
315
- const text = message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error";
316
- tool.updateResult({ content: [{ type: "text", text }], isError: true });
317
- } else {
318
- this.tools.set(part.id, tool);
319
- }
320
- }
321
- } else if (message.role === "toolResult") {
322
- this.tools.get(message.toolCallId)?.updateResult(message);
323
- this.tools.delete(message.toolCallId);
324
- } else if (message.role === "user") {
325
- const text = typeof message.content === "string"
326
- ? message.content
327
- : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
328
- if (!text) continue;
329
- this.transcript.addChild(new Spacer(1));
330
- const skill = parseSkillBlock(text);
331
- if (skill) {
332
- const component = new SkillInvocationMessageComponent(skill, markdown, pad);
333
- component.setExpanded(this.expanded);
334
- this.transcript.addChild(component);
335
- if (skill.userMessage) {
336
- this.transcript.addChild(new Spacer(1));
337
- this.transcript.addChild(new UserMessageComponent(skill.userMessage, markdown, pad, transformers));
338
- }
339
- } else {
340
- this.transcript.addChild(new UserMessageComponent(text, markdown, pad, transformers));
341
- }
342
- } else if (message.role === "custom" && message.display) {
343
- const user = message.customType === MESSAGE_TYPE && (message.details as { user?: boolean } | undefined)?.user;
344
- if (user && typeof message.content === "string") {
457
+ if (skill.userMessage) {
345
458
  this.transcript.addChild(new Spacer(1));
346
- this.transcript.addChild(new UserMessageComponent(message.content, markdown, pad, transformers));
347
- } else {
348
- // The component starts with its own blank line.
349
- const component = new CustomMessageComponent(message, runner?.getMessageRenderer(message.customType), markdown, pad);
350
- component.setExpanded(this.expanded);
351
- this.transcript.addChild(component);
459
+ this.transcript.addChild(new UserMessageComponent(skill.userMessage, markdown, pad, transformers));
352
460
  }
353
- } else if (message.role === "bashExecution") {
354
- const component = new BashExecutionComponent(message.command, this.tui, message.excludeFromContext, pad);
355
- if (message.output) component.appendOutput(message.output);
356
- component.setComplete(message.exitCode, message.cancelled, message.truncated ? { truncated: true } as never : undefined, message.fullOutputPath);
357
- component.setExpanded(this.expanded);
358
- this.transcript.addChild(component);
359
- } else if (message.role === "compactionSummary" || message.role === "branchSummary") {
461
+ } else {
462
+ this.transcript.addChild(new UserMessageComponent(text, markdown, pad, transformers));
463
+ }
464
+ } else if (message.role === "custom" && message.display) {
465
+ const user = message.customType === MESSAGE_TYPE && (message.details as { user?: boolean } | undefined)?.user;
466
+ if (user && typeof message.content === "string") {
360
467
  this.transcript.addChild(new Spacer(1));
361
- const component = message.role === "compactionSummary"
362
- ? new CompactionSummaryMessageComponent(message, markdown, pad)
363
- : new BranchSummaryMessageComponent(message, markdown, pad);
468
+ this.transcript.addChild(new UserMessageComponent(message.content, markdown, pad, transformers));
469
+ } else {
470
+ // The component starts with its own blank line.
471
+ const component = new CustomMessageComponent(message, runner?.getMessageRenderer(message.customType), markdown, pad);
364
472
  component.setExpanded(this.expanded);
365
473
  this.transcript.addChild(component);
366
474
  }
367
- }
368
- for (const [id, tool] of this.tools) {
369
- const running = this.running.get(id);
370
- if (!running) continue;
371
- tool.markExecutionStarted();
372
- if (running.partial) tool.updateResult(running.partial, true);
475
+ } else if (message.role === "bashExecution") {
476
+ const component = new BashExecutionComponent(message.command, this.tui, message.excludeFromContext, pad);
477
+ if (message.output) component.appendOutput(message.output);
478
+ component.setComplete(message.exitCode, message.cancelled, message.truncated ? { truncated: true } as never : undefined, message.fullOutputPath);
479
+ component.setExpanded(this.expanded);
480
+ this.transcript.addChild(component);
481
+ } else if (message.role === "compactionSummary" || message.role === "branchSummary") {
482
+ this.transcript.addChild(new Spacer(1));
483
+ const component = message.role === "compactionSummary"
484
+ ? new CompactionSummaryMessageComponent(message, markdown, pad)
485
+ : new BranchSummaryMessageComponent(message, markdown, pad);
486
+ component.setExpanded(this.expanded);
487
+ this.transcript.addChild(component);
373
488
  }
374
489
  }
375
490
  }
@@ -406,20 +521,25 @@ class ProgramViewer implements Component, Focusable {
406
521
  }
407
522
 
408
523
  handleInput(data: string): void {
409
- const page = Math.max(1, this.tui.terminal.rows - 4);
524
+ const lines = scrollKey(this.keys, data, Math.max(1, this.tui.terminal.rows - 4));
410
525
  if (this.keys.matches(data, "app.clear" as never)) return this.done();
411
526
  if (this.keys.matches(data, "app.interrupt" as never)) this.stop();
412
527
  else if (this.keys.matches(data, "app.tools.expand" as never)) {
413
528
  this.expanded = !this.expanded;
414
529
  this.tool.setExpanded(this.expanded);
415
- } else if (this.keys.matches(data, "tui.altScreen.pageUp")) this.scroll += page;
416
- else if (this.keys.matches(data, "tui.altScreen.pageDown")) this.scroll = Math.max(0, this.scroll - page);
530
+ } else if (lines !== undefined) this.scroll = Math.max(0, this.scroll + lines);
417
531
  this.tui.requestRender();
418
532
  }
419
533
 
534
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
535
+ if (event.type !== "wheel" || !event.wheelDelta) return undefined;
536
+ this.scroll = Math.max(0, this.scroll - event.wheelDelta);
537
+ return { handled: true };
538
+ }
539
+
420
540
  render(width: number): string[] {
421
541
  const running = this.programs.view(this.id)?.record.state === "running";
422
- const hints = [...running ? [keyHint("app.interrupt", "stop")] : [], keyHint("app.clear", "back")].join(" ");
542
+ const hints = [...running ? [keyHint("app.interrupt", "stop")] : [], this.theme.fg("accent", `${keyText("app.clear")} back`)].join(" ");
423
543
  const footer = truncateToWidth(" ".repeat(Math.max(0, width - visibleWidth(hints))) + hints, width);
424
544
  const border = new DynamicBorder().render(width);
425
545
  const budget = Math.max(1, this.tui.terminal.rows - border.length - 2);
@@ -514,27 +634,28 @@ class ListSelector extends Container implements Focusable {
514
634
  /** The Agents in the current Agent's tree, including Programs' Agents, those not idle first and newest first within each group. */
515
635
  export function ownedEntries(self: AgentNode): Entry[] {
516
636
  const entries: Entry[] = [];
517
- const visit = (node: AgentNode) => {
518
- for (const agent of node.agents.owned()) {
519
- entries.push({ ...agent, ownerId: node.id });
520
- const loaded = nodes.get(agent.id);
521
- if (loaded) visit(loaded);
637
+ const visit = (tasks: Task[]) => {
638
+ for (const task of tasks) {
639
+ if ("entry" in task) entries.push(task.entry);
640
+ visit(task.tasks);
522
641
  }
523
- for (const program of programsOf(node)) visit(program);
524
642
  };
525
- visit(self);
643
+ visit(tasksOf(self));
526
644
  return entries.sort((a, b) => Number(b.state !== "idle") - Number(a.state !== "idle") || (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
527
645
  }
528
646
 
529
647
  const PROGRAM_ITEM = "program:";
530
648
 
531
649
  /**
532
- * `/tasks`: picks an Agent in the tree or a background Program, live ones first and newest first, and opens it in
533
- * the viewer. `paddingX` is Pi's editor padding, so the viewer's editor lines up as Pi's does.
650
+ * `/tasks`: picks an Agent in the tree, a background Program of the current Agent, or a running one of another Agent, live ones first and newest first, and opens
651
+ * it in the viewer. `paddingX` is Pi's editor padding, so the viewer's editor lines up as Pi's does.
534
652
  */
535
- export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, programs: Programs | undefined, paddingX: number): Promise<void> {
653
+ export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, paddingX: number): Promise<void> {
536
654
  const entries = ownedEntries(self);
537
- const records = programs?.list() ?? [];
655
+ const records = [self, ...entries.flatMap((entry) => nodes.get(entry.id) ?? [])]
656
+ .flatMap((caller) => caller.programs?.list().map((record) => ({ caller, programs: caller.programs!, record })) ?? [])
657
+ // Another Agent's ended Programs show in its conversation; its Session may not be loaded to list them.
658
+ .filter(({ caller, record }) => caller === self || record.state === "running");
538
659
  if (entries.length === 0 && records.length === 0) {
539
660
  ctx.ui.notify("No tasks.", "info");
540
661
  return;
@@ -554,20 +675,25 @@ export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, pro
554
675
  label: path(entry),
555
676
  description: [entry.state, latestActivity(entry.sessionFile) ?? ""].filter(Boolean).join(" "),
556
677
  } })),
557
- ...records.map((record) => ({ live: record.state === "running", at: new Date(record.startedAt).toISOString(), item: {
678
+ ...records.map(({ caller, programs, record }) => ({ live: record.state === "running", at: new Date(record.startedAt).toISOString(), item: {
558
679
  value: PROGRAM_ITEM + record.id,
559
- label: `Program ${programs!.label(record)}`,
680
+ label: `Program ${programs.label(record)}${caller === self ? "" : ` ‹ ${path(byId.get(caller.id)!)}`}`,
560
681
  description: record.state,
561
682
  } })),
562
683
  ];
563
684
  const items: SelectItem[] = tasks
564
685
  .sort((a, b) => Number(b.live) - Number(a.live) || b.at.localeCompare(a.at))
565
686
  .map((task) => task.item);
566
- const id = await ctx.ui.custom<string | undefined>((_tui, theme, keys, done) =>
567
- new ListSelector("Open task:", items, theme, keys, (value) => done(value), () => done(undefined)));
568
- if (id?.startsWith(PROGRAM_ITEM) && programs) {
687
+ // In regular mode, a viewer shows over the screen; in fullscreen mode, it takes Pi's layout (`mountViewer`).
688
+ let overlay = true;
689
+ const id = await ctx.ui.custom<string | undefined>((tui, theme, keys, done) => {
690
+ overlay = !isViewportTUI(tui);
691
+ return new ListSelector("Open task:", items, theme, keys, (value) => done(value), () => done(undefined));
692
+ });
693
+ const program = id?.startsWith(PROGRAM_ITEM) ? records.find(({ record }) => record.id === id.slice(PROGRAM_ITEM.length)) : undefined;
694
+ if (program) {
569
695
  await ctx.ui.custom<void>(
570
- (tui, theme, keys, done) => new ProgramViewer(tui, theme, keys, ctx, programs, id.slice(PROGRAM_ITEM.length), () => done()),
696
+ (tui, theme, keys, done) => new ProgramViewer(tui, theme, keys, ctx, program.programs, program.record.id, () => done()),
571
697
  { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } },
572
698
  );
573
699
  return;
@@ -584,6 +710,6 @@ export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, pro
584
710
  }
585
711
  await ctx.ui.custom<void>(
586
712
  (tui, theme, keys, done) => new AgentViewer(tui, theme, keys, ctx, owner, entry, session, paddingX, () => done()),
587
- { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } },
713
+ overlay ? { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } } : undefined,
588
714
  );
589
715
  }
package/src/workbench.mjs CHANGED
@@ -8,7 +8,7 @@ import { join } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { styleText } from 'node:util';
10
10
  import { setTimeout as delay } from 'node:timers/promises';
11
- import { alive, attach, listHosts, settledHosts, startHost } from './host/client.mjs';
11
+ import { alive, attach, drain, listHosts, settledHosts, startHost } from './host/client.mjs';
12
12
  import { detachKey, detachMatcher } from './host/keys.mjs';
13
13
  import { piPackage } from './pi.mjs';
14
14
  import { ensureDaemon, sendViaDaemon } from './daemon/client.mjs';
@@ -351,7 +351,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
351
351
  async function attachTo(socket) {
352
352
  ui.stop();
353
353
  let result;
354
- try { result = await attach(socket, { isDetach }); }
354
+ // The list reads the terminal again at once, and skips late key releases itself.
355
+ try { result = await attach(socket, { isDetach, drainInput: false }); }
355
356
  finally {
356
357
  process.stdout.write(ALT_SCREEN);
357
358
  ui.start();
@@ -551,9 +552,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
551
552
  fold();
552
553
  summarize('branch summary');
553
554
  } else if (message.role === 'custom') {
554
- const from = message.customType === 'pi-agents' ? message.details?.sender?.name ?? message.details?.sender?.id : undefined;
555
555
  fold();
556
- summarize(from ? `message from ${from}` : `${message.customType} message`);
556
+ summarize(`${message.customType} message`);
557
557
  }
558
558
  }
559
559
  fold();
@@ -623,10 +623,14 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
623
623
  if (quitting) return;
624
624
  quitting = true;
625
625
  stream?.close();
626
- // Late key releases would otherwise leak into the shell, as Pi avoids on exit.
627
626
  themeSync.disableAutoSync();
628
- await terminal.drainInput(1000);
629
627
  ui.stop();
628
+ // Late key releases would otherwise leak into the shell, as when detaching from a Session.
629
+ process.stdin.setRawMode(true);
630
+ process.stdin.resume();
631
+ await drain(process.stdin, process.stdout);
632
+ process.stdin.setRawMode(false);
633
+ process.stdin.pause();
630
634
  process.stdout.write(`${MAIN_SCREEN}${styleText('dim', 'Sessions keep running. To reopen:')} pd agents\n`);
631
635
  finish();
632
636
  }