pi-long-task 0.1.3 → 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/README.md CHANGED
@@ -6,19 +6,22 @@ It is useful when you want Pi to handle a multi-step change without losing track
6
6
 
7
7
  ## What you get
8
8
 
9
- When you ask Pi to use `pi_long_task`, it will:
9
+ When you ask Pi to run a long task, it will:
10
10
 
11
- 1. Create or clean up a TODO plan from your request.
12
- 2. Work through each unfinished TODO task in order.
13
- 3. Record progress and final results under `tmp/pi-long-task/<run-id>/`.
14
- 4. Return a summary with completed, failed, blocked, and remaining task counts.
15
- 5. Optionally commit completed work after each task.
11
+ 1. Recognize natural-language requests like "run a long task with commits" and route them to `pi_long_task`.
12
+ 2. Create or clean up a TODO plan from your request.
13
+ 3. Work through each unfinished TODO task in order.
14
+ 4. Show the current task and inferred subtask progress while it runs.
15
+ 5. Record progress and final results under `tmp/pi-long-task/<run-id>/`.
16
+ 6. Return a summary with completed, failed, blocked, and remaining task counts.
17
+ 7. Optionally commit completed work after each task.
16
18
 
17
19
  A finished run gives you:
18
20
 
19
21
  - a concise status summary in Pi
20
22
  - a generated `TODO.md`
21
23
  - a generated `TASK_RESULT.md`
24
+ - live progress for the active task and its `**Status:**` checkbox subtasks
22
25
  - commit hashes when commits were enabled and created
23
26
  - any remaining or blocked tasks clearly listed
24
27
 
@@ -42,11 +45,23 @@ Or install the local checkout so Pi can load it normally:
42
45
  pi install /path/to/pi-long-task
43
46
  ```
44
47
 
45
- After installing, start `pi` in your target project and ask it to use the `pi_long_task` tool.
48
+ After installing, start `pi` in your target project and ask it to run a long task.
49
+
50
+ To update an existing npm install:
51
+
52
+ ```bash
53
+ pi update npm:pi-long-task
54
+ ```
55
+
56
+ Or update all installed Pi extension packages:
57
+
58
+ ```bash
59
+ pi update --extensions
60
+ ```
46
61
 
47
62
  ## Usage
48
63
 
49
- Use natural language:
64
+ Use natural language; you do not need to mention `pi_long_task` explicitly:
50
65
 
51
66
  ```text
52
67
  Run a long task without commits to add tests for the parser and fix any failures.
@@ -92,8 +107,22 @@ The tool has two inputs:
92
107
 
93
108
  For natural-language requests, Pi Long Task routes phrases like "run a long task with commits" to the tool with commits enabled. If you ask for a long task without mentioning commits, commits stay disabled.
94
109
 
110
+ Natural-language routing intentionally avoids informational questions, such as "How do I run a long task with commits?", and explicit tool calls are left unchanged.
111
+
95
112
  No other public options are required.
96
113
 
114
+ ## Progress display
115
+
116
+ While a task is running, Pi Long Task shows the active TODO and subtasks parsed from that task's `**Status:**` checkbox list.
117
+
118
+ Status markers are:
119
+
120
+ - `○` not started
121
+ - green `●` done
122
+ - orange `●` inferred as in progress
123
+
124
+ Because workers currently report structured results at the end of a task, in-progress subtask state is inferred: the first unchecked status item is shown as in progress while the task runs.
125
+
97
126
  ## Commits and files
98
127
 
99
128
  When `commit` is `false`, Pi Long Task never creates commits.
@@ -106,6 +135,8 @@ When `commit` is `true`, it may commit eligible task changes after a task report
106
135
 
107
136
  This lets you keep existing local work separate from Pi Long Task changes.
108
137
 
138
+ Commit messages are generated from the task title and adjusted to resemble recent commit-message style in the repository. Pi Long Task does not prefix commits with generated labels like `Complete TODO 1 — ...`.
139
+
109
140
  ## Validate the install
