@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.
@@ -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() || (info.mode & 0o777) !== 0o600) {
43
- throw fault('UNSAFE_PATH', 'Socket must be owned by the current user with mode 0600');
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 (value.role !== 'session' && value.role !== 'workbench') throw fault('INVALID_REQUEST', 'stream role must be session or workbench');
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 > MAX_BYTES) throw fault('FRAME_TOO_LARGE', 'JSONL frame exceeds 1 MiB');
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
- const decoder = new StringDecoder('utf8');
113
- let buffer = '';
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
- buffer += decoder.write(chunk);
116
- for (let newline; (newline = buffer.indexOf('\n')) >= 0;) {
117
- const line = buffer.slice(0, newline);
118
- buffer = buffer.slice(newline + 1);
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 (Buffer.byteLength(buffer) > MAX_BYTES) onInvalid();
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
- socket.write(encode({ ok: true, result: { pid: process.pid } }));
193
- return stream(input, channel(socket, signal));
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. Empty when none.
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
- rows.push({ line: `${" ".repeat(level)}${programLabel(child.program)} running`, parent, shown: false });
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
- rows.push({ line: `${" ".repeat(level)}Agent ${[label(entry, ids), entry.state, ...pending].join(" ")}`, parent, shown: false });
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 hide)`, ...lines.map((line) => ` ${line}`)];
72
+ return [`Tasks (${live} live, /tasks to view)`, ...lines.map((line) => ` ${line}`)];
52
73
  }
53
74
 
54
- /** Keeps the task panel above the editor current while the Session is loaded; `toggle` hides or shows it. */
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);
@@ -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
- /** `/agents`: picks an owned Agent and opens it in the viewer. */
408
- /** `paddingX` is Pi's editor padding, so the viewer's editor lines up as Pi's does. */
409
- export async function openAgentViewer(ctx: ExtensionContext, self: AgentNode, paddingX: number): Promise<void> {
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
- if (entries.length === 0) {
412
- ctx.ui.notify("No owned Agents.", "info");
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 items: SelectItem[] = entries.map((entry) => ({
425
- value: entry.id,
426
- label: path(entry),
427
- description: [entry.state, latestActivity(entry.sessionFile) ?? ""].filter(Boolean).join(" "),
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 Agent:", items, theme, keys, (value) => done(value), () => done(undefined)));
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 input', 'Sessions waiting on you or with a finished turn you have not seen land here'],
79
- ['working', 'Working', 'Sessions Pi is working on; they keep running after you leave'],
80
- ['completed', 'Completed', 'Sessions you have seen wait here'],
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(`${hints.keyText('tui.altScreen.pageUp')}/${hints.keyText('tui.altScreen.pageDown')}`, 'scroll'),
205
- hints.rawKeyHint('esc', 'close'),
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
- const hosted = (await listHosts(paths)).find(host => host.session?.id === session.id);
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
- const current = sessions.find(other => other.id === session.id) ?? session;
458
- const live = ['working', 'blocked', 'unknown'].includes(current.status) ? ` ${icon(current)} ${activity(current)}` : '';
459
- const head = truncate(` ${theme.bold(label(session))} ${theme.fg('dim', session.cwd ?? '')}${live}`, width);
460
- const lines = body.render(width);
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
- return [border('─'.repeat(width)), head, ...lines.slice(Math.max(0, end - page), end)];
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
- const step = empty && T.matchesKey(data, 'up') ? 1 : empty && T.matchesKey(data, 'down') ? -1
482
- : keybindings.matches(data, 'tui.altScreen.pageUp') ? page - 1 : keybindings.matches(data, 'tui.altScreen.pageDown') ? 1 - page : 0;
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 sendViaDaemon(paths, message, { sessionDir, launch: { command: executable, args: [], env } });
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, run pd agents\n`);
618
+ process.stdout.write(`${MAIN_SCREEN}${styleText('dim', 'Sessions keep running. To reopen:')} pd agents\n`);
591
619
  finish();
592
620
  }
593
621