@tshu-w/pi-agents 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -20
- package/bin/daemon.mjs +32 -14
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +48 -36
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +7 -5
- package/src/agents/agents.ts +103 -30
- package/src/agents/registry.ts +30 -3
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +10 -5
- package/src/daemon/client.mjs +4 -1
- package/src/host/client.mjs +24 -4
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +360 -0
- package/src/index.ts +36 -14
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/programs.ts +78 -11
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +11 -4
- package/src/programs/tool.ts +172 -16
- package/src/roots/guard.mjs +8 -2
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +46 -0
- package/src/ui/viewer.ts +119 -11
- package/src/workbench.mjs +48 -20
package/src/roots/transport.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
|
-
import { StringDecoder } from 'node:string_decoder';
|
|
3
2
|
import { chmodSync, lstatSync, mkdirSync } from 'node:fs';
|
|
4
3
|
import { dirname, resolve } from 'node:path';
|
|
5
4
|
|
|
6
5
|
const MAX_BYTES = 1024 * 1024;
|
|
6
|
+
// Pi's events on an rpc stream carry whole messages, images included.
|
|
7
|
+
const RPC_MAX_BYTES = 256 * 1024 * 1024;
|
|
7
8
|
const MAX_SOCKET_PATH_BYTES = 103;
|
|
8
9
|
const MAX_PENDING = 64;
|
|
9
10
|
// Each running Pi keeps a stream open to the daemon.
|
|
@@ -39,9 +40,9 @@ function safeDirectory(path, create = false) {
|
|
|
39
40
|
function privateSocket(path) {
|
|
40
41
|
safeDirectory(dirname(path));
|
|
41
42
|
const info = lstatSync(path);
|
|
42
|
-
if (!info.isSocket() || info.uid !== process.getuid()
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
if (!info.isSocket() || info.uid !== process.getuid()) throw fault('UNSAFE_PATH', 'Socket must be owned by the current user');
|
|
44
|
+
// A listener sets the mode just after it binds; a client waiting for a new listener may retry this.
|
|
45
|
+
if ((info.mode & 0o777) !== 0o600) throw fault('SOCKET_MODE', 'Socket must have mode 0600');
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -51,7 +52,7 @@ function validate(value) {
|
|
|
51
52
|
if (!record(value)) throw fault('INVALID_REQUEST', 'Expected a request object');
|
|
52
53
|
if (value.action === 'status') return { action: 'status' };
|
|
53
54
|
if (value.action === 'stream') {
|
|
54
|
-
if (
|
|
55
|
+
if (!['session', 'workbench', 'rpc', 'print'].includes(value.role)) throw fault('INVALID_REQUEST', 'stream role must be session, workbench, rpc or print');
|
|
55
56
|
return { action: 'stream', role: value.role };
|
|
56
57
|
}
|
|
57
58
|
const m = value.message, l = value.launch;
|
|
@@ -75,12 +76,14 @@ function validate(value) {
|
|
|
75
76
|
}, ...(l === undefined ? {} : { launch: { command: l.command, args: [...l.args], env: { ...l.env } } }) };
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
function encode(value) {
|
|
79
|
+
function encode(value, limit = MAX_BYTES) {
|
|
79
80
|
const bytes = Buffer.from(`${JSON.stringify(value)}\n`);
|
|
80
|
-
if (bytes.length >
|
|
81
|
+
if (bytes.length > limit) throw fault('FRAME_TOO_LARGE', `JSONL frame exceeds ${limit / 1024 / 1024} MiB`);
|
|
81
82
|
return bytes;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
const streamLimit = role => role === 'rpc' || role === 'print' ? RPC_MAX_BYTES : MAX_BYTES;
|
|
86
|
+
|
|
84
87
|
// Consume exactly one bounded JSONL frame; never dispatch additional requests.
|
|
85
88
|
function readFrame(socket, done) {
|
|
86
89
|
let chunks = [], size = 0, finished = false;
|
|
@@ -108,28 +111,33 @@ function readFrame(socket, done) {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
// JSONL messages after a stream's opening frame.
|
|
111
|
-
function readLines(socket, onLine, onInvalid) {
|
|
112
|
-
|
|
113
|
-
let
|
|
114
|
+
function readLines(socket, onLine, onInvalid, limit) {
|
|
115
|
+
// An unfinished line is kept as chunks, so each byte is scanned once.
|
|
116
|
+
let pending = [], size = 0;
|
|
114
117
|
socket.on('data', chunk => {
|
|
115
|
-
|
|
116
|
-
for (let newline; (newline =
|
|
117
|
-
const line =
|
|
118
|
-
|
|
118
|
+
let start = 0;
|
|
119
|
+
for (let newline; (newline = chunk.indexOf(10, start)) >= 0; start = newline + 1) {
|
|
120
|
+
const line = Buffer.concat([...pending, chunk.subarray(start, newline)]).toString('utf8');
|
|
121
|
+
pending = [];
|
|
122
|
+
size = 0;
|
|
119
123
|
let value;
|
|
120
124
|
try { value = JSON.parse(line); } catch { return onInvalid(); }
|
|
121
125
|
onLine(value);
|
|
122
126
|
}
|
|
123
|
-
if (
|
|
127
|
+
if (start < chunk.length) {
|
|
128
|
+
pending.push(chunk.subarray(start));
|
|
129
|
+
size += chunk.length - start;
|
|
130
|
+
}
|
|
131
|
+
if (size > limit) onInvalid();
|
|
124
132
|
});
|
|
125
133
|
}
|
|
126
134
|
|
|
127
|
-
function channel(socket, signal) {
|
|
135
|
+
function channel(socket, signal, limit) {
|
|
128
136
|
const listeners = [];
|
|
129
|
-
readLines(socket, value => { for (const listener of listeners) listener(value); }, () => socket.destroy());
|
|
137
|
+
readLines(socket, value => { for (const listener of listeners) listener(value); }, () => socket.destroy(), limit);
|
|
130
138
|
return {
|
|
131
139
|
signal,
|
|
132
|
-
send(value) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(value)); },
|
|
140
|
+
send(value) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(value, limit)); },
|
|
133
141
|
onMessage(listener) { listeners.push(listener); },
|
|
134
142
|
close() { socket.destroy(); },
|
|
135
143
|
};
|
|
@@ -145,7 +153,8 @@ function errorFrame(error) {
|
|
|
145
153
|
|
|
146
154
|
/**
|
|
147
155
|
* FIFO by default. Handlers receive a connection-lifetime signal, not an acceptance rollback.
|
|
148
|
-
* `stream` takes over the connections that open a stream; they keep the server from being idle.
|
|
156
|
+
* `stream` takes over the connections that open a stream; they keep the server from being idle. It may
|
|
157
|
+
* refuse a stream by throwing; its result joins the acceptance, which reaches the client before anything it sends.
|
|
149
158
|
* @param {string} socketPath
|
|
150
159
|
* @param {{ status: Function, accept: Function, stream?: Function, serialize?: boolean }} handlers
|
|
151
160
|
*/
|
|
@@ -189,8 +198,17 @@ export async function listen(socketPath, { status, accept, stream, serialize = t
|
|
|
189
198
|
if (input.action === 'stream') {
|
|
190
199
|
if (!stream) return respond(fault('INVALID_REQUEST', 'Streams are not supported here'));
|
|
191
200
|
clearTimeout(timer);
|
|
192
|
-
|
|
193
|
-
|
|
201
|
+
const opened = channel(socket, signal, streamLimit(input.role));
|
|
202
|
+
const send = opened.send;
|
|
203
|
+
const early = [];
|
|
204
|
+
opened.send = value => early.push(value);
|
|
205
|
+
let result;
|
|
206
|
+
try { result = stream(input, opened); }
|
|
207
|
+
catch (failure) { return respond(failure); }
|
|
208
|
+
socket.write(encode({ ok: true, result: { pid: process.pid, ...result } }));
|
|
209
|
+
opened.send = send;
|
|
210
|
+
for (const value of early) send(value);
|
|
211
|
+
return;
|
|
194
212
|
}
|
|
195
213
|
if (pending >= MAX_PENDING) return respond(fault('SERVER_BUSY', 'Request queue is full'));
|
|
196
214
|
pending++;
|
|
@@ -332,10 +350,10 @@ export function openStream(socketPath, input, { onMessage, onClose }) {
|
|
|
332
350
|
opened = true;
|
|
333
351
|
resolvePromise({
|
|
334
352
|
result: response.result,
|
|
335
|
-
send(message) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(message)); },
|
|
353
|
+
send(message) { if (!socket.destroyed && !socket.writableEnded) socket.write(encode(message, streamLimit(value.role))); },
|
|
336
354
|
close() { socket.destroy(); },
|
|
337
355
|
unref() { socket.unref(); },
|
|
338
356
|
});
|
|
339
|
-
}, () => socket.destroy());
|
|
357
|
+
}, () => socket.destroy(), streamLimit(value.role));
|
|
340
358
|
});
|
|
341
359
|
}
|
package/src/ui/panel.ts
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
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 } from "../agents/registry.ts";
|
|
3
|
+
import { label, nodes, onTreeChange, programLabel, programsOf, type AgentNode, type Spend } from "../agents/registry.ts";
|
|
4
4
|
|
|
5
5
|
const WIDGET_KEY = "pi-agents";
|
|
6
6
|
|
|
7
7
|
const MAX_LINES = 10;
|
|
8
8
|
|
|
9
|
+
/** Time since `since` as Pi's bash tool shows durations, in whole seconds: `45s`, `1m 32s`, `1h 5m 3s`. */
|
|
10
|
+
function elapsed(since: number, now: number): string {
|
|
11
|
+
const seconds = Math.max(0, Math.floor((now - since) / 1000));
|
|
12
|
+
const minutes = Math.floor(seconds / 60);
|
|
13
|
+
if (minutes === 0) return `${seconds}s`;
|
|
14
|
+
return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m ${seconds % 60}s`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `$2.10`, or the input and output tokens as Pi's footer shows them, `7.3M tok`, when a reply had no price. */
|
|
18
|
+
function cost({ cost, tokens, unpriced }: Spend): string {
|
|
19
|
+
if (!unpriced) return `$${cost.toFixed(2)}`;
|
|
20
|
+
if (tokens < 1000) return `${tokens} tok`;
|
|
21
|
+
if (tokens < 10_000) return `${(tokens / 1000).toFixed(1)}k tok`;
|
|
22
|
+
if (tokens < 1_000_000) return `${Math.round(tokens / 1000)}k tok`;
|
|
23
|
+
return tokens < 10_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M tok` : `${Math.round(tokens / 1_000_000)}M tok`;
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
/**
|
|
10
27
|
* The task panel: the Agent's live owned Agents and running background Programs as a tree, in the
|
|
11
|
-
* order they started, with their Programs' live Agents under them
|
|
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.
|
|
12
30
|
*/
|
|
13
|
-
export function panelLines(self: AgentNode): string[] {
|
|
31
|
+
export function panelLines(self: AgentNode, now = Date.now()): string[] {
|
|
14
32
|
const rows: Array<{ line: string; parent?: number; shown: boolean }> = [];
|
|
15
33
|
const ids = self.agents.ids();
|
|
16
34
|
let live = 0;
|
|
@@ -26,7 +44,9 @@ export function panelLines(self: AgentNode): string[] {
|
|
|
26
44
|
for (const child of children) {
|
|
27
45
|
const index = rows.length;
|
|
28
46
|
if ("program" in child) {
|
|
29
|
-
|
|
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 });
|
|
30
50
|
live += 1;
|
|
31
51
|
show(index);
|
|
32
52
|
visit(child.program, level + 1, index);
|
|
@@ -35,7 +55,8 @@ export function panelLines(self: AgentNode): string[] {
|
|
|
35
55
|
const { entry } = child;
|
|
36
56
|
const own = node.agents.counts(entry.id);
|
|
37
57
|
const pending = own?.pending ? [`${own.pending} pending`] : [];
|
|
38
|
-
|
|
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 });
|
|
39
60
|
if (entry.state !== "idle") {
|
|
40
61
|
live += 1;
|
|
41
62
|
show(index);
|
|
@@ -48,21 +69,30 @@ export function panelLines(self: AgentNode): string[] {
|
|
|
48
69
|
const lines = rows.filter((row) => row.shown).map((row) => row.line);
|
|
49
70
|
if (lines.length === 0) return [];
|
|
50
71
|
if (lines.length > MAX_LINES - 1) lines.splice(MAX_LINES - 2, Infinity, `+${lines.length - (MAX_LINES - 2)} more`);
|
|
51
|
-
return [`Tasks (${live} live, /tasks to
|
|
72
|
+
return [`Tasks (${live} live, /tasks to view)`, ...lines.map((line) => ` ${line}`)];
|
|
52
73
|
}
|
|
53
74
|
|
|
54
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Keeps the task panel above the editor current while the Session is loaded, and its times each second
|
|
77
|
+
* while it shows; `toggle` hides or shows it.
|
|
78
|
+
*/
|
|
55
79
|
export function createPanel(getNode: () => AgentNode | undefined) {
|
|
56
80
|
let ctx: ExtensionContext | undefined;
|
|
57
81
|
let hidden = false;
|
|
58
82
|
let scheduled = false;
|
|
59
83
|
let unsubscribe: (() => void) | undefined;
|
|
84
|
+
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
60
85
|
|
|
61
86
|
const render = () => {
|
|
62
87
|
scheduled = false;
|
|
63
88
|
const node = getNode();
|
|
64
89
|
if (!ctx?.hasUI || !node) return;
|
|
65
90
|
const lines = hidden ? [] : panelLines(node);
|
|
91
|
+
if (lines.length > 0) ticker ??= setInterval(render, 1000);
|
|
92
|
+
else {
|
|
93
|
+
clearInterval(ticker);
|
|
94
|
+
ticker = undefined;
|
|
95
|
+
}
|
|
66
96
|
ctx.ui.setWidget(WIDGET_KEY, lines.length === 0 ? undefined : (_tui, theme) => {
|
|
67
97
|
const panel = new Container();
|
|
68
98
|
lines.forEach((line, index) => panel.addChild(new TruncatedText(theme.fg(index === 0 ? "accent" : "muted", line), 0, 0)));
|
|
@@ -87,6 +117,8 @@ export function createPanel(getNode: () => AgentNode | undefined) {
|
|
|
87
117
|
render();
|
|
88
118
|
},
|
|
89
119
|
stop() {
|
|
120
|
+
clearInterval(ticker);
|
|
121
|
+
ticker = undefined;
|
|
90
122
|
unsubscribe?.();
|
|
91
123
|
unsubscribe = undefined;
|
|
92
124
|
if (ctx?.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
package/src/ui/queue.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AgentSession, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { TruncatedText, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { MESSAGE_TAG, MESSAGE_TYPE } from "../agents/registry.ts";
|
|
4
|
+
|
|
5
|
+
const WIDGET_KEY = "pi-agents-queue";
|
|
6
|
+
|
|
7
|
+
type Queued = { role: string; customType?: string; content?: unknown; details?: { delivery?: string } };
|
|
8
|
+
|
|
9
|
+
/** One line for an Agent message: its sender and body. */
|
|
10
|
+
function summary(content: unknown): string {
|
|
11
|
+
const text = typeof content === "string" ? content : "";
|
|
12
|
+
const match = new RegExp(`^<${MESSAGE_TAG} from="([^"]*)"[^>]*>\\n([\\s\\S]*)\\n</${MESSAGE_TAG}>$`).exec(text);
|
|
13
|
+
return (match ? `[${match[1]}] ${match[2]}` : text).replace(/\s+/g, " ").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Agent messages waiting in the Session's queues, which Pi does not list as it lists the user's. */
|
|
17
|
+
export function queuedLines(agent: AgentSession["agent"]): string[] {
|
|
18
|
+
const queues = agent as unknown as Record<"steeringQueue" | "followUpQueue", { messages: Queued[] }>;
|
|
19
|
+
const lines = (label: string, messages: Queued[]) => messages
|
|
20
|
+
.filter((message) => message.role === "custom" && message.customType === MESSAGE_TYPE)
|
|
21
|
+
// A write waits in the steering queue but asks for nothing, so it is labeled apart from a steer.
|
|
22
|
+
.map((message) => `${message.details?.delivery === "write" ? "Write" : label}: ${summary(message.content)}`);
|
|
23
|
+
return [...lines("Steering", queues.steeringQueue.messages), ...lines("Follow-up", queues.followUpQueue.messages)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Lists the Agent messages queued in `session` under Pi's own queued messages. The list is read from
|
|
28
|
+
* the queues on each render, so it follows them as they are consumed or cleared; `update` renders it
|
|
29
|
+
* after a message is queued.
|
|
30
|
+
*/
|
|
31
|
+
export function showQueue(ctx: ExtensionContext, session: () => AgentSession | undefined) {
|
|
32
|
+
let tui: TUI | undefined;
|
|
33
|
+
if (ctx.hasUI) {
|
|
34
|
+
ctx.ui.setWidget(WIDGET_KEY, (current, theme) => {
|
|
35
|
+
tui = current;
|
|
36
|
+
return {
|
|
37
|
+
render: (width: number) => {
|
|
38
|
+
const agent = session()?.agent;
|
|
39
|
+
return agent ? queuedLines(agent).flatMap((line) => new TruncatedText(theme.fg("dim", line), 1, 0).render(width)) : [];
|
|
40
|
+
},
|
|
41
|
+
invalidate() {},
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return { update: () => tui?.requestRender() };
|
|
46
|
+
}
|
package/src/ui/viewer.ts
CHANGED
|
@@ -36,12 +36,22 @@ import {
|
|
|
36
36
|
} from "@earendil-works/pi-tui";
|
|
37
37
|
import { MESSAGE_TYPE, nodes, onTreeChange, programLabel, programsOf, type AgentNode, type Entry } from "../agents/registry.ts";
|
|
38
38
|
import { latestActivity } from "../agents/search.ts";
|
|
39
|
+
import { PROGRAM_TOOL_NAME } from "../programs/execute.ts";
|
|
40
|
+
import type { Programs } from "../programs/programs.ts";
|
|
41
|
+
import { agentSession } from "../roots/rpc.ts";
|
|
39
42
|
import { execFileSync } from "node:child_process";
|
|
40
43
|
|
|
41
44
|
type Message = AgentSession["messages"][number];
|
|
42
45
|
type ToolResult = Parameters<ToolExecutionComponent["updateResult"]>[0];
|
|
43
46
|
type StatusIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
|
|
44
47
|
|
|
48
|
+
/** Stops the elapsed-time timer of a tool component the viewer drops; only a final result stops it otherwise. */
|
|
49
|
+
function stopTimer(tool: ToolExecutionComponent): void {
|
|
50
|
+
const state = (tool as unknown as { rendererState: { interval?: ReturnType<typeof setInterval> } }).rendererState;
|
|
51
|
+
clearInterval(state.interval);
|
|
52
|
+
state.interval = undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
/** A spinner and message for the editor's top border, like Pi's working status. */
|
|
46
56
|
class WorkingStatus extends Loader {
|
|
47
57
|
renderInBorder(width: number): string {
|
|
@@ -179,6 +189,7 @@ class AgentViewer implements Component, Focusable {
|
|
|
179
189
|
dispose(): void {
|
|
180
190
|
this.indicator?.dispose();
|
|
181
191
|
this.footer.dispose();
|
|
192
|
+
for (const tool of this.tools.values()) stopTimer(tool);
|
|
182
193
|
for (const dispose of this.disposers.splice(0)) dispose();
|
|
183
194
|
}
|
|
184
195
|
|
|
@@ -264,6 +275,7 @@ class AgentViewer implements Component, Focusable {
|
|
|
264
275
|
this.dirty = false;
|
|
265
276
|
this.transcript.clear();
|
|
266
277
|
this.live = undefined;
|
|
278
|
+
for (const tool of this.tools.values()) stopTimer(tool);
|
|
267
279
|
this.tools.clear();
|
|
268
280
|
if (!conversation) return;
|
|
269
281
|
const messages = [...conversation.messages];
|
|
@@ -328,6 +340,80 @@ class AgentViewer implements Component, Focusable {
|
|
|
328
340
|
}
|
|
329
341
|
}
|
|
330
342
|
|
|
343
|
+
/** The user's view of one background Program, as its tool call shows in the foreground. */
|
|
344
|
+
class ProgramViewer implements Component, Focusable {
|
|
345
|
+
private readonly tool: ToolExecutionComponent;
|
|
346
|
+
private expanded = false;
|
|
347
|
+
/** Lines scrolled up from the end. */
|
|
348
|
+
private scroll = 0;
|
|
349
|
+
private disposers: Array<() => void> = [];
|
|
350
|
+
focused = false;
|
|
351
|
+
|
|
352
|
+
constructor(
|
|
353
|
+
private tui: TUI,
|
|
354
|
+
private theme: Theme,
|
|
355
|
+
private keys: KeybindingsManager,
|
|
356
|
+
private ctx: ExtensionContext,
|
|
357
|
+
private programs: Programs,
|
|
358
|
+
private id: string,
|
|
359
|
+
private done: () => void,
|
|
360
|
+
) {
|
|
361
|
+
const code = programs.view(id)?.code;
|
|
362
|
+
this.tool = new ToolExecutionComponent(PROGRAM_TOOL_NAME, `program-${id}`, { action: "run", background: true, ...(code !== undefined && { code }) }, { showImages: false },
|
|
363
|
+
agentSession(ctx.sessionManager)?.getToolDefinition(PROGRAM_TOOL_NAME), tui, ctx.cwd);
|
|
364
|
+
this.tool.markExecutionStarted();
|
|
365
|
+
this.sync();
|
|
366
|
+
this.disposers.push(programs.onView(() => {
|
|
367
|
+
this.sync();
|
|
368
|
+
this.tui.requestRender();
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
handleInput(data: string): void {
|
|
373
|
+
const page = Math.max(1, this.tui.terminal.rows - 4);
|
|
374
|
+
if (this.keys.matches(data, "app.clear" as never)) return this.done();
|
|
375
|
+
if (this.keys.matches(data, "app.interrupt" as never)) this.stop();
|
|
376
|
+
else if (this.keys.matches(data, "app.tools.expand" as never)) {
|
|
377
|
+
this.expanded = !this.expanded;
|
|
378
|
+
this.tool.setExpanded(this.expanded);
|
|
379
|
+
} else if (matchesKey(data, "pageUp")) this.scroll += page;
|
|
380
|
+
else if (matchesKey(data, "pageDown")) this.scroll = Math.max(0, this.scroll - page);
|
|
381
|
+
this.tui.requestRender();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
render(width: number): string[] {
|
|
385
|
+
const running = this.programs.view(this.id)?.record.state === "running";
|
|
386
|
+
const hints = [...running ? [keyHint("app.interrupt", "stop")] : [], keyHint("app.clear", "back")].join(" ");
|
|
387
|
+
const footer = truncateToWidth(" ".repeat(Math.max(0, width - visibleWidth(hints))) + hints, width);
|
|
388
|
+
const border = new DynamicBorder().render(width);
|
|
389
|
+
const budget = Math.max(1, this.tui.terminal.rows - border.length - 2);
|
|
390
|
+
const lines = this.tool.render(width);
|
|
391
|
+
this.scroll = Math.min(this.scroll, Math.max(0, lines.length - budget));
|
|
392
|
+
const end = lines.length - this.scroll;
|
|
393
|
+
// As tall as its content, so the Session shows above it.
|
|
394
|
+
return [...border, ...lines.slice(Math.max(0, end - budget), end), " ".repeat(width), footer];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
invalidate(): void {
|
|
398
|
+
this.tool.invalidate();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
dispose(): void {
|
|
402
|
+
stopTimer(this.tool);
|
|
403
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private sync(): void {
|
|
407
|
+
const view = this.programs.view(this.id);
|
|
408
|
+
if (view?.result) this.tool.updateResult(view.result as ToolResult, view.record.state === "running");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private stop(): void {
|
|
412
|
+
this.programs.stop(this.programs.target(this.id)).catch((error: unknown) =>
|
|
413
|
+
this.ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
331
417
|
/** A filterable list in place of the editor, like Pi Durable's conversation switcher. */
|
|
332
418
|
class ListSelector extends Container implements Focusable {
|
|
333
419
|
private readonly input = new Input();
|
|
@@ -404,12 +490,17 @@ export function ownedEntries(self: AgentNode): Entry[] {
|
|
|
404
490
|
return entries.sort((a, b) => Number(b.state !== "idle") - Number(a.state !== "idle") || (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
405
491
|
}
|
|
406
492
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
493
|
+
const PROGRAM_ITEM = "program:";
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* `/tasks`: picks an Agent in the tree or a background Program, live ones first and newest first, and opens it in
|
|
497
|
+
* the viewer. `paddingX` is Pi's editor padding, so the viewer's editor lines up as Pi's does.
|
|
498
|
+
*/
|
|
499
|
+
export async function openTaskViewer(ctx: ExtensionContext, self: AgentNode, programs: Programs | undefined, paddingX: number): Promise<void> {
|
|
410
500
|
const entries = ownedEntries(self);
|
|
411
|
-
|
|
412
|
-
|
|
501
|
+
const records = programs?.list() ?? [];
|
|
502
|
+
if (entries.length === 0 && records.length === 0) {
|
|
503
|
+
ctx.ui.notify("No tasks.", "info");
|
|
413
504
|
return;
|
|
414
505
|
}
|
|
415
506
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
@@ -421,13 +512,30 @@ export async function openAgentViewer(ctx: ExtensionContext, self: AgentNode, pa
|
|
|
421
512
|
return program?.program ? ` ‹ ${programLabel(program)}${owners(program.ownerId)}` : "";
|
|
422
513
|
};
|
|
423
514
|
const path = (entry: Entry): string => `${entry.name ?? entry.id}${owners(entry.ownerId)}`;
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
515
|
+
const tasks = [
|
|
516
|
+
...entries.map((entry) => ({ live: entry.state !== "idle", at: entry.createdAt ?? "", item: {
|
|
517
|
+
value: entry.id,
|
|
518
|
+
label: path(entry),
|
|
519
|
+
description: [entry.state, latestActivity(entry.sessionFile) ?? ""].filter(Boolean).join(" "),
|
|
520
|
+
} })),
|
|
521
|
+
...records.map((record) => ({ live: record.state === "running", at: new Date(record.startedAt).toISOString(), item: {
|
|
522
|
+
value: PROGRAM_ITEM + record.id,
|
|
523
|
+
label: `Program ${programs!.label(record)}`,
|
|
524
|
+
description: record.state,
|
|
525
|
+
} })),
|
|
526
|
+
];
|
|
527
|
+
const items: SelectItem[] = tasks
|
|
528
|
+
.sort((a, b) => Number(b.live) - Number(a.live) || b.at.localeCompare(a.at))
|
|
529
|
+
.map((task) => task.item);
|
|
429
530
|
const id = await ctx.ui.custom<string | undefined>((_tui, theme, keys, done) =>
|
|
430
|
-
new ListSelector("Open
|
|
531
|
+
new ListSelector("Open task:", items, theme, keys, (value) => done(value), () => done(undefined)));
|
|
532
|
+
if (id?.startsWith(PROGRAM_ITEM) && programs) {
|
|
533
|
+
await ctx.ui.custom<void>(
|
|
534
|
+
(tui, theme, keys, done) => new ProgramViewer(tui, theme, keys, ctx, programs, id.slice(PROGRAM_ITEM.length), () => done()),
|
|
535
|
+
{ overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } },
|
|
536
|
+
);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
431
539
|
const entry = id === undefined ? undefined : byId.get(id);
|
|
432
540
|
const owner = entry?.ownerId === undefined ? undefined : nodes.get(entry.ownerId);
|
|
433
541
|
if (!entry || !owner) return;
|
package/src/workbench.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import { readFile } from 'node:fs/promises';
|
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { styleText } from 'node:util';
|
|
9
10
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
|
-
import { alive, attach, listHosts, startHost } from './host/client.mjs';
|
|
11
|
+
import { alive, attach, listHosts, settledHosts, startHost } from './host/client.mjs';
|
|
11
12
|
import { detachKey, detachMatcher } from './host/keys.mjs';
|
|
12
13
|
import { piPackage } from './pi.mjs';
|
|
13
14
|
import { ensureDaemon, sendViaDaemon } from './daemon/client.mjs';
|
|
@@ -75,9 +76,9 @@ const COMMANDS = [
|
|
|
75
76
|
// Pi's working spinner.
|
|
76
77
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
77
78
|
const GROUPS = [
|
|
78
|
-
['attention', 'Needs
|
|
79
|
-
['working', 'Working', 'Sessions
|
|
80
|
-
['completed', 'Completed', 'Sessions
|
|
79
|
+
['attention', 'Needs attention', 'Sessions with a question for you or a reply you have not read'],
|
|
80
|
+
['working', 'Working', 'Sessions still working; they keep running when you close the terminal'],
|
|
81
|
+
['completed', 'Completed', 'Sessions with nothing new for you'],
|
|
81
82
|
];
|
|
82
83
|
|
|
83
84
|
function stateOf(session) {
|
|
@@ -112,6 +113,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
112
113
|
let sessions = [], stream, connected = false, quitting = false;
|
|
113
114
|
// `picked` is the Session last chosen with `@` and its mention; a message starting with the mention goes to it.
|
|
114
115
|
let selectedKey, top = 0, notice, modal, pane, picked, lastCtrlC = 0, finish;
|
|
116
|
+
// Messages in flight by Session ID; each settles once the daemon has delivered it, waking the Session if needed.
|
|
117
|
+
const sending = new Map();
|
|
115
118
|
|
|
116
119
|
/** How `@` refers to a Session: its name when that is one word, otherwise the shortest unique ID prefix. */
|
|
117
120
|
function mention(session) {
|
|
@@ -201,8 +204,9 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
201
204
|
// While the preview is open, the keys are its own: Enter may send a message, so `app.tools.expand` attaches.
|
|
202
205
|
const keys = pane ? [
|
|
203
206
|
hints.keyHint('app.tools.expand', 'attach'),
|
|
204
|
-
hints.rawKeyHint(
|
|
205
|
-
|
|
207
|
+
hints.rawKeyHint(['lineUp', 'halfPageUp', 'pageUp', 'lineDown', 'halfPageDown', 'pageDown']
|
|
208
|
+
.map(name => hints.keyText(`tui.altScreen.${name}`)).filter(Boolean).join('/'), 'scroll'),
|
|
209
|
+
hints.rawKeyHint(`${detach}/esc`, 'close'),
|
|
206
210
|
].join(theme.fg('muted', ' · ')) : [
|
|
207
211
|
hints.rawKeyHint('enter', 'attach'),
|
|
208
212
|
hints.rawKeyHint(detach, 'quit'),
|
|
@@ -275,8 +279,8 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
275
279
|
handleInput(data) {
|
|
276
280
|
// Holding only the preview's mention, the input counts as empty.
|
|
277
281
|
const empty = ['', pane?.mention].includes(editor.getText());
|
|
278
|
-
// As in a Session, the detach key leaves and everything keeps running.
|
|
279
|
-
if (isDetach(data)) return void quit();
|
|
282
|
+
// As in a Session, the detach key leaves and everything keeps running; in the preview, it returns to the list.
|
|
283
|
+
if (isDetach(data)) return void (pane ? pane.close() : quit());
|
|
280
284
|
if (pane?.handleInput(data, empty)) return;
|
|
281
285
|
if (T.matchesKey(data, 'ctrl+c')) {
|
|
282
286
|
if (!empty) return editor.setText(pane?.mention ?? '');
|
|
@@ -365,7 +369,10 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
365
369
|
async function openSession(session) {
|
|
366
370
|
stream?.send({ type: 'show', id: session.id });
|
|
367
371
|
try {
|
|
368
|
-
|
|
372
|
+
// A message just sent may be waking the Session in a host.
|
|
373
|
+
await sending.get(session.id);
|
|
374
|
+
const running = host => host.session?.id === session.id;
|
|
375
|
+
const hosted = (await settledHosts(paths, running)).find(running);
|
|
369
376
|
if (hosted) return await attachTo(hosted.socket);
|
|
370
377
|
if (session.running || session.status === 'unknown') {
|
|
371
378
|
return say(`${named(session)} runs outside a host; attach it after it exits.`, 'warning');
|
|
@@ -422,7 +429,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
422
429
|
catch (error) { return say(error.message, 'error'); }
|
|
423
430
|
let body = conversation(messages);
|
|
424
431
|
const border = themes.getEditorTheme().borderColor;
|
|
425
|
-
let scroll = 0, page = 1, timer;
|
|
432
|
+
let scroll = 0, page = 1, timer, scrolled, hideBar;
|
|
426
433
|
// A turn the preview shows counts as seen.
|
|
427
434
|
const see = () => stream?.send({ type: 'see', id: session.id });
|
|
428
435
|
see();
|
|
@@ -440,6 +447,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
440
447
|
watcher.close();
|
|
441
448
|
see();
|
|
442
449
|
clearTimeout(timer);
|
|
450
|
+
clearTimeout(hideBar);
|
|
443
451
|
// A draft stays, still addressed to the Session.
|
|
444
452
|
if (editor.getText() === pane?.mention) editor.setText('');
|
|
445
453
|
pane = undefined;
|
|
@@ -454,14 +462,23 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
454
462
|
invalidate() { body.invalidate(); },
|
|
455
463
|
/** A short conversation takes only its own height; a longer one takes `max` lines and scrolls. */
|
|
456
464
|
render(width, max) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
const
|
|
465
|
+
// The selected row and the input's mention name the Session, so the preview needs no title. A blank line
|
|
466
|
+
// sets it apart from the list.
|
|
467
|
+
const top = ['', border('─'.repeat(width))];
|
|
468
|
+
const bar = width > 1 ? settings.getFullscreenScrollbar() : 'hidden';
|
|
469
|
+
// The conversation starts right below the border.
|
|
470
|
+
const lines = body.render(bar === 'always' ? width - 1 : width);
|
|
471
|
+
while (lines.length && !T.stripTerminalSequences(lines[0]).trim()) lines.shift();
|
|
461
472
|
page = Math.max(1, max - 2);
|
|
462
473
|
scroll = Math.min(scroll, Math.max(0, lines.length - page));
|
|
463
|
-
const end = lines.length - scroll;
|
|
464
|
-
|
|
474
|
+
const end = lines.length - scroll, start = Math.max(0, end - page);
|
|
475
|
+
const shown = lines.slice(start, end);
|
|
476
|
+
if (bar !== 'always' && !(bar === 'auto' && scrolled && lines.length > page)) return [...top, ...shown];
|
|
477
|
+
// The scrollbar of Pi's fullscreen transcript, in the last column.
|
|
478
|
+
const track = shown.length, size = Math.max(Math.min(2, track), Math.min(track, Math.round(track * track / lines.length)));
|
|
479
|
+
const offset = lines.length === track ? 0 : Math.round(start / (lines.length - track) * (track - size));
|
|
480
|
+
const cell = row => row >= offset && row < offset + size ? theme.fg('scrollbarThumb', '┃') : theme.fg('scrollbarTrack', '│');
|
|
481
|
+
return [...top, ...shown.map((line, row) => `${T.truncateToWidth(line, width - 1, '', true)}${cell(row)}`)];
|
|
465
482
|
},
|
|
466
483
|
/** Enter, `↑`/`↓`, and Space act on the preview only while no message is typed. */
|
|
467
484
|
handleInput(data, empty) {
|
|
@@ -478,10 +495,17 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
478
495
|
close();
|
|
479
496
|
return true;
|
|
480
497
|
}
|
|
481
|
-
|
|
482
|
-
|
|
498
|
+
// The scroll keys of Pi's fullscreen mode; a step counts lines up from the end.
|
|
499
|
+
const half = Math.max(1, Math.floor(page / 2));
|
|
500
|
+
const steps = { lineUp: 1, lineDown: -1, halfPageUp: half, halfPageDown: -half, pageUp: page - 1, pageDown: 1 - page, top: Infinity, bottom: -Infinity };
|
|
501
|
+
const action = Object.keys(steps).find(name => keybindings.matches(data, `tui.altScreen.${name}`));
|
|
502
|
+
const step = empty && T.matchesKey(data, 'up') ? 1 : empty && T.matchesKey(data, 'down') ? -1 : action ? steps[action] : 0;
|
|
483
503
|
if (!step) return false;
|
|
484
504
|
scroll = Math.max(0, scroll + step);
|
|
505
|
+
// As in Pi's `auto` mode, the scrollbar shows while scrolling and for a second after.
|
|
506
|
+
scrolled = true;
|
|
507
|
+
clearTimeout(hideBar);
|
|
508
|
+
hideBar = setTimeout(() => { scrolled = false; ui.requestRender(); }, 1000);
|
|
485
509
|
ui.requestRender();
|
|
486
510
|
return true;
|
|
487
511
|
},
|
|
@@ -528,8 +552,12 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
528
552
|
async function send(session, body) {
|
|
529
553
|
say(`Sending to ${named(session)}…`);
|
|
530
554
|
const message = { id: randomUUID(), user: true, recipient: session.id, body };
|
|
555
|
+
const pending = sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
|
|
556
|
+
const settled = pending.then(() => {}, () => {});
|
|
557
|
+
sending.set(session.id, settled);
|
|
558
|
+
void settled.then(() => { if (sending.get(session.id) === settled) sending.delete(session.id); });
|
|
531
559
|
try {
|
|
532
|
-
const receipt = await
|
|
560
|
+
const receipt = await pending;
|
|
533
561
|
if (receipt?.accepted !== true || receipt.messageId !== message.id) throw new Error(`Invalid acknowledgement from ${named(session)}; it may have received the message.`);
|
|
534
562
|
say(`Sent to ${named(session)}.`);
|
|
535
563
|
} catch (error) { say(error.message, 'error'); }
|
|
@@ -587,7 +615,7 @@ export async function workbench({ pi: executable, paths, sessionDir }) {
|
|
|
587
615
|
themeSync.disableAutoSync();
|
|
588
616
|
await terminal.drainInput(1000);
|
|
589
617
|
ui.stop();
|
|
590
|
-
process.stdout.write(`${MAIN_SCREEN}Sessions keep running. To reopen
|
|
618
|
+
process.stdout.write(`${MAIN_SCREEN}${styleText('dim', 'Sessions keep running. To reopen:')} pd agents\n`);
|
|
591
619
|
finish();
|
|
592
620
|
}
|
|
593
621
|
|