110
141
 
111
142
  Run the local checks:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.1.3",
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": [
@@ -11,13 +11,14 @@ import type {
11
11
  import { commitAfterSession, gitDirtyPaths, shouldCommitOutcome, type CommitAfterSessionResult } from "./git.ts";
12
12
  import { formatCoordinatorResultMessage } from "./render.ts";
13
13
  import { extractResultSummary } from "./result_writer.ts";
14
+ import { buildTaskProgressModel, type TaskProgressModel, type TaskProgressStatus } from "./task_progress.ts";
14
15
  import {
15
16
  buildTodoCreationPrompt,
16
17
  extractTodoMarkdown,
17
18
  todoMarkdownFromString,
18
19
  validateTodoMarkdown,
19
20
  } from "./todo_generator.ts";
20
- import { incompleteTasks, markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
21
+ import { markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
21
22
  import {
22
23
  createIsolatedWorkerSession,
23
24
  lastAssistantTextFromMessages,
@@ -49,7 +50,7 @@ export type CoordinatorProgressPhase =
49
50
  | "task_failed"
50
51
  | "complete";
51
52
 
52
- export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done";
53
+ export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
53
54
 
54
55
  export interface CoordinatorProgressTask {
55
56
  taskId: string;
@@ -79,8 +80,10 @@ export interface CoordinatorProgressUpdate {
79
80
  workerEventType?: string;
80
81
  isError?: boolean;
81
82
  totalTasks?: number;
83
+ workerCostTotal: number;
82
84
  currentTask?: CoordinatorProgressTask;
83
85
  subtasks?: CoordinatorProgressSubtask[];
86
+ taskProgress?: TaskProgressModel;
84
87
  }
85
88
 
86
89
  export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
@@ -142,10 +145,19 @@ export interface CoordinatorResult {
142
145
  outcomes: SessionOutcome[];
143
146
  commits: CoordinatorCommitSummary[];
144
147
  attempts: TaskAttemptSummary[];
148
+ taskProgress: TaskProgressModel;
149
+ workerCostTotal: number;
145
150
  commit: boolean;
146
151
  error?: string;
147
152
  }
148
153
 
154
+ interface WorkerCostState {
155
+ total: number;
156
+ finalizedByWorker: Map<string, number>;
157
+ liveByWorker: Map<string, number>;
158
+ liveByMessage: Map<string, number>;
159
+ }
160
+
149
161
  interface RuntimeOptions {
150
162
  cwd: string;
151
163
  runId: string;
@@ -164,6 +176,7 @@ interface RuntimeOptions {
164
176
  todoSessionFactory?: WorkerSessionFactory;
165
177
  now: () => Date;
166
178
  onProgress?: CoordinatorProgressHandler;
179
+ workerCostState: WorkerCostState;
167
180
  }
168
181
 
169
182
  export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
@@ -184,13 +197,15 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
184
197
  emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
185
198
  phase: "planned",
186
199
  totalTasks: initialTasks.length,
200
+ taskProgress: buildTaskProgressModel({ tasks: initialTasks }),
187
201
  });
188
202
 
189
203
  const previousAttempts = new Map<string, string[]>();
190
204
  let failure: string | undefined;
191
205
 
192
206
  while (!runtime.abortSignal?.aborted) {
193
- const nextTask = incompleteTasks(todoMarkdown)[0];
207
+ const tasksBeforeAttempt = parseTasks(todoMarkdown);
208
+ const nextTask = tasksBeforeAttempt.find((task) => !task.done);
194
209
  if (!nextTask) {
195
210
  break;
196
211
  }
@@ -205,6 +220,11 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
205
220
  title: nextTask.title,
206
221
  attempt,
207
222
  ...currentTaskProgress(nextTask, "in_progress"),
223
+ taskProgress: buildTaskProgressModel({
224
+ tasks: tasksBeforeAttempt,
225
+ attempts,
226
+ currentTaskId: nextTask.taskId,
227
+ }),
208
228
  },
209
229
  );
210
230
  const preExistingDirtyPaths = options.commit
@@ -224,9 +244,10 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
224
244
  abortSignal: runtime.abortSignal,
225
245
  sessionFactory: runtime.workerSessionFactory,
226
246
  now: runtime.now,
227
- onEvent: (event) => emitWorkerEventProgress(runtime, nextTask, attempt, event),
247
+ onEvent: (event) => emitWorkerEventProgress(runtime, tasksBeforeAttempt, nextTask, attempts, attempt, event),
228
248
  });
229
249
  outcomes.push(outcome);
250
+ finalizeWorkerCost(runtime.workerCostState, outcome);
230
251
 
231
252
  if (outcome.done) {
232
253
  todoMarkdown = markTaskDone(todoMarkdown, nextTask.taskId);
@@ -270,7 +291,16 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
270
291
  await appendCommitNote(runtime.taskResultPath, commitResult);
271
292
  }
272
293
 
273
- emitTaskOutcomeProgress(runtime, nextTask, outcome, taskCommitHash, taskCommitError, taskCommitSkipped);
294
+ emitTaskOutcomeProgress(
295
+ runtime,
296
+ parseTasks(todoMarkdown),
297
+ nextTask,
298
+ attempts,
299
+ outcome,
300
+ taskCommitHash,
301
+ taskCommitError,
302
+ taskCommitSkipped,
303
+ );
274
304
 
275
305
  const attemptSummary = resultTextForPreviousAttempt(outcome);
276
306
  previousAttempts.set(nextTask.taskId, [...(previousAttempts.get(nextTask.taskId) ?? []), attemptSummary]);
@@ -304,6 +334,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
304
334
  blockedTasks,
305
335
  failedTasks,
306
336
  });
