@tshu-w/pi-agents 0.1.1 → 0.2.1

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.
@@ -18,12 +18,15 @@ import { fileURLToPath } from "node:url";
18
18
  import {
19
19
  customMessage,
20
20
  deliver,
21
+ addProgramSpend,
22
+ addSpend,
21
23
  label,
22
24
  messageText,
23
25
  MESSAGE_TYPE,
24
26
  nodes,
25
27
  resolveTarget,
26
28
  scheduler,
29
+ sender,
27
30
  shortId,
28
31
  TREE_ENTRY,
29
32
  trackTreeWork,
@@ -31,15 +34,18 @@ import {
31
34
  visibleIds,
32
35
  type AgentState,
33
36
  type Delivery,
37
+ type Sender,
34
38
  type Entry,
39
+ type Spend,
35
40
  type TreeMetadata,
36
41
  } from "./registry.ts";
42
+ import { defaultSessionDir } from "../roots/paths.mjs";
37
43
 
38
44
  const AGENT_ENTRY = "pi-agents-agent";
39
45
  const SHUTDOWN_TIMEOUT_MS = 10_000;
40
46
  const EXTENSION_PATH = fileURLToPath(new URL("../index.ts", import.meta.url));
41
47
  export const SUBMIT_RESULT_TOOL_NAME = "submit_result";
42
- export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
48
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
43
49
 
44
50
  export type Outcome = "completed" | "failed" | "aborted";
45
51
  type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
@@ -72,16 +78,11 @@ export interface AgentRecord {
72
78
  }
73
79
 
74
80
 
75
- function defaultSessionDirectory(cwd: string, agentDir: string): string {
76
- const safePath = `--${path.resolve(cwd).replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
77
- return path.join(path.resolve(agentDir), "sessions", safePath);
78
- }
79
-
80
81
  function childSessionDirectory(cwd: string, currentCwd: string, currentSessionDir: string, agentDir: string): string {
81
- const targetDefault = defaultSessionDirectory(cwd, agentDir);
82
+ const targetDefault = defaultSessionDir(cwd, agentDir);
82
83
  if (!currentSessionDir) return path.join(targetDefault, "subagents");
83
84
  const current = path.resolve(currentSessionDir);
84
- const currentDefault = defaultSessionDirectory(currentCwd, agentDir);
85
+ const currentDefault = defaultSessionDir(currentCwd, agentDir);
85
86
  if (current === currentDefault ||
86
87
  (path.basename(current) === "subagents" && path.dirname(current) === currentDefault)) {
87
88
  return path.join(targetDefault, "subagents");
@@ -121,8 +122,8 @@ function lastAnswer(session: AgentSession, before: number): { outcome: Outcome;
121
122
  return { outcome: "completed", result: text };
122
123
  }
123
124
 
124
- /** Takes this extension's messages left in the agent queues after a run settles. */
125
- function takeQueued(agent: AgentSession["agent"]) {
125
+ /** Takes this extension's messages out of the agent queues. */
126
+ export function takeQueued(agent: AgentSession["agent"]) {
126
127
  // peekQueuedMessages returns one message in one-at-a-time mode; read the queues whole.
127
128
  const queues = agent as unknown as Record<"steeringQueue" | "followUpQueue", { messages: QueuedMessage[] }>;
128
129
  const steering = queues.steeringQueue.messages.slice();
@@ -135,6 +136,19 @@ function takeQueued(agent: AgentSession["agent"]) {
135
136
  return [...steering, ...followUps].filter(ours);
136
137
  }
137
138
 
139
+ /** Whether a steer, which this turn must take in before its next model call, waits in the agent's queue; a write does not count. */
140
+ export function steerQueued(agent: AgentSession["agent"] | undefined): boolean {
141
+ if (!agent) return false;
142
+ const queue = (agent as unknown as { steeringQueue: { messages: QueuedMessage[] } }).steeringQueue.messages;
143
+ return queue.some((message) => !(message.role === "custom" && message.customType === MESSAGE_TYPE
144
+ && (message as { details?: { delivery?: string } }).details?.delivery === "write"));
145
+ }
146
+
147
+ // Pi does not report steers queued by extensions, so a wait checks the queue this often.
148
+ export const STEER_POLL_MS = 250;
149
+
150
+ export const WAIT_INTERRUPTED = "Stopped waiting for a new message. Wait again to keep waiting.";
151
+
138
152
  export interface Deferred {
139
153
  promise: Promise<void>;
140
154
  resolve(): void;
@@ -187,8 +201,10 @@ function ended(input: InputRecord): boolean {
187
201
 
188
202
  interface Input {
189
203
  text: string;
190
- /** The user's input from the Agent viewer. */
204
+ /** The user's input from the task viewer. */
191
205
  user?: boolean;
206
+ /** The Agent that sent it. */
207
+ sender?: Sender;
192
208
  record?: InputRecord;
193
209
  permit?: symbol;
194
210
  schema?: unknown;
@@ -198,7 +214,7 @@ interface Input {
198
214
  silent?: boolean;
199
215
  }
200
216
 
201
- type Message = Pick<Input, "text" | "user">;
217
+ type Message = Pick<Input, "text" | "user" | "sender">;
202
218
 
203
219
  interface Turn {
204
220
  inputs: Input[];
@@ -208,6 +224,10 @@ interface Turn {
208
224
  started: boolean;
209
225
  /** Whether the turn holds a scheduler slot. */
210
226
  slot: boolean;
227
+ /** When the turn got its slot, in milliseconds. */
228
+ since?: number;
229
+ /** What the turn's replies have cost. */
230
+ spent: Spend;
211
231
  finished: boolean;
212
232
  done: Deferred;
213
233
  }
@@ -226,6 +246,8 @@ interface Owned {
226
246
  loading?: Promise<AgentSession>;
227
247
  prepared?: Prepared;
228
248
  waiters: number;
249
+ /** Its turn was stopped: messages from other Agents become writes until its owner's or user's next input. */
250
+ stopped?: boolean;
229
251
  }
230
252
 
231
253
  export interface SpawnRequest {
@@ -246,6 +268,8 @@ export interface WaitResult {
246
268
  export interface WaitOutcome {
247
269
  results: WaitResult[];
248
270
  pending: AgentRecord[];
271
+ /** A steer ended the wait. */
272
+ interrupted?: boolean;
249
273
  }
250
274
 
251
275
  /** The Agents owned by one loaded Agent. */
@@ -287,11 +311,11 @@ export class Agents {
287
311
  return [...this.agents.values()].some((agent) => agent.turn !== undefined || agent.queue.length > 0);
288
312
  }
289
313
 
290
- /** An owned Agent's state, and its inputs pending behind its current or next turn. */
291
- counts(id: string): { state: AgentState; pending: number } | undefined {
314
+ /** An owned Agent's state, its inputs pending behind its current or next turn, when its turn got a slot, and what the turn has cost. */
315
+ counts(id: string): { state: AgentState; pending: number; since?: number; spent?: Spend } | undefined {
292
316
  const agent = this.agents.get(id);
293
317
  if (!agent) return undefined;
294
- return { state: this.state(agent), pending: agent.queue.length };
318
+ return { state: this.state(agent), pending: agent.queue.length, since: agent.turn?.since, spent: agent.turn?.spent };
295
319
  }
296
320
 
297
321
  private state(agent: Owned): AgentState {
@@ -362,8 +386,9 @@ export class Agents {
362
386
  scheduler.releaseOutstanding(this.self.rootId, permit);
363
387
  throw error;
364
388
  }
365
- const text = messageText({ id: this.self.id, name: this.self.name() }, true, "followUp", request.message, this.ids());
366
- const queued = this.enqueue(agent, { text, permit, record: this.newRecord(agent) });
389
+ const self = { id: this.self.id, name: this.self.name() };
390
+ const text = messageText(self, true, "followUp", request.message, this.ids());
391
+ const queued = this.enqueue(agent, { text, sender: sender(self, this.ids()), permit, record: this.newRecord(agent) });
367
392
  return { record: agent.record, queued };
368
393
  }
369
394
 
@@ -391,16 +416,27 @@ export class Agents {
391
416
  }
392
417
 
393
418
  /**
394
- * Sends an input from the owner and resolves when it ends. With a schema, the Agent must call
419
+ * Sends an input from the owner, `from` as its text names it, and resolves when it ends. With a schema, the Agent must call
395
420
  * `submit_result` with a matching value, which becomes the input's `value`; `text` tells it so.
421
+ * An input not ended within `timeoutMs` is aborted alone, and `timedOut` is set.
396
422
  */
397
- async request(id: string, text: string, delivery: "followUp" | "steer", schema?: unknown): Promise<InputRecord> {
423
+ async request(id: string, text: string, delivery: "followUp" | "steer", from: Sender, schema?: unknown, timeoutMs?: number): Promise<{ record: InputRecord; timedOut: boolean }> {
398
424
  const agent = this.agent(id);
399
- const input: Input = { text, permit: this.reserve(), record: this.newRecord(agent), done: deferred() };
425
+ const input: Input = { text, sender: from, permit: this.reserve(), record: this.newRecord(agent), done: deferred() };
400
426
  if (schema !== undefined) input.schema = schema;
401
427
  this.add(agent, input, delivery);
402
- await input.done!.promise;
403
- return input.record!;
428
+ let stopping: Promise<void> | undefined;
429
+ const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
430
+ stopping = this.cancel(agent, input);
431
+ stopping.catch(() => {});
432
+ }, timeoutMs);
433
+ try {
434
+ await input.done!.promise;
435
+ } finally {
436
+ clearTimeout(timer);
437
+ }
438
+ await stopping;
439
+ return { record: input.record!, timedOut: stopping !== undefined && input.record!.state === "aborted" };
404
440
  }
405
441
 
406
442
  /** Records a value from `submit_result` for the Agent's current input with a schema. */
@@ -414,19 +450,20 @@ export class Agents {
414
450
  input.submitted = { value: args.value };
415
451
  }
416
452
 
417
- accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notification: boolean }): { queued: boolean } {
453
+ accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notification: boolean; sender?: Sender }): { queued: boolean } {
418
454
  const agent = this.agent(id);
455
+ if (agent.stopped && !options.fromOwner) delivery = "write";
419
456
  if (delivery === "write") {
420
- this.write(agent, text);
457
+ this.write(agent, text, options.sender);
421
458
  return { queued: false };
422
459
  }
423
460
  // Notifications keep their owner informed even at the input limit.
424
461
  const permit = options.notification ? undefined : this.reserve();
425
- const input: Input = { text, permit, record: options.fromOwner && !options.notification ? this.newRecord(agent) : undefined };
462
+ const input: Input = { text, sender: options.sender, permit, record: options.fromOwner && !options.notification ? this.newRecord(agent) : undefined };
426
463
  return { queued: this.add(agent, input, delivery) };
427
464
  }
428
465
 
429
- async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal): Promise<WaitOutcome> {
466
+ async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<WaitOutcome> {
430
467
  const selected = targets ?? [...this.agents.values()].filter((agent) =>
431
468
  this.pending(agent) || agent.record.inputs.some((input) => ended(input) && !input.read));
432
469
  const unique = [...new Set(selected)];
@@ -438,13 +475,18 @@ export class Agents {
438
475
  const suspended = blocking && this.self.ownerId !== undefined && scheduler.suspend(this.self.rootId, this.self.id);
439
476
  if (blocking) this.markWaiting(1);
440
477
  let returned = false;
478
+ let interrupted = false;
441
479
  try {
442
480
  const deadline = Date.now() + timeoutSeconds * 1000;
443
481
  while (unique.some((agent) => this.pending(agent))) {
444
482
  const remaining = deadline - Date.now();
445
483
  if (remaining <= 0) break;
484
+ if (steered?.()) {
485
+ interrupted = true;
486
+ break;
487
+ }
446
488
  signal?.throwIfAborted();
447
- await waitForChange(this.change.promise, remaining, signal);
489
+ await waitForChange(this.change.promise, steered ? Math.min(remaining, STEER_POLL_MS) : remaining, signal);
448
490
  }
449
491
  signal?.throwIfAborted();
450
492
  const results: WaitResult[] = [];
@@ -455,7 +497,7 @@ export class Agents {
455
497
  }
456
498
  }
457
499
  returned = true;
458
- return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record) };
500
+ return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record), ...(interrupted && { interrupted }) };
459
501
  } finally {
460
502
  if (blocking) this.markWaiting(-1);
461
503
  if (suspended) await scheduler.resume(this.self.rootId, this.self.id, this.limits.maxConcurrent, signal);
@@ -494,22 +536,38 @@ export class Agents {
494
536
  if (silent) for (const input of [...agent.queue, ...(turn?.inputs ?? [])]) input.silent = true;
495
537
  for (const input of agent.queue.splice(0)) this.end(agent, input, "aborted", "");
496
538
  this.changed();
497
- if (turn) {
498
- turn.abort.abort();
499
- const stopping = (async () => {
500
- if (turn.started && agent.session) {
501
- agent.session.abortCompaction();
502
- agent.session.abortRetry();
503
- await agent.session.abort();
504
- }
505
- await turn.done.promise;
506
- })();
507
- if (!await settlesBy(stopping, deadline)) {
508
- this.finish(agent, turn, { outcome: "aborted", result: "" });
509
- throw new Error(`Agent ${label(agent.record, this.ids())} did not stop within the timeout and may still be running.`);
539
+ if (turn) await this.stop(agent, turn, deadline);
540
+ return true;
541
+ }
542
+
543
+ /** Ends one input as aborted: withdraws it if queued, or else stops its turn, as `abort` does. */
544
+ private async cancel(agent: Owned, input: Input): Promise<void> {
545
+ const index = agent.queue.indexOf(input);
546
+ if (index !== -1) {
547
+ agent.queue.splice(index, 1);
548
+ this.end(agent, input, "aborted", "");
549
+ this.changed();
550
+ } else if (agent.turn?.inputs.includes(input)) {
551
+ await this.stop(agent, agent.turn, Date.now() + SHUTDOWN_TIMEOUT_MS);
552
+ }
553
+ }
554
+
555
+ /** Stops a turn; one that has not stopped by `deadline` is abandoned: its inputs end as aborted, and this throws. */
556
+ private async stop(agent: Owned, turn: Turn, deadline: number): Promise<void> {
557
+ agent.stopped = true;
558
+ turn.abort.abort();
559
+ const stopping = (async () => {
560
+ if (turn.started && agent.session) {
561
+ agent.session.abortCompaction();
562
+ agent.session.abortRetry();
563
+ await agent.session.abort();
510
564
  }
565
+ await turn.done.promise;
566
+ })();
567
+ if (!await settlesBy(stopping, deadline)) {
568
+ this.finish(agent, turn, { outcome: "aborted", result: "" });
569
+ throw new Error(`Agent ${label(agent.record, this.ids())} did not stop within the timeout and may still be running.`);
511
570
  }
512
- return true;
513
571
  }
514
572
 
515
573
  restore(ctx: ExtensionContext): void {
@@ -539,9 +597,7 @@ export class Agents {
539
597
  const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS;
540
598
  const agents = [...this.agents.values()];
541
599
  const aborted = await Promise.allSettled(agents.map((agent) => this.abort(agent, { deadline })));
542
- const closed = await Promise.allSettled(agents.map(async (agent) => {
543
- const session = agent.session ?? await agent.loading?.catch(() => undefined);
544
- if (!session) return;
600
+ const close = async (agent: Owned, session: AgentSession) => {
545
601
  try {
546
602
  if (!await settlesBy(session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }), deadline)) {
547
603
  throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
@@ -550,13 +606,23 @@ export class Agents {
550
606
  session.dispose();
551
607
  agent.session = undefined;
552
608
  }
609
+ };
610
+ const closed = await Promise.allSettled(agents.map(async (agent) => {
611
+ const loading = agent.loading;
612
+ if (!agent.session && loading && !await settlesBy(loading, deadline)) {
613
+ // A Session that loads after the timeout is closed once it does.
614
+ void loading.then((late) => close(agent, late).catch(() => {}), () => {});
615
+ throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
616
+ }
617
+ const session = agent.session ?? await loading?.catch(() => undefined);
618
+ if (session) await close(agent, session);
553
619
  }));
554
620
  const errors = [...aborted, ...closed].flatMap((result) => result.status === "rejected" ? [result.reason] : []);
555
621
  if (errors.length > 0) throw new Error(errors.map((error) => error instanceof Error ? error.message : String(error)).join("\n"));
556
622
  }
557
623
 
558
- private write(agent: Owned, text: string): void {
559
- const message = customMessage(text, "write");
624
+ private write(agent: Owned, text: string, from?: Sender): void {
625
+ const message = customMessage(text, "write", { sender: from });
560
626
  const session = agent.session;
561
627
  if (session?.isStreaming) {
562
628
  session.agent.steer({ role: "custom", ...message, timestamp: Date.now() });
@@ -584,6 +650,8 @@ export class Agents {
584
650
 
585
651
  /** A steer joins the current turn; other inputs queue. Returns whether the input waits for a slot. */
586
652
  private add(agent: Owned, input: Input, delivery: "followUp" | "steer"): boolean {
653
+ // While stopped, other senders' messages become writes, so this is the owner's or user's next input.
654
+ agent.stopped = false;
587
655
  if (delivery !== "steer" || !agent.turn) return this.enqueue(agent, input);
588
656
  this.join(agent, agent.turn, input);
589
657
  return false;
@@ -596,9 +664,9 @@ export class Agents {
596
664
  this.changed();
597
665
  if (!turn.started) return;
598
666
  if (agent.session?.isStreaming) {
599
- agent.session.agent.steer({ role: "custom", ...customMessage(input.text, "steer", undefined, input.user), timestamp: Date.now() });
667
+ agent.session.agent.steer({ role: "custom", ...customMessage(input.text, "steer", { user: input.user, sender: input.sender }), timestamp: Date.now() });
600
668
  } else {
601
- turn.pending.push({ text: input.text, user: input.user });
669
+ turn.pending.push({ text: input.text, user: input.user, sender: input.sender });
602
670
  }
603
671
  }
604
672
 
@@ -606,7 +674,7 @@ export class Agents {
606
674
  if (agent.turn || this.closing) return;
607
675
  const head = agent.queue.shift();
608
676
  if (!head) return;
609
- const turn: Turn = { inputs: [head], pending: [], abort: new AbortController(), started: false, slot: false, finished: false, done: deferred() };
677
+ const turn: Turn = { inputs: [head], pending: [], abort: new AbortController(), started: false, slot: false, spent: { cost: 0, tokens: 0, unpriced: false }, finished: false, done: deferred() };
610
678
  agent.turn = turn;
611
679
  void this.runTurn(agent, turn);
612
680
  }
@@ -616,6 +684,7 @@ export class Agents {
616
684
  try {
617
685
  await scheduler.acquireQueued(this.self.rootId, agent.record.id, this.limits.maxConcurrent, turn.abort.signal);
618
686
  turn.slot = true;
687
+ turn.since = Date.now();
619
688
  const session = await this.load(agent);
620
689
  turn.abort.signal.throwIfAborted();
621
690
  const before = session.getSessionStats().assistantMessages;
@@ -623,11 +692,11 @@ export class Agents {
623
692
  for (const input of turn.inputs) if (input.record) input.record.state = "running";
624
693
  this.persist(agent);
625
694
  this.changed();
626
- let batch: Message[] = turn.inputs.map(({ text, user }) => ({ text, user }));
695
+ let batch: Message[] = turn.inputs.map(({ text, user, sender }) => ({ text, user, sender }));
627
696
  while (batch.length > 0 && !turn.abort.signal.aborted) {
628
- for (const { text, user } of batch.slice(0, -1)) await session.sendCustomMessage(customMessage(text, "steer", undefined, user), { triggerTurn: false });
629
- const last = batch.at(-1)!;
630
- await session.sendCustomMessage(customMessage(last.text, "steer", undefined, last.user), { triggerTurn: true });
697
+ for (const { text, ...rest } of batch.slice(0, -1)) await session.sendCustomMessage(customMessage(text, "steer", rest), { triggerTurn: false });
698
+ const { text, ...rest } = batch.at(-1)!;
699
+ await session.sendCustomMessage(customMessage(text, "steer", rest), { triggerTurn: true });
631
700
  await session.waitForIdle();
632
701
  const leftover = takeQueued(session.agent);
633
702
  for (const message of leftover) {
@@ -635,7 +704,7 @@ export class Agents {
635
704
  }
636
705
  batch = [
637
706
  ...turn.pending.splice(0),
638
- ...leftover.flatMap(({ content, details }) => details.delivery === "write" ? [] : [{ text: content, user: details.user }]),
707
+ ...leftover.flatMap(({ content, details }) => details.delivery === "write" ? [] : [{ text: content, user: details.user, sender: details.sender }]),
639
708
  ];
640
709
  }
641
710
  const last = lastAnswer(session, before);
@@ -877,6 +946,11 @@ export class Agents {
877
946
  finally { session.dispose(); }
878
947
  throw error;
879
948
  }
949
+ session.subscribe((event) => {
950
+ if (event.type !== "message_end" || event.message.role !== "assistant") return;
951
+ if (agent.turn) addSpend(agent.turn.spent, event.message.usage);
952
+ addProgramSpend(this.self.id, event.message.usage);
953
+ });
880
954
  return session;
881
955
  }
882
956
  }
@@ -1,4 +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
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import type { Agents } from "./agents.ts";
4
5
  import { TreeScheduler } from "./scheduler.ts";
@@ -16,19 +17,23 @@ export interface AgentNode {
16
17
  /** The node whose tree this node sees: its root, or the Program its Agents belong to. */
17
18
  scopeId: string;
18
19
  ownerId?: string;
19
- /** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the Agent viewer. */
20
+ /** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the task viewer. */
20
21
  program?: boolean;
21
22
  /** Whether a Program runs in the background. */
22
23
  background?: boolean;
23
24
  /** When a Program started. */
24
25
  createdAt?: string;
26
+ /** What the replies of a Program's Agents and the Agents under them have cost since it started. */
27
+ spent?: Spend;
25
28
  name(): string | undefined;
26
29
  cwd(): string;
27
30
  busy(): boolean;
28
31
  sessionFile(): string | undefined;
29
32
  agents: Agents;
30
33
  /** Delivers a message to this node; used only for root Agents. */
31
- receive(text: string, delivery: Delivery, messageId?: string): void;
34
+ receive(text: string, delivery: Delivery, messageId?: string, sender?: Sender): void;
35
+ /** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
36
+ stopped?: boolean;
32
37
  }
33
38
 
34
39
  export interface Entry {
@@ -86,6 +91,26 @@ export function trackTreeWork(rootId: string): () => void {
86
91
  };
87
92
  }
88
93
 
94
+ /** What replies cost: dollars, input and output tokens, and whether a reply had no price. */
95
+ export interface Spend {
96
+ cost: number;
97
+ tokens: number;
98
+ unpriced: boolean;
99
+ }
100
+
101
+ export function addSpend(spend: Spend, usage: Usage): void {
102
+ spend.cost += usage.cost.total;
103
+ spend.tokens += usage.input + usage.output;
104
+ if (usage.cost.total === 0 && usage.input + usage.output + usage.cacheRead + usage.cacheWrite > 0) spend.unpriced = true;
105
+ }
106
+
107
+ /** Adds a reply of an Agent owned by `ownerId` to the Programs above it. */
108
+ export function addProgramSpend(ownerId: string, usage: Usage): void {
109
+ for (let node = shared.nodes.get(ownerId); node; node = node.ownerId === undefined ? undefined : shared.nodes.get(node.ownerId)) {
110
+ if (node.spent) addSpend(node.spent, usage);
111
+ }
112
+ }
113
+
89
114
  /** The Programs a node runs, oldest first. */
90
115
  export function programsOf(node: AgentNode): AgentNode[] {
91
116
  return [...shared.nodes.values()]
@@ -169,7 +194,9 @@ export function treeMetadata(ctx: ExtensionContext): TreeMetadata | undefined {
169
194
  export function treeEntries(scopeId: string): Entry[] {
170
195
  const root = shared.nodes.get(scopeId);
171
196
  if (!root) return [];
172
- const entries: Entry[] = root.program ? [] : [{
197
+ // A Program's Agent heads its own scope; it stays owned, so messages to it go through its owner.
198
+ const owned = root.ownerId === undefined ? undefined : shared.nodes.get(root.ownerId)?.agents.owned().find((agent) => agent.id === root.id);
199
+ const entries: Entry[] = root.program ? [] : [owned ? { ...owned, ownerId: root.ownerId } : {
173
200
  id: root.id,
174
201
  name: root.name(),
175
202
  cwd: root.cwd(),
@@ -215,31 +242,56 @@ export function deliver(
215
242
  const fromOwner = from !== undefined && target.ownerId === from.id;
216
243
  const ids = recipientIds(target);
217
244
  const text = from === undefined ? body : messageText(from, fromOwner, delivery, body, ids);
245
+ const by = from && sender(from, ids);
218
246
  if (target.ownerId === undefined) {
219
247
  const node = shared.nodes.get(target.id);
220
248
  if (!node) throw new Error(`${label(target, ids)} is offline.`);
221
- node.receive(text, delivery);
249
+ node.receive(text, delivery, undefined, by);
222
250
  return { queued: false };
223
251
  }
224
252
  const owner = shared.nodes.get(target.ownerId);
225
253
  if (!owner) throw new Error(`${label(target, ids)} is offline.`);
226
- return owner.agents.accept(target.id, text, delivery, { fromOwner, notification: from === undefined });
254
+ return owner.agents.accept(target.id, text, delivery, { fromOwner, notification: from === undefined, sender: by });
227
255
  }
228
256
 
229
257
  export const MESSAGE_TAG = "agent-message";
258
+ const REPLY_NOTE = ' note="Reply with send"';
259
+
260
+ /** The sender of a message as its element names it: its name and its short ID among the recipient's visible Agents. */
261
+ export interface Sender {
262
+ id: string;
263
+ name?: string;
264
+ }
265
+
266
+ export function sender(from: { id: string; name?: string }, ids: Iterable<string>): Sender {
267
+ return { id: shortId(from.id, ids), ...(from.name === undefined ? {} : { name: from.name }) };
268
+ }
269
+
270
+ /** The element a sender's message opens with, naming the sender as `agent-result` names its Agent. */
271
+ function messageOpen({ id, name }: Sender): string {
272
+ return `<${MESSAGE_TAG}${name === undefined ? "" : ` name="${name}"`} id="${id}"`;
273
+ }
230
274
 
231
275
  /** A message in an element naming its sender; an input from a sender other than the owner asks for a reply. */
232
276
  export function messageText(from: { id: string; name?: string }, fromOwner: boolean, delivery: Delivery, body: string, ids: Iterable<string>): string {
233
- const note = !fromOwner && delivery !== "write" ? ' note="Reply with send"' : "";
234
- return `<${MESSAGE_TAG} from="${from.name ?? shortId(from.id, ids)}" id="${shortId(from.id, ids)}"${note}>\n${body}\n</${MESSAGE_TAG}>`;
277
+ const note = !fromOwner && delivery !== "write" ? REPLY_NOTE : "";
278
+ return `${messageOpen(sender(from, ids))}${note}>\n${body}\n</${MESSAGE_TAG}>`;
279
+ }
280
+
281
+ /** The body of a message `messageText` made for `sender`, or undefined if `text` is not one. */
282
+ export function messageBody(text: string, sender: Sender): string | undefined {
283
+ const open = messageOpen(sender);
284
+ const close = `\n</${MESSAGE_TAG}>`;
285
+ const header = [`${open}>\n`, `${open}${REPLY_NOTE}>\n`].find((start) => text.startsWith(start));
286
+ return header && text.length >= header.length + close.length && text.endsWith(close) ? text.slice(header.length, -close.length) : undefined;
235
287
  }
236
288
 
237
289
  /**
238
290
  * `messageId` marks a message from a root Agent in another process, so it is added once; `user`
239
- * marks the user's input from the Agent viewer.
291
+ * marks the user's input from the task viewer, and `sender` the Agent that sent it.
240
292
  */
241
- export function customMessage(text: string, delivery: Delivery, messageId?: string, user?: boolean) {
242
- return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}) } };
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 } : {}) } };
243
295
  }
244
296
 
245
297
  export function receivedMessageIds(ctx: ExtensionContext): string[] {
@@ -4,6 +4,8 @@ import { entryLine, MESSAGE_TAG, MESSAGE_TYPE, type Entry } from "./registry.ts"
4
4
  interface SearchText {
5
5
  time: number;
6
6
  text: string;
7
+ /** A user message or an input, not a summary, write, or notification. */
8
+ request?: boolean;
7
9
  }
8
10
 
9
11
  /** Parsed texts by Session file, reused while the file is unchanged. */
@@ -32,9 +34,10 @@ function sessionTexts(sessionFile: string | undefined): SearchText[] {
32
34
  const text = typeof content === "string"
33
35
  ? content
34
36
  : Array.isArray(content) ? content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n") : "";
35
- if (text) texts.push({ time, text });
37
+ if (text) texts.push({ time, text, request: true });
36
38
  } else if (entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
37
- texts.push({ time, text: entry.content });
39
+ const request = entry.details?.delivery !== "write" && (entry.content.startsWith(`<${MESSAGE_TAG} `) || entry.details?.user === true);
40
+ texts.push({ time, text: entry.content, request });
38
41
  }
39
42
  }
40
43
  cache.set(sessionFile, { stamp, texts });
@@ -85,9 +88,23 @@ export function searchEntries(entries: Entry[], query: string | undefined): List
85
88
  return items.sort((left, right) => right.time - left.time).map(({ time: _time, ...item }) => item);
86
89
  }
87
90
 
91
+ /** An entry with its latest matching excerpt, else its latest request, and its time. */
88
92
  export function listLine(item: ListItem, ids: Iterable<string>): string {
89
93
  const line = entryLine(item.entry, ids);
90
- return item.match ? `${line}\n ${formatTime(item.match.time)} ${item.match.excerpt}` : line;
94
+ const detail = item.match ?? latestRequest(item.entry.sessionFile);
95
+ return detail ? `${line}\n ${formatTime(detail.time)} ${detail.excerpt}` : line;
96
+ }
97
+
98
+ function latestRequest(sessionFile: string | undefined): { time: number; excerpt: string } | undefined {
99
+ const text = sessionTexts(sessionFile).findLast((text) => text.request);
100
+ if (!text) return undefined;
101
+ const flat = messageBody(text.text).replace(/\s+/g, " ").trim();
102
+ return { time: text.time, excerpt: flat.length > 120 ? `${flat.slice(0, 120)}…` : flat };
103
+ }
104
+
105
+ /** A message's body without its `agent-message` element. */
106
+ function messageBody(content: string): string {
107
+ return content.startsWith(`<${MESSAGE_TAG} `) ? content.slice(content.indexOf("\n") + 1, content.lastIndexOf("\n")) : content;
91
108
  }
92
109
 
93
110
  /**
@@ -103,7 +120,7 @@ export function latestActivity(sessionFile: string | undefined): string | undefi
103
120
  let entry: Record<string, any>;
104
121
  try { entry = JSON.parse(line); } catch { continue; }
105
122
  if (input === undefined && entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
106
- input = entry.content.startsWith(`<${MESSAGE_TAG} `) ? entry.content.slice(entry.content.indexOf("\n") + 1, entry.content.lastIndexOf("\n")) : entry.content;
123
+ input = messageBody(entry.content);
107
124
  }
108
125
  if (input !== undefined && entry.type === "message" && entry.message?.role === "assistant" && Array.isArray(entry.message.content)) {
109
126
  activity = replyActivity(entry.message.content) ?? activity;