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 +39 -8
- package/package.json +1 -1
- package/src/coordinator.ts +222 -15
- package/src/index.ts +64 -0
- package/src/render.ts +287 -9
- package/src/task_progress.ts +187 -0
- package/src/types.ts +3 -0
- package/src/worker_session.ts +142 -2
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
|
|
9
|
+
When you ask Pi to run a long task, it will:
|
|
10
10
|
|
|
11
|
-
1.
|
|
12
|
-
2.
|
|
13
|
-
3.
|
|
14
|
-
4.
|
|
15
|
-
5.
|
|
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
|
|
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
package/src/coordinator.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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.", {
|
|
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
|
|
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" && !
|
|
484
|
-
|
|
485
|
-
return { text: item.text, status:
|
|
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
|
|
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: [
|