@tshu-w/pi-agents 0.1.0 → 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 +33 -15
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +52 -38
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +10 -6
- 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/daemon/sessions.mjs +7 -0
- package/src/host/client.mjs +25 -5
- 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 +78 -31
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;
|