337
+ const taskProgress = buildCompletionTaskProgressModel(finalTasks, attempts, status);
307
338
  const summary = failure
308
339
  ? `Pi Long Task ${status}: ${failure}`
309
340
  : `Pi Long Task completed ${completedTasks}/${finalTasks.length} task(s).`;
@@ -325,6 +356,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
325
356
  outcomes,
326
357
  commits,
327
358
  attempts,
359
+ taskProgress,
360
+ workerCostTotal: runtime.workerCostState.total,
328
361
  commit: options.commit,
329
362
  error: failure,
330
363
  };
@@ -333,6 +366,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
333
366
  phase: "complete",
334
367
  status,
335
368
  totalTasks: finalTasks.length,
369
+ taskProgress,
336
370
  });
337
371
  return result;
338
372
  } catch (error) {
@@ -362,11 +396,17 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
362
396
  outcomes,
363
397
  commits,
364
398
  attempts,
399
+ taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
400
+ workerCostTotal: runtime.workerCostState.total,
365
401
  commit: options.commit,
366
402
  error: message,
367
403
  };
368
404
  result.message = formatCoordinatorResultMessage(result);
369
- emitProgress(runtime, "Pi Long Task failed.", { phase: "complete", status: "failed" });
405
+ emitProgress(runtime, "Pi Long Task failed.", {
406
+ phase: "complete",
407
+ status: "failed",
408
+ taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
409
+ });
370
410
  return result;
371
411
  }
372
412
  }
@@ -440,23 +480,101 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
440
480
  todoSessionFactory: options.todoSessionFactory,
441
481
  now: options.now ?? (() => new Date()),
442
482
  onProgress: options.onProgress,
483
+ workerCostState: createWorkerCostState(),
443
484
  };
444
485
  }
445
486
 
