@tshu-w/pi-agents 0.3.0 → 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,5 +1,5 @@
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
4
  import type { Programs } from "../programs/programs.ts";
5
5
  import type { Agents } from "./agents.ts";
@@ -34,7 +34,7 @@ export interface AgentNode {
34
34
  /** An Agent's background Programs, which the task viewer lists. */
35
35
  programs?: Programs;
36
36
  /** Delivers a message to this node; used only for root Agents. */
37
- receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender): void;
37
+ receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender, notice?: Notice): void;
38
38
  /** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
39
39
  stopped?: boolean;
40
40
  }
@@ -252,11 +252,30 @@ export function resolveIn(entries: Entry[], target: string): Entry {
252
252
  }
253
253
 
254
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. */
255
273
  export function deliver(
256
274
  from: { id: string; name?: string } | undefined,
257
275
  target: Entry,
258
276
  delivery: Delivery,
259
277
  body: string,
278
+ notice?: Notice,
260
279
  ): { queued: boolean } {
261
280
  const fromOwner = from !== undefined && target.ownerId === from.id;
262
281
  const ids = recipientIds(target);
@@ -265,12 +284,12 @@ export function deliver(
265
284
  if (target.ownerId === undefined) {
266
285
  const node = shared.nodes.get(target.id);
267
286
  if (!node) throw new Error(`${label(target, ids)} is offline.`);
268
- node.receive(text, delivery, undefined, by);
287
+ node.receive(text, delivery, undefined, by, notice);
269
288
  return { queued: false };
270
289
  }
271
290
  const owner = shared.nodes.get(target.ownerId);
272
291
  if (!owner) throw new Error(`${label(target, ids)} is offline.`);
273
- 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 });
274
293
  }
275
294
 
276
295
  export const MESSAGE_TAG = "agent-message";
@@ -307,10 +326,24 @@ export function messageBody(text: string, sender: Sender): string | undefined {
307
326
 
308
327
  /**
309
328
  * `messageId` marks a message from a root Agent in another process, so it is added once; `user`
310
- * 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.
311
330
  */
312
- export function customMessage(text: string, delivery: Delivery, { messageId, user, sender }: { messageId?: string; user?: boolean; sender?: Sender } = {}) {
313
- 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 } : {}) };
314
347
  }
315
348
 
316
349
  export function receivedMessageIds(ctx: ExtensionContext): string[] {