pi-long-task 0.2.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension for breaking down and running long coding tasks safely.",
6
6
  "keywords": [
@@ -80,6 +80,7 @@ export interface CoordinatorProgressUpdate {
80
80
  workerEventType?: string;
81
81
  isError?: boolean;
82
82
  totalTasks?: number;
83
+ workerCostTotal: number;
83
84
  currentTask?: CoordinatorProgressTask;
84
85
  subtasks?: CoordinatorProgressSubtask[];
85
86
  taskProgress?: TaskProgressModel;
@@ -145,10 +146,18 @@ export interface CoordinatorResult {
145
146
  commits: CoordinatorCommitSummary[];
146
147
  attempts: TaskAttemptSummary[];
147
148
  taskProgress: TaskProgressModel;
149
+ workerCostTotal: number;
148
150
  commit: boolean;
149
151
  error?: string;
150
152
  }
151
153
 
154
+ interface WorkerCostState {
155
+ total: number;
156
+ finalizedByWorker: Map<string, number>;
157
+ liveByWorker: Map<string, number>;
158
+ liveByMessage: Map<string, number>;
159
+ }
160
+
152
161
  interface RuntimeOptions {
153
162
  cwd: string;
154
163
  runId: string;
@@ -167,6 +176,7 @@ interface RuntimeOptions {
167
176
  todoSessionFactory?: WorkerSessionFactory;
168
177
  now: () => Date;
169
178
  onProgress?: CoordinatorProgressHandler;
179
+ workerCostState: WorkerCostState;
170
180
  }
171
181
 
172
182
  export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
@@ -237,6 +247,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
237
247
  onEvent: (event) => emitWorkerEventProgress(runtime, tasksBeforeAttempt, nextTask, attempts, attempt, event),
238
248
  });
239
249
  outcomes.push(outcome);
250
+ finalizeWorkerCost(runtime.workerCostState, outcome);
240
251
 
241
252
  if (outcome.done) {
242
253
  todoMarkdown = markTaskDone(todoMarkdown, nextTask.taskId);
@@ -346,6 +357,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
346
357
  commits,
347
358
  attempts,
348
359
  taskProgress,
360
+ workerCostTotal: runtime.workerCostState.total,
349
361
  commit: options.commit,
350
362
  error: failure,
351
363
  };
@@ -385,6 +397,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
385
397
  commits,
386
398
  attempts,
387
399
  taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
400
+ workerCostTotal: runtime.workerCostState.total,
388
401
  commit: options.commit,
389
402
  error: message,
390
403
  };
@@ -467,23 +480,101 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
467
480
  todoSessionFactory: options.todoSessionFactory,
468
481
  now: options.now ?? (() => new Date()),
469
482
  onProgress: options.onProgress,
483
+ workerCostState: createWorkerCostState(),
470
484
  };
471
485
  }
472
486
 
473
487
  function emitProgress(
474
488
  runtime: RuntimeOptions,
475
489
  message: string,
476
- update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath">,
490
+ update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath" | "workerCostTotal">,
477
491
  ): void {
478
492
  runtime.onProgress?.({
479
493
  message,
480
494
  runId: runtime.runId,
481
495
  todoPath: runtime.todoPath,
482
496
  resultPath: runtime.taskResultPath,
497
+ workerCostTotal: runtime.workerCostState.total,
483
498
  ...update,
484
499
  });
485
500
  }
486
501
 
