@tshu-w/pi-agents 0.2.2 → 0.4.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.
@@ -15,9 +15,12 @@ import { randomUUID } from "node:crypto";
15
15
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { boundText } from "../output.ts";
18
19
  import {
19
20
  customMessage,
20
21
  deliver,
22
+ messageParts,
23
+ type NotifiedResult,
21
24
  addProgramSpend,
22
25
  addSpend,
23
26
  label,
@@ -36,10 +39,12 @@ import {
36
39
  type Delivery,
37
40
  type Sender,
38
41
  type Entry,
42
+ type Notice,
39
43
  type Spend,
40
44
  type TreeMetadata,
41
45
  } from "./registry.ts";
42
- import { defaultSessionDir } from "../roots/paths.mjs";
46
+ import { releaseSession } from "../roots/guard.mjs";
47
+ import { defaultSessionDir, sessionDir as rootSessionDir } from "../roots/paths.mjs";
43
48
 
44
49
  const AGENT_ENTRY = "pi-agents-agent";
45
50
  const SHUTDOWN_TIMEOUT_MS = 10_000;
@@ -64,8 +69,6 @@ export interface InputRecord {
64
69
  result?: string;
65
70
  /** The value submitted with `submit_result`, for an input with a schema. */
66
71
  value?: unknown;
67
- read: boolean;
68
- notified: boolean;
69
72
  }
70
73
 
71
74
  export interface AgentRecord {
@@ -90,15 +93,20 @@ function childSessionDirectory(cwd: string, currentCwd: string, currentSessionDi
90
93
  return path.basename(current) === "subagents" ? current : path.join(current, "subagents");
91
94
  }
92
95
 
93
- function persistPreparedSession(sessionManager: SessionManager, cwd: string): SessionManager {
96
+ function writePreparedSession(sessionManager: SessionManager): string {
94
97
  const sessionFile = sessionManager.getSessionFile()!;
95
98
  mkdirSync(path.dirname(sessionFile), { recursive: true });
96
99
  // SessionManager flushes itself once it holds an assistant message (a forked context);
97
- // otherwise write the prepared entries so the child can be reopened.
100
+ // otherwise write the prepared entries so the Session can be reopened.
98
101
  if (!existsSync(sessionFile)) {
99
102
  const entries = [sessionManager.getHeader(), ...sessionManager.getEntries()];
100
103
  writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, { flag: "wx" });
101
104
  }
105
+ return sessionFile;
106
+ }
107
+
108
+ function persistPreparedSession(sessionManager: SessionManager, cwd: string): SessionManager {
109
+ const sessionFile = writePreparedSession(sessionManager);
102
110
  return SessionManager.open(sessionFile, path.dirname(sessionFile), cwd);
103
111
  }
104
112
 
@@ -147,7 +155,17 @@ export function steerQueued(agent: AgentSession["agent"] | undefined): boolean {
147
155
  // Pi does not report steers queued by extensions, so a wait checks the queue this often.
148
156
  export const STEER_POLL_MS = 250;
149
157
 
150
- export const WAIT_INTERRUPTED = "Stopped waiting for a new message. Wait again to keep waiting.";
158
+ export const RESULT_TAG = "agent-result";
159
+
160
+ /** How a wait ended: what it waited for ended, its timeout expired, another message ended it, or it had nothing to wait for. */
161
+ export type WaitEnd = "completed" | "timedOut" | "interrupted" | "nothing";
162
+
163
+ /** The text `wait` returns, naming what it still waits for as `still` does. */
164
+ export function waitText(end: WaitEnd, still: string): string {
165
+ if (end === "nothing") return "Nothing to wait for.";
166
+ if (end === "completed") return "Wait completed.";
167
+ return `${end === "timedOut" ? "Wait timed out." : "Wait interrupted by a new message."} ${still}`;
168
+ }
151
169
 
152
170
  export interface Deferred {
153
171
  promise: Promise<void>;
@@ -205,6 +223,7 @@ interface Input {
205
223
  user?: boolean;
206
224
  /** The Agent that sent it. */
207
225
  sender?: Sender;
226
+ notice?: Notice;
208
227
  record?: InputRecord;
209
228
  permit?: symbol;
210
229
  schema?: unknown;
@@ -214,7 +233,7 @@ interface Input {
214
233
  silent?: boolean;
215
234
  }
216
235
 
217
- type Message = Pick<Input, "text" | "user" | "sender">;
236
+ type Message = Pick<Input, "text" | "user" | "sender" | "notice">;
218
237
 
219
238
  interface Turn {
220
239
  inputs: Input[];
@@ -245,7 +264,6 @@ interface Owned {
245
264
  session?: AgentSession;
246
265
  loading?: Promise<AgentSession>;
247
266
  prepared?: Prepared;
248
- waiters: number;
249
267
  /** Its turn was stopped: messages from other Agents become writes until its owner's or user's next input. */
250
268
  stopped?: boolean;
251
269
  }
@@ -259,23 +277,47 @@ export interface SpawnRequest {
259
277
  thinkingLevel?: ThinkingLevel;
260
278
  }
261
279
 
262
- export interface WaitResult {
263
- agent: AgentRecord;
264
- input: InputRecord;
265
- history: boolean;
280
+ /** A notification a wait holds; every wait for its Agent or Program holds it, and the first to end delivers it. */
281
+ interface HeldNotice {
282
+ text: string;
283
+ result: NotifiedResult;
284
+ images?: NonNullable<Notice["images"]>;
285
+ delivered?: boolean;
286
+ }
287
+
288
+ /** A wait in progress: the IDs of the Agents or Programs it waits for, and their notifications it holds. */
289
+ export interface Held {
290
+ ids: Set<string>;
291
+ notices: HeldNotice[];
292
+ }
293
+
294
+ /** Has the waits in progress for `id` hold a notification; returns whether any does. */
295
+ export function hold(waits: Held[], id: string, notice: HeldNotice): boolean {
296
+ const holding = waits.filter(({ ids }) => ids.has(id));
297
+ for (const wait of holding) wait.notices.push(notice);
298
+ return holding.length > 0;
299
+ }
300
+
301
+ /** The notifications a wait holds that no other wait has delivered, as one notification, or undefined if none. */
302
+ export function release(wait: Held): { text: string; notice: Notice } | undefined {
303
+ const notices = wait.notices.filter(({ delivered }) => !delivered);
304
+ if (notices.length === 0) return undefined;
305
+ for (const notice of notices) notice.delivered = true;
306
+ const images = notices.flatMap((notice) => notice.images ?? []);
307
+ return { text: notices.map(({ text }) => text).join("\n\n"), notice: { results: notices.map(({ result }) => result), ...(images.length ? { images } : {}) } };
266
308
  }
267
309
 
268
310
  export interface WaitOutcome {
269
- results: WaitResult[];
311
+ end: WaitEnd;
270
312
  pending: AgentRecord[];
271
- /** A steer ended the wait. */
272
- interrupted?: boolean;
273
313
  }
274
314
 
275
315
  /** The Agents owned by one loaded Agent. */
276
316
  export class Agents {
277
317
  /** Waits in progress that keep this Agent's turn `waiting`. */
278
318
  waiting = 0;
319
+ /** The notifications held by the waits in progress. */
320
+ private held: Held[] = [];
279
321
  private agents = new Map<string, Owned>();
280
322
  private change = deferred();
281
323
  private closing = false;
@@ -409,7 +451,6 @@ export class Agents {
409
451
  },
410
452
  queue: [],
411
453
  prepared,
412
- waiters: 0,
413
454
  };
414
455
  this.agents.set(agent.record.id, agent);
415
456
  return agent;
@@ -450,31 +491,30 @@ export class Agents {
450
491
  input.submitted = { value: args.value };
451
492
  }
452
493
 
453
- accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notification: boolean; sender?: Sender }): { queued: boolean } {
494
+ accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notice?: Notice; sender?: Sender }): { queued: boolean } {
454
495
  const agent = this.agent(id);
455
496
  if (agent.stopped && !options.fromOwner) delivery = "write";
456
497
  if (delivery === "write") {
457
- this.write(agent, text, options.sender);
498
+ this.write(agent, text, options.sender, options.notice);
458
499
  return { queued: false };
459
500
  }
460
501
  // Notifications keep their owner informed even at the input limit.
461
- const permit = options.notification ? undefined : this.reserve();
462
- const input: Input = { text, sender: options.sender, permit, record: options.fromOwner && !options.notification ? this.newRecord(agent) : undefined };
502
+ const permit = options.notice ? undefined : this.reserve();
503
+ const input: Input = { text, sender: options.sender, notice: options.notice, permit, record: options.fromOwner && !options.notice ? this.newRecord(agent) : undefined };
463
504
  return { queued: this.add(agent, input, delivery) };
464
505
  }
465
506
 
466
- async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<WaitOutcome> {
467
- const selected = targets ?? [...this.agents.values()].filter((agent) =>
468
- this.pending(agent) || agent.record.inputs.some((input) => ended(input) && !input.read));
469
- const unique = [...new Set(selected)];
470
- const replay = new Map(unique.map((agent) => [agent, history === 0
471
- ? []
472
- : agent.record.inputs.filter((input) => ended(input) && input.read).slice(-history)]));
473
- for (const agent of unique) agent.waiters += 1;
474
- const blocking = unique.some((agent) => this.pending(agent));
475
- const suspended = blocking && this.self.ownerId !== undefined && scheduler.suspend(this.self.rootId, this.self.id);
476
- if (blocking) this.markWaiting(1);
477
- let returned = false;
507
+ /**
508
+ * Waits until `targets`, or the Agents with pending inputs, have none; a steer ends it early. Their notifications
509
+ * are held until it ends, then delivered as one.
510
+ */
511
+ async wait(targets: Owned[] | undefined, timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<WaitOutcome> {
512
+ const unique = [...new Set(targets ?? [...this.agents.values()])].filter((agent) => this.pending(agent));
513
+ if (unique.length === 0) return { end: "nothing", pending: [] };
514
+ const held: Held = { ids: new Set(unique.map((agent) => agent.record.id)), notices: [] };
515
+ this.held.push(held);
516
+ const suspended = this.self.ownerId !== undefined && scheduler.suspend(this.self.rootId, this.self.id);
517
+ this.markWaiting(1);
478
518
  let interrupted = false;
479
519
  try {
480
520
  const deadline = Date.now() + timeoutSeconds * 1000;
@@ -489,42 +529,17 @@ export class Agents {
489
529
  await waitForChange(this.change.promise, steered ? Math.min(remaining, STEER_POLL_MS) : remaining, signal);
490
530
  }
491
531
  signal?.throwIfAborted();
492
- const results: WaitResult[] = [];
493
- for (const agent of unique) {
494
- for (const input of replay.get(agent)!) results.push({ agent: agent.record, input, history: true });
495
- for (const input of agent.record.inputs) {
496
- if (ended(input) && !input.read) results.push({ agent: agent.record, input, history: false });
497
- }
498
- }
499
- returned = true;
500
- return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record), ...(interrupted && { interrupted }) };
532
+ const pending = unique.filter((agent) => this.pending(agent)).map((agent) => agent.record);
533
+ return { end: pending.length === 0 ? "completed" : interrupted ? "interrupted" : "timedOut", pending };
501
534
  } finally {
502
- if (blocking) this.markWaiting(-1);
535
+ this.held.splice(this.held.indexOf(held), 1);
536
+ const released = this.closing ? undefined : release(held);
537
+ if (released) this.notify(released.text, released.notice);
538
+ this.markWaiting(-1);
503
539
  if (suspended) await scheduler.resume(this.self.rootId, this.self.id, this.limits.maxConcurrent, signal);
504
- for (const agent of unique) {
505
- agent.waiters -= 1;
506
- let notified = false;
507
- for (const input of agent.record.inputs) {
508
- if (!ended(input) || input.notified) continue;
509
- if (returned) input.notified = notified = true;
510
- else this.notifyLater(agent, input);
511
- }
512
- if (notified) this.persist(agent);
513
- }
514
540
  }
515
541
  }
516
542
 
517
- markRead(results: WaitResult[]): void {
518
- const changed = new Set<string>();
519
- for (const { agent, input, history } of results) {
520
- if (history || input.read) continue;
521
- input.read = true;
522
- changed.add(agent.id);
523
- }
524
- for (const id of changed) this.persist(this.agents.get(id)!);
525
- if (changed.size > 0) treeChanged();
526
- }
527
-
528
543
  /**
529
544
  * Stops the current turn and withdraws queued inputs; returns whether there was anything to stop.
530
545
  * A turn that has not stopped by `deadline` is abandoned: its inputs end as aborted, and this throws.
@@ -578,14 +593,13 @@ export class Agents {
578
593
  records.set(record.id, record);
579
594
  }
580
595
  for (const record of records.values()) {
581
- const agent: Owned = { record, queue: [], waiters: 0 };
596
+ const agent: Owned = { record, queue: [] };
582
597
  this.agents.set(record.id, agent);
583
598
  if (!record.inputs.some((input) => !ended(input))) continue;
584
599
  for (const input of record.inputs) {
585
600
  if (ended(input)) continue;
586
601
  input.state = "aborted";
587
602
  input.result = "The process exited before this input ended.";
588
- input.notified = true;
589
603
  }
590
604
  this.persist(agent);
591
605
  }
@@ -621,8 +635,8 @@ export class Agents {
621
635
  if (errors.length > 0) throw new Error(errors.map((error) => error instanceof Error ? error.message : String(error)).join("\n"));
622
636
  }
623
637
 
624
- private write(agent: Owned, text: string, from?: Sender): void {
625
- const message = customMessage(text, "write", { sender: from });
638
+ private write(agent: Owned, text: string, from?: Sender, notice?: Notice): void {
639
+ const message = customMessage(text, "write", { sender: from, notice });
626
640
  const session = agent.session;
627
641
  if (session?.isStreaming) {
628
642
  session.agent.steer({ role: "custom", ...message, timestamp: Date.now() });
@@ -664,9 +678,9 @@ export class Agents {
664
678
  this.changed();
665
679
  if (!turn.started) return;
666
680
  if (agent.session?.isStreaming) {
667
- agent.session.agent.steer({ role: "custom", ...customMessage(input.text, "steer", { user: input.user, sender: input.sender }), timestamp: Date.now() });
681
+ agent.session.agent.steer({ role: "custom", ...customMessage(input.text, "steer", { user: input.user, sender: input.sender, notice: input.notice }), timestamp: Date.now() });
668
682
  } else {
669
- turn.pending.push({ text: input.text, user: input.user, sender: input.sender });
683
+ turn.pending.push({ text: input.text, user: input.user, sender: input.sender, notice: input.notice });
670
684
  }
671
685
  }
672
686
 
@@ -692,7 +706,7 @@ export class Agents {
692
706
  for (const input of turn.inputs) if (input.record) input.record.state = "running";
693
707
  this.persist(agent);
694
708
  this.changed();
695
- let batch: Message[] = turn.inputs.map(({ text, user, sender }) => ({ text, user, sender }));
709
+ let batch: Message[] = turn.inputs.map(({ text, user, sender, notice }) => ({ text, user, sender, notice }));
696
710
  while (batch.length > 0 && !turn.abort.signal.aborted) {
697
711
  for (const { text, ...rest } of batch.slice(0, -1)) await session.sendCustomMessage(customMessage(text, "steer", rest), { triggerTurn: false });
698
712
  const { text, ...rest } = batch.at(-1)!;
@@ -704,7 +718,7 @@ export class Agents {
704
718
  }
705
719
  batch = [
706
720
  ...turn.pending.splice(0),
707
- ...leftover.flatMap(({ content, details }) => details.delivery === "write" ? [] : [{ text: content, user: details.user, sender: details.sender }]),
721
+ ...leftover.flatMap((message) => message.details.delivery === "write" ? [] : [messageParts(message)]),
708
722
  ];
709
723
  }
710
724
  const last = lastAnswer(session, before);
@@ -744,10 +758,9 @@ export class Agents {
744
758
  }
745
759
  record.state = outcome;
746
760
  record.result = result;
747
- if (input.silent) record.notified = true;
748
761
  this.persist(agent);
749
762
  input.done?.resolve();
750
- if (agent.waiters === 0 && !input.silent) this.notifyLater(agent, record);
763
+ if (!input.silent) this.notifyLater(agent, record);
751
764
  }
752
765
 
753
766
  private notifyLater(agent: Owned, record: InputRecord): void {
@@ -756,10 +769,12 @@ export class Agents {
756
769
  const release = trackTreeWork(this.self.rootId);
757
770
  queueMicrotask(() => {
758
771
  try {
759
- if (this.closing || record.notified || agent.waiters > 0) return;
760
- record.notified = true;
761
- this.persist(agent);
762
- this.notify(`Agent ${label(agent.record, this.ids())} ${record.state}.`);
772
+ if (this.closing) return;
773
+ const { kept, notice } = boundText(record.result ?? "", "pi-agents-notification");
774
+ const id = shortId(agent.record.id, this.ids());
775
+ const text = `<${RESULT_TAG} name="${agent.record.name}" id="${id}" status="${record.state}">\n${kept}\n</${RESULT_TAG}>${notice ? `\n\n${notice}` : ""}`;
776
+ const result = { name: agent.record.name, id, status: record.state, body: kept, ...(notice && { notice }) };
777
+ if (!hold(this.held, agent.record.id, { text, result })) this.notify(text, { results: [result] });
763
778
  } finally {
764
779
  release();
765
780
  }
@@ -767,9 +782,9 @@ export class Agents {
767
782
  }
768
783
 
769
784
  /** Delivers a notification to this Agent as a steer. */
770
- notify(text: string): void {
785
+ notify(text: string, notice: Notice): void {
771
786
  try {
772
- deliver(undefined, { id: this.self.id, name: this.self.name(), ownerId: this.self.ownerId, cwd: "", state: "running" }, "steer", text);
787
+ deliver(undefined, { id: this.self.id, name: this.self.name(), ownerId: this.self.ownerId, cwd: "", state: "running" }, "steer", text, notice);
773
788
  } catch (error) {
774
789
  console.warn(`[pi-agents] notification failed: ${error instanceof Error ? error.message : String(error)}`);
775
790
  }
@@ -808,7 +823,7 @@ export class Agents {
808
823
  }
809
824
 
810
825
  private newRecord(agent: Owned): InputRecord {
811
- const record: InputRecord = { id: `${agent.record.id}:${agent.record.inputs.length + 1}`, state: "queued", read: false, notified: false };
826
+ const record: InputRecord = { id: `${agent.record.id}:${agent.record.inputs.length + 1}`, state: "queued" };
812
827
  agent.record.inputs.push(record);
813
828
  return record;
814
829
  }
@@ -837,7 +852,31 @@ export class Agents {
837
852
  return this.ctx;
838
853
  }
839
854
 
840
- private prepare(request: Omit<SpawnRequest, "message">, name: string, id?: string): Prepared {
855
+ /** Creates the Session of a root Agent the caller does not own; a message to it starts it. */
856
+ disown(request: Omit<SpawnRequest, "message" | "context">): { id: string; name: string } {
857
+ const ctx = this.requireContext();
858
+ const name = request.name.trim();
859
+ const env = ctx.sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === "env-state").pop();
860
+ if (env?.type === "custom" && (env.data as { environment?: string | null }).environment) throw new Error("An Agent in a pi-env environment cannot disown an Agent.");
861
+ const { model, thinkingLevel } = this.resolveModel(request);
862
+ const cwd = path.resolve(ctx.cwd, request.cwd ?? ".");
863
+ // Where Pi keeps the cwd's Sessions without `--session-dir`, which the daemon searches.
864
+ const configured = path.resolve(rootSessionDir());
865
+ const sessionDir = configured === path.resolve(this.agentDir, "sessions") ? defaultSessionDir(cwd, this.agentDir) : configured;
866
+ const sessionManager = SessionManager.create(cwd, sessionDir);
867
+ sessionManager.appendSessionInfo(name);
868
+ sessionManager.appendModelChange(model.provider, model.id);
869
+ sessionManager.appendThinkingLevelChange(thinkingLevel);
870
+ try {
871
+ writePreparedSession(sessionManager);
872
+ } finally {
873
+ // The Session runs in a process of its own.
874
+ releaseSession(sessionManager);
875
+ }
876
+ return { id: sessionManager.getSessionId(), name };
877
+ }
878
+
879
+ private resolveModel(request: Pick<SpawnRequest, "model" | "thinkingLevel">): Pick<Prepared, "model" | "thinkingLevel"> {
841
880
  const ctx = this.requireContext();
842
881
  let model = ctx.model;
843
882
  if (request.model !== undefined) {
@@ -848,7 +887,12 @@ export class Agents {
848
887
  if (!model) throw new Error(`Unknown model "${request.model}". Use provider/modelId.`);
849
888
  }
850
889
  if (!model) throw new Error("Cannot spawn an Agent without a model.");
851
- const thinkingLevel = request.thinkingLevel ?? this.pi.getThinkingLevel();
890
+ return { model, thinkingLevel: request.thinkingLevel ?? this.pi.getThinkingLevel() };
891
+ }
892
+
893
+ private prepare(request: Omit<SpawnRequest, "message">, name: string, id?: string): Prepared {
894
+ const ctx = this.requireContext();
895
+ const { model, thinkingLevel } = this.resolveModel(request);
852
896
  // In a pi-env execution environment, the Agent runs in the same environment and `cwd` is a path there.
853
897
  const envEntry = ctx.sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === "env-state").pop();
854
898
  const envState = envEntry?.type === "custom" ? structuredClone(envEntry.data) as { environment?: string | null } : undefined;
@@ -1,6 +1,7 @@
1
1
  /** The Agents loaded in this process, shared across trees: who sees whom, how targets resolve, and how messages are addressed. */
2
- import type { Usage } from "@earendil-works/pi-ai";
2
+ import type { ImageContent, Usage } from "@earendil-works/pi-ai";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Programs } from "../programs/programs.ts";
4
5
  import type { Agents } from "./agents.ts";
5
6
  import { TreeScheduler } from "./scheduler.ts";
6
7
 
@@ -30,8 +31,10 @@ export interface AgentNode {
30
31
  busy(): boolean;
31
32
  sessionFile(): string | undefined;
32
33
  agents: Agents;
34
+ /** An Agent's background Programs, which the task viewer lists. */
35
+ programs?: Programs;
33
36
  /** Delivers a message to this node; used only for root Agents. */
34
- receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender): void;
37
+ receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender, notice?: Notice): void;
35
38
  /** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
36
39
  stopped?: boolean;
37
40
  }
@@ -118,19 +121,35 @@ export function programsOf(node: AgentNode): AgentNode[] {
118
121
  .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
119
122
  }
120
123
 
121
- /** The node's running owned Agents and background Programs, with those under them. */
122
- export function liveWork(node: AgentNode): number {
123
- let count = 0;
124
- for (const entry of node.agents.owned()) {
125
- if (entry.state !== "idle") count += 1;
124
+ /** An owned Agent or a Program in the user's view of a tree, with the work under it. */
125
+ export type Task = { entry: Entry; owner: AgentNode; tasks: Task[] } | { program: AgentNode; tasks: Task[] };
126
+
127
+ /**
128
+ * The work under a node as the task panel and the task viewer show it, oldest first: its owned Agents, and
129
+ * its Programs that run in the background or have Agents. A foreground Program without Agents shows only in
130
+ * its caller's conversation.
131
+ */
132
+ export function tasksOf(node: AgentNode): Task[] {
133
+ const tasks: Array<{ at: string; task: Task }> = node.agents.owned().map((entry) => {
126
134
  const loaded = shared.nodes.get(entry.id);
127
- if (loaded) count += liveWork(loaded);
128
- }
135
+ return { at: entry.createdAt ?? "", task: { entry: { ...entry, ownerId: node.id }, owner: node, tasks: loaded ? tasksOf(loaded) : [] } };
136
+ });
129
137
  for (const program of programsOf(node)) {
130
- if (program.background) count += 1;
131
- count += liveWork(program);
138
+ const under = tasksOf(program);
139
+ if (program.background || under.length > 0) tasks.push({ at: program.createdAt ?? "", task: { program, tasks: under } });
132
140
  }
133
- return count;
141
+ return tasks.sort((a, b) => a.at.localeCompare(b.at)).map(({ task }) => task);
142
+ }
143
+
144
+ /** Whether a task is live work: an Agent that is not idle, or a background Program. */
145
+ export function taskLive(task: Task): boolean {
146
+ return "program" in task ? task.program.background === true : task.entry.state !== "idle";
147
+ }
148
+
149
+ /** The node's live work, with that under it. */
150
+ export function liveWork(node: AgentNode): number {
151
+ const count = (tasks: Task[]): number => tasks.reduce((sum, task) => sum + Number(taskLive(task)) + count(task.tasks), 0);
152
+ return count(tasksOf(node));
134
153
  }
135
154
 
136
155
  /** `Program <short ID>`, unique among its caller's running Programs. */
@@ -233,11 +252,30 @@ export function resolveIn(entries: Entry[], target: string): Entry {
233
252
  }
234
253
 
235
254
  /** Sends a message from one Agent to another visible Agent. */
255
+ /** A result a notification carries, as it shows: a Program has no name, and may have a wall time. */
256
+ export interface NotifiedResult {
257
+ name?: string;
258
+ id: string;
259
+ status: string;
260
+ seconds?: number;
261
+ /** The text kept within Pi's output limits, and the notice naming the file with the rest. */
262
+ body: string;
263
+ notice?: string;
264
+ }
265
+
266
+ /** What a notification carries: its results, and their images. */
267
+ export interface Notice {
268
+ results: NotifiedResult[];
269
+ images?: ImageContent[];
270
+ }
271
+
272
+ /** Delivers a message from `from`, or with `notice` a notification. */
236
273
  export function deliver(
237
274
  from: { id: string; name?: string } | undefined,
238
275
  target: Entry,
239
276
  delivery: Delivery,
240
277
  body: string,
278
+ notice?: Notice,
241
279
  ): { queued: boolean } {
242
280
  const fromOwner = from !== undefined && target.ownerId === from.id;
243
281
  const ids = recipientIds(target);
@@ -246,12 +284,12 @@ export function deliver(
246
284
  if (target.ownerId === undefined) {
247
285
  const node = shared.nodes.get(target.id);
248
286
  if (!node) throw new Error(`${label(target, ids)} is offline.`);
249
- node.receive(text, delivery, undefined, by);
287
+ node.receive(text, delivery, undefined, by, notice);
250
288
  return { queued: false };
251
289
  }
252
290
  const owner = shared.nodes.get(target.ownerId);
253
291
  if (!owner) throw new Error(`${label(target, ids)} is offline.`);
254
- return owner.agents.accept(target.id, text, delivery, { fromOwner, notification: from === undefined, sender: by });
292
+ return owner.agents.accept(target.id, text, delivery, { fromOwner, notice, sender: by });
255
293
  }
256
294
 
257
295
  export const MESSAGE_TAG = "agent-message";
@@ -288,10 +326,24 @@ export function messageBody(text: string, sender: Sender): string | undefined {
288
326
 
289
327
  /**
290
328
  * `messageId` marks a message from a root Agent in another process, so it is added once; `user`
291
- * marks the user's input from the task viewer, and `sender` the Agent that sent it.
329
+ * marks the user's input from the task viewer, `sender` the Agent that sent it, and `notice` a notification.
292
330
  */
293
- export function customMessage(text: string, delivery: Delivery, { messageId, user, sender }: { messageId?: string; user?: boolean; sender?: Sender } = {}) {
294
- return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}), ...(sender ? { sender } : {}) } };
331
+ export function customMessage(text: string, delivery: Delivery, { messageId, user, sender, notice }: { messageId?: string; user?: boolean; sender?: Sender; notice?: Notice } = {}) {
332
+ return {
333
+ customType: MESSAGE_TYPE,
334
+ content: notice?.images?.length ? [{ type: "text" as const, text }, ...notice.images] : text,
335
+ display: true,
336
+ details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}), ...(sender ? { sender } : {}), ...(notice ? { results: notice.results } : {}) },
337
+ };
338
+ }
339
+
340
+ /** The text, sender, and notice of a message `customMessage` made. */
341
+ export function messageParts({ content, details }: { content: string | Array<{ type: string }>; details: { user?: boolean; sender?: Sender; results?: NotifiedResult[] } }) {
342
+ const parts = typeof content === "string" ? [{ type: "text", text: content }] : content;
343
+ const text = parts.flatMap((part) => part.type === "text" ? [(part as { text: string }).text] : []).join("\n");
344
+ const images = parts.filter((part) => part.type === "image") as ImageContent[];
345
+ const notice = details.results && { results: details.results, ...(images.length ? { images } : {}) };
346
+ return { text, user: details.user, sender: details.sender, ...(notice ? { notice } : {}) };
295
347
  }
296
348
 
297
349
  export function receivedMessageIds(ctx: ExtensionContext): string[] {