@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.
@@ -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;