502
+ function createWorkerCostState(): WorkerCostState {
503
+ return {
504
+ total: 0,
505
+ finalizedByWorker: new Map(),
506
+ liveByWorker: new Map(),
507
+ liveByMessage: new Map(),
508
+ };
509
+ }
510
+
511
+ function recordLiveWorkerCost(
512
+ state: WorkerCostState,
513
+ worker: string,
514
+ event: { usageCostTotal?: number; usageCostKey?: string },
515
+ ): boolean {
516
+ if (state.finalizedByWorker.has(worker) || event.usageCostTotal === undefined || !event.usageCostKey) {
517
+ return false;
518
+ }
519
+
520
+ const cost = finiteNonNegativeNumber(event.usageCostTotal);
521
+ if (cost === undefined) {
522
+ return false;
523
+ }
524
+
525
+ const messageKey = `${worker}:${event.usageCostKey}`;
526
+ if (state.liveByMessage.get(messageKey) === cost) {
527
+ return false;
528
+ }
529
+
530
+ state.liveByMessage.set(messageKey, cost);
531
+ recomputeLiveWorkerCost(state, worker);
532
+ recomputeWorkerCostTotal(state);
533
+ return true;
534
+ }
535
+
536
+ function finalizeWorkerCost(
537
+ state: WorkerCostState,
538
+ outcome: Pick<SessionOutcome, "task" | "attempt" | "workerCostTotal">,
539
+ ): void {
540
+ const worker = workerKey(outcome.task.taskId, outcome.attempt);
541
+ state.finalizedByWorker.set(worker, finiteNonNegativeNumber(outcome.workerCostTotal) ?? 0);
542
+ state.liveByWorker.delete(worker);
543
+ for (const messageKey of state.liveByMessage.keys()) {
544
+ if (messageKey.startsWith(`${worker}:`)) {
545
+ state.liveByMessage.delete(messageKey);
546
+ }
547
+ }
548
+ recomputeWorkerCostTotal(state);
549
+ }
550
+
551
+ function recomputeLiveWorkerCost(state: WorkerCostState, worker: string): void {
552
+ let total = 0;
553
+ for (const [messageKey, cost] of state.liveByMessage) {
554
+ if (messageKey.startsWith(`${worker}:`)) {
555
+ total += cost;
556
+ }
557
+ }
558
+ state.liveByWorker.set(worker, total);
559
+ }
560
+
561
+ function recomputeWorkerCostTotal(state: WorkerCostState): void {
562
+ let total = 0;
563
+ for (const cost of state.finalizedByWorker.values()) {
564
+ total += cost;
565
+ }
566
+ for (const [worker, cost] of state.liveByWorker) {
567
+ if (!state.finalizedByWorker.has(worker)) {
568
+ total += cost;
569
+ }
570
+ }
571
+ state.total = total;
572
+ }
573
+
574
+ function workerKey(taskId: string, attempt: number): string {
575
+ return `${taskId}:${attempt}`;
576
+ }
577
+
487
578
  function currentTaskProgress(
488
579
  task: Pick<Task, "taskId" | "title" | "statusItems">,
489
580
  status: CoordinatorProgressItemStatus,
@@ -521,13 +612,33 @@ function emitWorkerEventProgress(
521
612
  task: Pick<Task, "taskId" | "title" | "statusItems">,
522
613
  attempts: readonly TaskAttemptSummary[],
523
614
  attempt: number,
524
- event: { type: string; toolName?: string; isError?: boolean },
615
+ event: { type: string; toolName?: string; isError?: boolean; usageCostTotal?: number; usageCostKey?: string },
525
616
  ): void {
617
+ if (event.usageCostTotal !== undefined) {
618
+ const changed = recordLiveWorkerCost(runtime.workerCostState, workerKey(task.taskId, attempt), event);
619
+ if (changed) {
620
+ emitProgress(
621
+ runtime,
622
+ `TODO ${task.taskId}: worker cost updated to ${formatCost(runtime.workerCostState.total)}.`,
623
+ {
624
+ phase: "worker_tool",
625
+ taskId: task.taskId,
626
+ title: task.title,
627
+ attempt,
628
+ status: "in_progress",
629
+ workerEventType: event.type,
630
+ ...currentTaskProgress(task, "in_progress"),
631
+ taskProgress: buildTaskProgressModel({ tasks, attempts, currentTaskId: task.taskId }),
632
+ },
633
+ );
634
+ }
635
+ }
636
+
526
637
  if (!event.toolName || (event.type !== "tool_execution_start" && event.type !== "tool_execution_end")) {
527
638
  return;
528
639
  }
529
640
  const action = event.type === "tool_execution_start" ? "started" : event.isError ? "failed" : "finished";
530
- const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath"> = {
641
+ const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath" | "workerCostTotal"> = {
531
642
  phase: "worker_tool",
532
643
  taskId: task.taskId,
533
644
  title: task.title,
@@ -568,7 +679,7 @@ function emitTaskOutcomeProgress(
568
679
  : outcome.reportedStatus === "blocked"
569
680
  ? "task_blocked"
570
681
  : "task_failed";
571
- const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath"> = {
682
+ const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath" | "workerCostTotal"> = {
572
683
  phase,
573
684
  taskId: task.taskId,
574
685
  title: task.title,
@@ -759,6 +870,20 @@ function positiveMilliseconds(value: number | undefined, fallback: number): numb
759
870
  return fallback;
760
871
  }
761
872
 
873
+ function finiteNonNegativeNumber(value: unknown): number | undefined {
874
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
875
+ }
876
+
877
+ function formatCost(value: number): string {
878
+ if (value === 0) {
879
+ return "$0";
880
+ }
881
+ if (value < 0.01) {
882
+ return `$${value.toFixed(4)}`;
883
+ }
884
+ return `$${value.toFixed(2)}`;
885
+ }
886
+
762
887
  function errorMessage(error: unknown): string {
763
888
  return error instanceof Error ? error.message : String(error);
764
889
  }
package/src/index.ts CHANGED
@@ -1,10 +1,60 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
3
 
3
4
  import { runCoordinator, type CoordinatorProgressUpdate, type CoordinatorResult } from "./coordinator.ts";
4
5
  import { longTaskInputTransform } from "./input_router.ts";
5
6
  import { renderLongTaskToolCall, renderLongTaskToolResult } from "./render.ts";
6
7
  import { PiLongTaskParams } from "./types.ts";
7
8
 
9
+ export function createWorkerCostAccumulator() {
10
+ let pendingWorkerCostTotal = 0;
11
+
12
+ return {
13
+ add(cost: number): void {
14
+ const value = finiteNonNegativeNumber(cost);
15
+ if (value && value > 0) {
16
+ pendingWorkerCostTotal += value;
17
+ }
18
+ },
19
+ applyToAssistantMessage(message: AssistantMessage): AssistantMessage | undefined {
20
+ if (pendingWorkerCostTotal <= 0) {
21
+ return undefined;
22
+ }
23
+
24
+ const replacement = addWorkerCostToAssistantMessage(message, pendingWorkerCostTotal);
25
+ if (replacement) {
26
+ pendingWorkerCostTotal = 0;
27
+ }
28
+ return replacement;
29
+ },
30
+ };
31
+ }
32
+
33
+ export function addWorkerCostToAssistantMessage(
34
+ message: AssistantMessage,
35
+ workerCostTotal: number,
36
+ ): AssistantMessage | undefined {
37
+ const workerCost = finiteNonNegativeNumber(workerCostTotal);
38
+ if (!workerCost || workerCost <= 0) {
39
+ return undefined;
40
+ }
41
+
42
+ return {
43
+ ...message,
44
+ usage: {
45
+ ...message.usage,
46
+ cost: {
47
+ ...message.usage.cost,
48
+ total: message.usage.cost.total + workerCost,
49
+ },
50
+ },
51
+ };
52
+ }
53
+
54
+ function finiteNonNegativeNumber(value: unknown): number | undefined {
55
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
56
+ }
57
+
8
58
  function toolDetails(result: CoordinatorResult) {
9
59
  return {
10
60
  runId: result.runId,
@@ -19,11 +69,23 @@ function toolDetails(result: CoordinatorResult) {
19
69
  blockedTasks: result.blockedTasks,
20
70
  remainingTasks: result.remainingTasks,
21
71
  taskProgress: result.taskProgress,
72
+ workerCostTotal: result.workerCostTotal,
22
73
  summary: result.summary,
23
74
  };
24
75
  }
25
76
 
26
77
  export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
78
+ const workerCostAccumulator = createWorkerCostAccumulator();
79
+
80
+ pi.on("message_end", (event) => {
81
+ if (event.message.role !== "assistant") {
82
+ return undefined;
83
+ }
84
+
85
+ const message = workerCostAccumulator.applyToAssistantMessage(event.message);
86
+ return message ? { message } : undefined;
87
+ });
88
+
27
89
  pi.on("input", (event) => {
28
90
  if (event.source === "extension") {
29
91
  return { action: "continue" as const };
@@ -64,6 +126,7 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
64
126
  abortSignal: signal,
65
127
  onProgress: publishProgress,
66
128
  });
129
+ workerCostAccumulator.add(result.workerCostTotal);
67
130
 
68
131
  return {
69
132
  content: [
package/src/render.ts CHANGED
@@ -17,6 +17,7 @@ export interface CoordinatorResultForRendering {
17
17
  commits?: CoordinatorCommitSummary[];
18
18
  remainingTasks?: CoordinatorRemainingTask[];
19
19
  taskProgress?: TaskProgressModel;
20
+ workerCostTotal?: number;
20
21
  error?: string;
21
22
  }
22
23
 
@@ -45,11 +46,13 @@ const SIDEBAR_GAP = 2;
45
46
  class LongTaskSidebarShell implements Component {
46
47
  private readonly mainText: string;
47
48
  private readonly taskProgress: TaskProgressModel;
49
+ private readonly workerCostTotal: number | undefined;
48
50
  private readonly theme: Theme;
49
51
 
50
- constructor(mainText: string, taskProgress: TaskProgressModel, theme: Theme) {
52
+ constructor(mainText: string, taskProgress: TaskProgressModel, theme: Theme, workerCostTotal?: number) {
51
53
  this.mainText = mainText;
52
54
  this.taskProgress = taskProgress;
55
+ this.workerCostTotal = workerCostTotal;
53
56
  this.theme = theme;
54
57
  }
55
58
 
@@ -92,7 +95,7 @@ class LongTaskSidebarShell implements Component {
92
95
  }
93
96
 
94
97
  const innerWidth = Math.max(0, width - 2);
95
- const rows = sidebarRows(this.taskProgress, this.theme);
98
+ const rows = sidebarRows(this.taskProgress, this.theme, this.workerCostTotal);
96
99
  return [
97
100
  sidebarBorder("Long Task", width, this.theme),
98
101
  ...rows.map((row) => sidebarRow(row, innerWidth, this.theme)),
@@ -113,6 +116,10 @@ export function formatCoordinatorResultMessage(result: CoordinatorResultForRende
113
116
  `TODO file: ${result.todoPath}`,
114
117
  ];
115
118
 
119
+ if (result.workerCostTotal) {
120
+ lines.push(`Worker spend: ${formatCost(result.workerCostTotal)}`);
121
+ }
122
+
116
123
  const commitLines = commits
117
124
  .filter((commit) => commit.hash || commit.error)
118
125
  .map((commit) => {
@@ -154,8 +161,9 @@ export function renderLongTaskToolResult(
154
161
  const details = recordOrUndefined(result.details);
155
162
  if (options.isPartial) {
156
163
  const taskProgress = taskProgressModel(details?.taskProgress);
164
+ const workerCostTotal = numberValue(details?.workerCostTotal);
157
165
  const main = renderLongTaskProgress(details, contentText(result), theme);
158
- return taskProgress ? new LongTaskSidebarShell(main, taskProgress, theme) : new Text(main, 0, 0);
166
+ return taskProgress ? new LongTaskSidebarShell(main, taskProgress, theme, workerCostTotal) : new Text(main, 0, 0);
159
167
  }
160
168
 
161
169
  const finalDetails = longTaskDetails(details);
@@ -165,7 +173,7 @@ export function renderLongTaskToolResult(
165
173
 
166
174
  const main = renderLongTaskSummary(finalDetails, options.expanded, theme);
167
175
  return finalDetails.taskProgress
168
- ? new LongTaskSidebarShell(main, finalDetails.taskProgress, theme)
176
+ ? new LongTaskSidebarShell(main, finalDetails.taskProgress, theme, finalDetails.workerCostTotal)
169
177
  : new Text(main, 0, 0);
170
178
  }
171
179
 
@@ -208,6 +216,7 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
208
216
  details.blockedTasks ? theme.fg("warning", `${details.blockedTasks} blocked`) : undefined,
209
217
  remainingCount ? theme.fg("muted", `${remainingCount} remaining`) : undefined,
210
218
  commitCount ? theme.fg("success", `${commitCount} commit${commitCount === 1 ? "" : "s"}`) : undefined,
219
+ details.workerCostTotal ? theme.fg("muted", `worker ${formatCost(details.workerCostTotal)}`) : undefined,
211
220
  ].filter(Boolean);
212
221
 
213
222
  if (!expanded) {
@@ -252,9 +261,12 @@ function padLine(line: string, width: number): string {
252
261
  return truncateToWidth(line, width, "…", true);
253
262
  }
254
263
 
255
- function sidebarRows(taskProgress: TaskProgressModel, theme: Theme): string[] {
264
+ function sidebarRows(taskProgress: TaskProgressModel, theme: Theme, workerCostTotal?: number): string[] {
256
265
  const summary = normalizedTaskProgressSummary(taskProgress);
257
266
  const rows = [theme.fg("toolTitle", theme.bold("Task sidebar")), theme.fg("dim", "Centered timeline")];
267
+ if (workerCostTotal) {
268
+ rows.push(theme.fg("muted", `Worker spend: ${formatCost(workerCostTotal)}`));
269
+ }
258
270
  if (summary.totalTasks === 0) {
259
271
  rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
260
272
  return rows;
@@ -503,6 +515,7 @@ function longTaskDetails(details: Record<string, unknown> | undefined): Coordina
503
515
  commits: commitSummaries(details.commits),
504
516
  remainingTasks: remainingTaskSummaries(details.remainingTasks),
505
517
  taskProgress: taskProgressModel(details.taskProgress),
518
+ workerCostTotal: nonNegativeNumberValue(details.workerCostTotal),
506
519
  error: stringValue(details.error),
507
520
  };
508
521
  }
@@ -592,6 +605,20 @@ function numberValue(value: unknown): number | undefined {
592
605
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
593
606
  }
594
607
 
608
+ function nonNegativeNumberValue(value: unknown): number | undefined {
609
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
610
+ }
611
+
612
+ function formatCost(value: number): string {
613
+ if (value === 0) {
614
+ return "$0";
615
+ }
616
+ if (value < 0.01) {
617
+ return `$${value.toFixed(4)}`;
618
+ }
619
+ return `$${value.toFixed(2)}`;
620
+ }
621
+
595
622
  function recordOrUndefined(value: unknown): Record<string, unknown> | undefined {
596
623
  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
597
624
  }
package/src/types.ts CHANGED
@@ -62,6 +62,7 @@ export interface PiLongTaskResult {
62
62
  commitSkipped?: string;
63
63
  }>;
64
64
  taskProgress: TaskProgressModel;
65
+ workerCostTotal: number;
65
66
  commit: boolean;
66
67
  error?: string;
67
68
  }
@@ -201,7 +201,7 @@ export interface WorkerSessionLike {
201
201
  compact?(customInstructions?: string): Promise<unknown>;
202
202
  dispose?(): void;
203
203
  getLastAssistantText?(): string | undefined;
204
- getSessionStats?(): unknown;
204
+ getSessionStats?(): unknown | Promise<unknown>;
205
205
  getContextUsage?(): unknown;
206
206
  sessionFile?: string;
207
207
  sessionId?: string;
@@ -246,6 +246,8 @@ export interface CapturedWorkerEvent {
246
246
  toolName?: string;
247
247
  isError?: boolean;
248
248
  note?: string;
249
+ usageCostTotal?: number;
250
+ usageCostKey?: string;
249
251
  }
250
252
 
251
253
  export interface SessionOutcome {
@@ -261,6 +263,8 @@ export interface SessionOutcome {
261
263
  contextObservations: string[];
262
264
  compactionEvents: string[];
263
265
  events: CapturedWorkerEvent[];
266
+ workerCostTotal: number;
267
+ workerCostSource?: string;
264
268
  shutdownRequested: boolean;
265
269
  timedOut: boolean;
266
270
  aborted: boolean;
@@ -345,6 +349,9 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
345
349
  let error: string | undefined;
346
350
  let finished = false;
347
351
  let turnCount = 0;
352
+ let messageUsageCostTotal = 0;
353
+ let hasMessageUsageCost = false;
354
+ let sessionStatsCostTotal: number | undefined;
348
355
 
349
356
  const prompt = buildTaskPrompt(options);
350
357
  const taskTimeoutSeconds = options.taskTimeoutSeconds ?? DEFAULT_TASK_TIMEOUT_SECONDS;
@@ -366,6 +373,22 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
366
373
  timers.clear();
367
374
  };
368
375
 
376
+ const messageUsageCostsByKey = new Map<string, number>();
377
+ const recordWorkerUsageCost = (cost: number | undefined, key: string | undefined) => {
378
+ if (cost === undefined) {
379
+ return;
380
+ }
381
+ hasMessageUsageCost = true;
382
+ if (!key) {
383
+ messageUsageCostTotal += cost;
384
+ return;
385
+ }
386
+
387
+ const previousCost = messageUsageCostsByKey.get(key) ?? 0;
388
+ messageUsageCostsByKey.set(key, cost);
389
+ messageUsageCostTotal += cost - previousCost;
390
+ };
391
+
369
392
  const schedule = (fn: () => void | Promise<void>, ms: number) => {
370
393
  const timer = setTimeout(() => {
371
394
  timers.delete(timer);
@@ -458,6 +481,7 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
458
481
  if (messageText) {
459
482
  assistantText = messageText;
460
483
  }
484
+ recordWorkerUsageCost(workerUsageCostFromEvent(event), workerUsageCostKeyFromEvent(event));
461
485
  break;
462
486
  }
463
487
  case "turn_end": {
@@ -538,6 +562,7 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
538
562
  assistantText = latestAssistantText(session, assistantText);
539
563
  sessionFile = session.sessionFile ?? sessionFile;
540
564
  sessionId = session.sessionId ?? sessionId;
565
+ sessionStatsCostTotal = await workerUsageCostFromSessionStats(session);
541
566
  session.dispose?.();
542
567
  }
543
568
  }
@@ -547,6 +572,10 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
547
572
  }
548
573
 
549
574
  const reportedStatus = parseReportedStatus(assistantText);
575
+ const capturedWorkerCost = selectWorkerCostTotal({
576
+ messageCostTotal: hasMessageUsageCost ? messageUsageCostTotal : undefined,
577
+ statsCostTotal: sessionStatsCostTotal,
578
+ });
550
579
  return {
551
580
  task: options.task,
552
581
  attempt: options.attempt,
@@ -560,6 +589,8 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
560
589
  contextObservations,
561
590
  compactionEvents,
562
591
  events,
592
+ workerCostTotal: capturedWorkerCost.total,
593
+ workerCostSource: capturedWorkerCost.source,
563
594
  shutdownRequested,
564
595
  timedOut,
565
596
  aborted: aborted || Boolean(options.abortSignal?.aborted),
@@ -723,9 +754,15 @@ function summarizeWorkerEvent(event: unknown): CapturedWorkerEvent | undefined {
723
754
  };
724
755
  }
725
756
 
757
+ if (event.type === "message_end") {
758
+ const usageCostTotal = workerUsageCostFromEvent(event);
759
+ return usageCostTotal === undefined
760
+ ? { type: event.type }
761
+ : { type: event.type, usageCostTotal, usageCostKey: workerUsageCostKeyFromEvent(event) };
762
+ }
763
+
726
764
  if (
727
765
  event.type === "turn_end" ||
728
- event.type === "message_end" ||
729
766
  event.type === "compaction_start" ||
730
767
  event.type === "compaction_end" ||
731
768
  event.type === "agent_end" ||
@@ -738,6 +775,109 @@ function summarizeWorkerEvent(event: unknown): CapturedWorkerEvent | undefined {
738
775
  return undefined;
739
776
  }
740
777
 
778
+ export function workerUsageCostFromEvent(event: unknown): number | undefined {
779
+ if (!isRecord(event)) {
780
+ return undefined;
781
+ }
782
+
783
+ for (const candidate of [event.assistantMessage, event.message, event]) {
784
+ const cost = workerUsageCostFromAssistantMessage(candidate);
785
+ if (cost !== undefined) {
786
+ return cost;
787
+ }
788
+ }
789
+ return undefined;
790
+ }
791
+
792
+ export function workerUsageCostFromAssistantMessage(message: unknown): number | undefined {
793
+ if (!isRecord(message)) {
794
+ return undefined;
795
+ }
796
+ return usageCostTotal(message.usage);
797
+ }
798
+
799
+ export function workerUsageCostKeyFromEvent(event: unknown): string | undefined {
800
+ if (!isRecord(event)) {
801
+ return undefined;
802
+ }
803
+
804
+ for (const candidate of [event.assistantMessage, event.message, event]) {
805
+ const key = workerUsageCostKeyFromAssistantMessage(candidate);
806
+ if (key) {
807
+ return key;
808
+ }
809
+ }
810
+ return undefined;
811
+ }
812
+
813
+ function workerUsageCostKeyFromAssistantMessage(message: unknown): string | undefined {
814
+ if (!isRecord(message)) {
815
+ return undefined;
816
+ }
817
+
818
+ for (const keyName of ["id", "messageId", "uuid"] as const) {
819
+ const value = message[keyName];
820
+ if (typeof value === "string" && value) {
821
+ return `${keyName}:${value}`;
822
+ }
823
+ }
824
+ return undefined;
825
+ }
826
+
827
+ export function workerUsageCostFromStats(stats: unknown): number | undefined {
828
+ if (!isRecord(stats)) {
829
+ return undefined;
830
+ }
831
+
832
+ const directCost = finiteNonNegativeNumber(stats.cost);
833
+ if (directCost !== undefined) {
834
+ return directCost;
835
+ }
836
+
837
+ return usageCostTotal(stats.usage) ?? usageCostTotal(stats);
838
+ }
839
+
840
+ async function workerUsageCostFromSessionStats(session: WorkerSessionLike): Promise<number | undefined> {
841
+ if (!session.getSessionStats) {
842
+ return undefined;
843
+ }
844
+
845
+ try {
846
+ return workerUsageCostFromStats(await session.getSessionStats());
847
+ } catch {
848
+ return undefined;
849
+ }
850
+ }
851
+
852
+ function usageCostTotal(usage: unknown): number | undefined {
853
+ if (!isRecord(usage)) {
854
+ return undefined;
855
+ }
856
+
857
+ const cost = usage.cost;
858
+ if (isRecord(cost)) {
859
+ return finiteNonNegativeNumber(cost.total);
860
+ }
861
+ return finiteNonNegativeNumber(cost);
862
+ }
863
+
864
+ function selectWorkerCostTotal(options: { messageCostTotal: number | undefined; statsCostTotal: number | undefined }): {
865
+ total: number;
866
+ source?: string;
867
+ } {
868
+ if (options.statsCostTotal !== undefined) {
869
+ return { total: options.statsCostTotal, source: "session_stats" };
870
+ }
871
+ if (options.messageCostTotal !== undefined) {
872
+ return { total: options.messageCostTotal, source: "message_end" };
873
+ }
874
+ return { total: 0 };
875
+ }
876
+
877
+ function finiteNonNegativeNumber(value: unknown): number | undefined {
878
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
879
+ }
880
+
741
881
  function captureContextUsage(
742
882
  session: WorkerSessionLike | undefined,
743
883
  turnCount: number,