@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 +2 -1
- package/bin/pd.mjs +39 -5
- package/docs/HOSTS.md +2 -0
- package/docs/SPEC.md +2 -2
- package/package.json +1 -1
- package/src/agents/registry.ts +29 -10
- package/src/host/client.mjs +38 -5
- package/src/index.ts +2 -1
- package/src/main-agent.md +8 -0
- package/src/programs/execute.ts +25 -5
- package/src/roots/paths.mjs +1 -0
- package/src/ui/panel.ts +25 -40
- package/src/ui/viewer.ts +278 -152
- package/src/workbench.mjs +10 -6
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
package/src/agents/registry.ts
CHANGED
|
@@ -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
|
-
/**
|
|
122
|
-
export
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
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. */
|
package/src/host/client.mjs
CHANGED
|
@@ -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
|
-
|
|
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),
|
|
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.
|
package/src/programs/execute.ts
CHANGED
|
@@ -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
|
|
109
|
-
* Programs waiting on Agents use up. A Program's worker is admitted on its own terms
|
|
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
|
}
|
package/src/roots/paths.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
28
|
-
*
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
69
|
-
const
|
|
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 (${
|
|
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,
|
|
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
|
|
98
|
-
private live?: { component: AssistantMessageComponent; calls: string };
|
|
99
|
-
/** Tool components without a result
|
|
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
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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;
|
|
234
|
-
private update(event: AgentSessionEvent):
|
|
235
|
-
if (this.
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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:
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
this.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
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.
|
|
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")] : [],
|
|
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 = (
|
|
518
|
-
for (const
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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,
|
|
653
|
+
export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, paddingX: number): Promise<void> {
|
|
536
654
|
const entries = ownedEntries(self);
|
|
537
|
-
const records =
|
|
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
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
}
|