446
487
  function emitProgress(
447
488
  runtime: RuntimeOptions,
448
489
  message: string,
449
- update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath">,
490
+ update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath" | "workerCostTotal">,
450
491
  ): void {
451
492
  runtime.onProgress?.({
452
493
  message,
453
494
  runId: runtime.runId,
454
495
  todoPath: runtime.todoPath,
455
496
  resultPath: runtime.taskResultPath,
497
+ workerCostTotal: runtime.workerCostState.total,
456
498
  ...update,
457
499
  });
458
500
  }
459
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
+
460
578
  function currentTaskProgress(
461
579
  task: Pick<Task, "taskId" | "title" | "statusItems">,
462
580
  status: CoordinatorProgressItemStatus,
@@ -475,14 +593,14 @@ function subtaskProgress(
475
593
  task: Pick<Task, "statusItems">,
476
594
  taskStatus: CoordinatorProgressItemStatus,
477
595
  ): CoordinatorProgressSubtask[] {
478
- let markedInProgress = false;
596
+ let markedActive = false;
479
597
  return task.statusItems.map((item) => {
480
598
  if (item.done || taskStatus === "done") {
481
599
  return { text: item.text, status: "done" };
482
600
  }
483
- if (taskStatus === "in_progress" && !markedInProgress) {
484
- markedInProgress = true;
485
- return { text: item.text, status: "in_progress" };
601
+ if ((taskStatus === "in_progress" || taskStatus === "failed" || taskStatus === "blocked") && !markedActive) {
602
+ markedActive = true;
603
+ return { text: item.text, status: taskStatus };
486
604
  }
487
605
  return { text: item.text, status: "empty" };
488
606
  });
@@ -490,15 +608,37 @@ function subtaskProgress(
490
608
 
491
609
  function emitWorkerEventProgress(
492
610
  runtime: RuntimeOptions,
611
+ tasks: readonly Task[],
493
612
  task: Pick<Task, "taskId" | "title" | "statusItems">,
613
+ attempts: readonly TaskAttemptSummary[],
494
614
  attempt: number,
495
- event: { type: string; toolName?: string; isError?: boolean },
615
+ event: { type: string; toolName?: string; isError?: boolean; usageCostTotal?: number; usageCostKey?: string },
496
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
+
497
637
  if (!event.toolName || (event.type !== "tool_execution_start" && event.type !== "tool_execution_end")) {
498
638
  return;
499
639
  }
500
640
  const action = event.type === "tool_execution_start" ? "started" : event.isError ? "failed" : "finished";
501
- const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath"> = {
641
+ const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath" | "workerCostTotal"> = {
502
642
  phase: "worker_tool",
503
643
  taskId: task.taskId,
504
644
  title: task.title,
@@ -508,6 +648,7 @@ function emitWorkerEventProgress(
508
648
  workerEventType: event.type,
509
649
  isError: event.isError,
510
650
  ...currentTaskProgress(task, "in_progress"),
651
+ taskProgress: buildTaskProgressModel({ tasks, attempts, currentTaskId: task.taskId }),
511
652
  };
512
653
  if (event.isError) {
513
654
  update.status = "failed";
@@ -517,7 +658,9 @@ function emitWorkerEventProgress(
517
658
 
518
659
  function emitTaskOutcomeProgress(
519
660
  runtime: RuntimeOptions,
661
+ tasks: readonly Task[],
520
662
  task: Pick<Task, "taskId" | "title" | "statusItems">,
663
+ attempts: readonly TaskAttemptSummary[],
521
664
  outcome: SessionOutcome,
522
665
  commitHash: string | undefined,
523
666
  commitError: string | undefined,
@@ -536,13 +679,19 @@ function emitTaskOutcomeProgress(
536
679
  : outcome.reportedStatus === "blocked"
537
680
  ? "task_blocked"
538
681
  : "task_failed";
539
- const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath"> = {
682
+ const update: Omit<CoordinatorProgressUpdate, "message" | "runId" | "todoPath" | "resultPath" | "workerCostTotal"> = {
540
683
  phase,
541
684
  taskId: task.taskId,
542
685
  title: task.title,
543
686
  attempt: outcome.attempt,
544
687
  status: outcome.reportedStatus,
545
- ...currentTaskProgress(task, outcome.done ? "done" : "in_progress"),
688
+ ...currentTaskProgress(task, outcomeProgressItemStatus(outcome)),
689
+ taskProgress: buildTaskProgressModel({
690
+ tasks,
691
+ attempts,
692
+ currentTaskId: task.taskId,
693
+ currentTaskStatus: outcomeTaskProgressStatus(outcome),
694
+ }),
546
695
  };
547
696
  if (commitHash) {
548
697
  update.commitHash = commitHash;
@@ -556,6 +705,50 @@ function emitTaskOutcomeProgress(
556
705
  emitProgress(runtime, `TODO ${task.taskId} ${statusText}${commitText}.`, update);
557
706
  }
558
707
 
708
+ function buildCompletionTaskProgressModel(
709
+ tasks: readonly Task[],
710
+ attempts: readonly TaskAttemptSummary[],
711
+ status: CoordinatorStatus,
712
+ ): TaskProgressModel {
713
+ if (status === "done") {
714
+ return buildTaskProgressModel({ tasks, attempts });
715
+ }
716
+
717
+ const lastIncompleteAttempt = [...attempts].reverse().find((attempt) => !attempt.done);
718
+ if (!lastIncompleteAttempt) {
719
+ return buildTaskProgressModel({ tasks, attempts });
720
+ }
721
+
722
+ return buildTaskProgressModel({
723
+ tasks,
724
+ attempts,
725
+ currentTaskId: lastIncompleteAttempt.taskId,
726
+ currentTaskStatus: outcomeTaskProgressStatus(lastIncompleteAttempt),
727
+ });
728
+ }
729
+
730
+ function outcomeTaskProgressStatus(outcome: Pick<SessionOutcome, "done" | "reportedStatus">): TaskProgressStatus {
731
+ if (outcome.done) {
732
+ return "completed";
733
+ }
734
+ if (outcome.reportedStatus === "blocked") {
735
+ return "blocked";
736
+ }
737
+ return "failed";
738
+ }
739
+
740
+ function outcomeProgressItemStatus(
741
+ outcome: Pick<SessionOutcome, "done" | "reportedStatus">,
742
+ ): CoordinatorProgressItemStatus {
743
+ if (outcome.done) {
744
+ return "done";
745
+ }
746
+ if (outcome.reportedStatus === "blocked") {
747
+ return "blocked";
748
+ }
749
+ return "failed";
750
+ }
751
+
559
752
  function initialTaskResultMarkdown(runId: string): string {
560
753
  return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
561
754
  }
@@ -677,6 +870,20 @@ function positiveMilliseconds(value: number | undefined, fallback: number): numb
677
870
  return fallback;
678
871
  }
679
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
+
680
887
  function errorMessage(error: unknown): string {
681
888
  return error instanceof Error ? error.message : String(error);
682
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,
@@ -18,11 +68,24 @@ function toolDetails(result: CoordinatorResult) {
18
68
  failedTasks: result.failedTasks,
19
69
  blockedTasks: result.blockedTasks,
20
70
  remainingTasks: result.remainingTasks,
71
+ taskProgress: result.taskProgress,
72
+ workerCostTotal: result.workerCostTotal,
21
73
  summary: result.summary,
22
74
  };
23
75
  }
24
76
 
25
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
+
26
89
  pi.on("input", (event) => {
27
90
  if (event.source === "extension") {
28
91
  return { action: "continue" as const };
@@ -63,6 +126,7 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
63
126
  abortSignal: signal,
64
127
  onProgress: publishProgress,
65
128
  });
129
+ workerCostAccumulator.add(result.workerCostTotal);
66
130
 
67
131
  return {
68
132
  content: [