pi-long-task 0.1.2 → 0.2.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 +52 -7
- package/package.json +1 -1
- package/src/coordinator.ts +93 -11
- package/src/index.ts +17 -1
- package/src/input_router.ts +57 -0
- package/src/render.ts +260 -9
- package/src/task_progress.ts +187 -0
- package/src/types.ts +9 -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,10 +45,34 @@ 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
|
|
|
64
|
+
Use natural language; you do not need to mention `pi_long_task` explicitly:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
Run a long task without commits to add tests for the parser and fix any failures.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
Run a long task with commits to implement the TODOs in @TODO.md.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
You can also call the tool explicitly.
|
|
75
|
+
|
|
49
76
|
Run without commits:
|
|
50
77
|
|
|
51
78
|
```text
|
|
@@ -78,8 +105,24 @@ The tool has two inputs:
|
|
|
78
105
|
- `inputText` is the request or TODO markdown to work on.
|
|
79
106
|
- `commit` controls whether Pi Long Task may create git commits.
|
|
80
107
|
|
|
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.
|
|
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
|
+
|
|
81
112
|
No other public options are required.
|
|
82
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
|
+
|
|
83
126
|
## Commits and files
|
|
84
127
|
|
|
85
128
|
When `commit` is `false`, Pi Long Task never creates commits.
|
|
@@ -92,6 +135,8 @@ When `commit` is `true`, it may commit eligible task changes after a task report
|
|
|
92
135
|
|
|
93
136
|
This lets you keep existing local work separate from Pi Long Task changes.
|
|
94
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
|
+
|
|
95
140
|
## Validate the install
|
|
96
141
|
|
|
97
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;
|
|
@@ -81,6 +82,7 @@ export interface CoordinatorProgressUpdate {
|
|
|
81
82
|
totalTasks?: number;
|
|
82
83
|
currentTask?: CoordinatorProgressTask;
|
|
83
84
|
subtasks?: CoordinatorProgressSubtask[];
|
|
85
|
+
taskProgress?: TaskProgressModel;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
|
|
@@ -142,6 +144,7 @@ export interface CoordinatorResult {
|
|
|
142
144
|
outcomes: SessionOutcome[];
|
|
143
145
|
commits: CoordinatorCommitSummary[];
|
|
144
146
|
attempts: TaskAttemptSummary[];
|
|
147
|
+
taskProgress: TaskProgressModel;
|
|
145
148
|
commit: boolean;
|
|
146
149
|
error?: string;
|
|
147
150
|
}
|
|
@@ -184,13 +187,15 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
184
187
|
emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
|
|
185
188
|
phase: "planned",
|
|
186
189
|
totalTasks: initialTasks.length,
|
|
190
|
+
taskProgress: buildTaskProgressModel({ tasks: initialTasks }),
|
|
187
191
|
});
|
|
188
192
|
|
|
189
193
|
const previousAttempts = new Map<string, string[]>();
|
|
190
194
|
let failure: string | undefined;
|
|
191
195
|
|
|
192
196
|
while (!runtime.abortSignal?.aborted) {
|
|
193
|
-
const
|
|
197
|
+
const tasksBeforeAttempt = parseTasks(todoMarkdown);
|
|
198
|
+
const nextTask = tasksBeforeAttempt.find((task) => !task.done);
|
|
194
199
|
if (!nextTask) {
|
|
195
200
|
break;
|
|
196
201
|
}
|
|
@@ -205,6 +210,11 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
205
210
|
title: nextTask.title,
|
|
206
211
|
attempt,
|
|
207
212
|
...currentTaskProgress(nextTask, "in_progress"),
|
|
213
|
+
taskProgress: buildTaskProgressModel({
|
|
214
|
+
tasks: tasksBeforeAttempt,
|
|
215
|
+
attempts,
|
|
216
|
+
currentTaskId: nextTask.taskId,
|
|
217
|
+
}),
|
|
208
218
|
},
|
|
209
219
|
);
|
|
210
220
|
const preExistingDirtyPaths = options.commit
|
|
@@ -224,7 +234,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
224
234
|
abortSignal: runtime.abortSignal,
|
|
225
235
|
sessionFactory: runtime.workerSessionFactory,
|
|
226
236
|
now: runtime.now,
|
|
227
|
-
onEvent: (event) => emitWorkerEventProgress(runtime, nextTask, attempt, event),
|
|
237
|
+
onEvent: (event) => emitWorkerEventProgress(runtime, tasksBeforeAttempt, nextTask, attempts, attempt, event),
|
|
228
238
|
});
|
|
229
239
|
outcomes.push(outcome);
|
|
230
240
|
|
|
@@ -270,7 +280,16 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
270
280
|
await appendCommitNote(runtime.taskResultPath, commitResult);
|
|
271
281
|
}
|
|
272
282
|
|
|
273
|
-
emitTaskOutcomeProgress(
|
|
283
|
+
emitTaskOutcomeProgress(
|
|
284
|
+
runtime,
|
|
285
|
+
parseTasks(todoMarkdown),
|
|
286
|
+
nextTask,
|
|
287
|
+
attempts,
|
|
288
|
+
outcome,
|
|
289
|
+
taskCommitHash,
|
|
290
|
+
taskCommitError,
|
|
291
|
+
taskCommitSkipped,
|
|
292
|
+
);
|
|
274
293
|
|
|
275
294
|
const attemptSummary = resultTextForPreviousAttempt(outcome);
|
|
276
295
|
previousAttempts.set(nextTask.taskId, [...(previousAttempts.get(nextTask.taskId) ?? []), attemptSummary]);
|
|
@@ -304,6 +323,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
304
323
|
blockedTasks,
|
|
305
324
|
failedTasks,
|
|
306
325
|
});
|
|
326
|
+
const taskProgress = buildCompletionTaskProgressModel(finalTasks, attempts, status);
|
|
307
327
|
const summary = failure
|
|
308
328
|
? `Pi Long Task ${status}: ${failure}`
|
|
309
329
|
: `Pi Long Task completed ${completedTasks}/${finalTasks.length} task(s).`;
|
|
@@ -325,6 +345,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
325
345
|
outcomes,
|
|
326
346
|
commits,
|
|
327
347
|
attempts,
|
|
348
|
+
taskProgress,
|
|
328
349
|
commit: options.commit,
|
|
329
350
|
error: failure,
|
|
330
351
|
};
|
|
@@ -333,6 +354,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
333
354
|
phase: "complete",
|
|
334
355
|
status,
|
|
335
356
|
totalTasks: finalTasks.length,
|
|
357
|
+
taskProgress,
|
|
336
358
|
});
|
|
337
359
|
return result;
|
|
338
360
|
} catch (error) {
|
|
@@ -362,11 +384,16 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
362
384
|
outcomes,
|
|
363
385
|
commits,
|
|
364
386
|
attempts,
|
|
387
|
+
taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
|
|
365
388
|
commit: options.commit,
|
|
366
389
|
error: message,
|
|
367
390
|
};
|
|
368
391
|
result.message = formatCoordinatorResultMessage(result);
|
|
369
|
-
emitProgress(runtime, "Pi Long Task failed.", {
|
|
392
|
+
emitProgress(runtime, "Pi Long Task failed.", {
|
|
393
|
+
phase: "complete",
|
|
394
|
+
status: "failed",
|
|
395
|
+
taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
|
|
396
|
+
});
|
|
370
397
|
return result;
|
|
371
398
|
}
|
|
372
399
|
}
|
|
@@ -475,14 +502,14 @@ function subtaskProgress(
|
|
|
475
502
|
task: Pick<Task, "statusItems">,
|
|
476
503
|
taskStatus: CoordinatorProgressItemStatus,
|
|
477
504
|
): CoordinatorProgressSubtask[] {
|
|
478
|
-
let
|
|
505
|
+
let markedActive = false;
|
|
479
506
|
return task.statusItems.map((item) => {
|
|
480
507
|
if (item.done || taskStatus === "done") {
|
|
481
508
|
return { text: item.text, status: "done" };
|
|
482
509
|
}
|
|
483
|
-
if (taskStatus === "in_progress" && !
|
|
484
|
-
|
|
485
|
-
return { text: item.text, status:
|
|
510
|
+
if ((taskStatus === "in_progress" || taskStatus === "failed" || taskStatus === "blocked") && !markedActive) {
|
|
511
|
+
markedActive = true;
|
|
512
|
+
return { text: item.text, status: taskStatus };
|
|
486
513
|
}
|
|
487
514
|
return { text: item.text, status: "empty" };
|
|
488
515
|
});
|
|
@@ -490,7 +517,9 @@ function subtaskProgress(
|
|
|
490
517
|
|
|
491
518
|
function emitWorkerEventProgress(
|
|
492
519
|
runtime: RuntimeOptions,
|
|
520
|
+
tasks: readonly Task[],
|
|
493
521
|
task: Pick<Task, "taskId" | "title" | "statusItems">,
|
|
522
|
+
attempts: readonly TaskAttemptSummary[],
|
|
494
523
|
attempt: number,
|
|
495
524
|
event: { type: string; toolName?: string; isError?: boolean },
|
|
496
525
|
): void {
|
|
@@ -508,6 +537,7 @@ function emitWorkerEventProgress(
|
|
|
508
537
|
workerEventType: event.type,
|
|
509
538
|
isError: event.isError,
|
|
510
539
|
...currentTaskProgress(task, "in_progress"),
|
|
540
|
+
taskProgress: buildTaskProgressModel({ tasks, attempts, currentTaskId: task.taskId }),
|
|
511
541
|
};
|
|
512
542
|
if (event.isError) {
|
|
513
543
|
update.status = "failed";
|
|
@@ -517,7 +547,9 @@ function emitWorkerEventProgress(
|
|
|
517
547
|
|
|
518
548
|
function emitTaskOutcomeProgress(
|
|
519
549
|
runtime: RuntimeOptions,
|
|
550
|
+
tasks: readonly Task[],
|
|
520
551
|
task: Pick<Task, "taskId" | "title" | "statusItems">,
|
|
552
|
+
attempts: readonly TaskAttemptSummary[],
|
|
521
553
|
outcome: SessionOutcome,
|
|
522
554
|
commitHash: string | undefined,
|
|
523
555
|
commitError: string | undefined,
|
|
@@ -542,7 +574,13 @@ function emitTaskOutcomeProgress(
|
|
|
542
574
|
title: task.title,
|
|
543
575
|
attempt: outcome.attempt,
|
|
544
576
|
status: outcome.reportedStatus,
|
|
545
|
-
...currentTaskProgress(task, outcome
|
|
577
|
+
...currentTaskProgress(task, outcomeProgressItemStatus(outcome)),
|
|
578
|
+
taskProgress: buildTaskProgressModel({
|
|
579
|
+
tasks,
|
|
580
|
+
attempts,
|
|
581
|
+
currentTaskId: task.taskId,
|
|
582
|
+
currentTaskStatus: outcomeTaskProgressStatus(outcome),
|
|
583
|
+
}),
|
|
546
584
|
};
|
|
547
585
|
if (commitHash) {
|
|
548
586
|
update.commitHash = commitHash;
|
|
@@ -556,6 +594,50 @@ function emitTaskOutcomeProgress(
|
|
|
556
594
|
emitProgress(runtime, `TODO ${task.taskId} ${statusText}${commitText}.`, update);
|
|
557
595
|
}
|
|
558
596
|
|
|
597
|
+
function buildCompletionTaskProgressModel(
|
|
598
|
+
tasks: readonly Task[],
|
|
599
|
+
attempts: readonly TaskAttemptSummary[],
|
|
600
|
+
status: CoordinatorStatus,
|
|
601
|
+
): TaskProgressModel {
|
|
602
|
+
if (status === "done") {
|
|
603
|
+
return buildTaskProgressModel({ tasks, attempts });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const lastIncompleteAttempt = [...attempts].reverse().find((attempt) => !attempt.done);
|
|
607
|
+
if (!lastIncompleteAttempt) {
|
|
608
|
+
return buildTaskProgressModel({ tasks, attempts });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return buildTaskProgressModel({
|
|
612
|
+
tasks,
|
|
613
|
+
attempts,
|
|
614
|
+
currentTaskId: lastIncompleteAttempt.taskId,
|
|
615
|
+
currentTaskStatus: outcomeTaskProgressStatus(lastIncompleteAttempt),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function outcomeTaskProgressStatus(outcome: Pick<SessionOutcome, "done" | "reportedStatus">): TaskProgressStatus {
|
|
620
|
+
if (outcome.done) {
|
|
621
|
+
return "completed";
|
|
622
|
+
}
|
|
623
|
+
if (outcome.reportedStatus === "blocked") {
|
|
624
|
+
return "blocked";
|
|
625
|
+
}
|
|
626
|
+
return "failed";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function outcomeProgressItemStatus(
|
|
630
|
+
outcome: Pick<SessionOutcome, "done" | "reportedStatus">,
|
|
631
|
+
): CoordinatorProgressItemStatus {
|
|
632
|
+
if (outcome.done) {
|
|
633
|
+
return "done";
|
|
634
|
+
}
|
|
635
|
+
if (outcome.reportedStatus === "blocked") {
|
|
636
|
+
return "blocked";
|
|
637
|
+
}
|
|
638
|
+
return "failed";
|
|
639
|
+
}
|
|
640
|
+
|
|
559
641
|
function initialTaskResultMarkdown(runId: string): string {
|
|
560
642
|
return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
|
|
561
643
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { runCoordinator, type CoordinatorProgressUpdate, type CoordinatorResult } from "./coordinator.ts";
|
|
4
|
+
import { longTaskInputTransform } from "./input_router.ts";
|
|
4
5
|
import { renderLongTaskToolCall, renderLongTaskToolResult } from "./render.ts";
|
|
5
6
|
import { PiLongTaskParams } from "./types.ts";
|
|
6
7
|
|
|
@@ -17,15 +18,30 @@ function toolDetails(result: CoordinatorResult) {
|
|
|
17
18
|
failedTasks: result.failedTasks,
|
|
18
19
|
blockedTasks: result.blockedTasks,
|
|
19
20
|
remainingTasks: result.remainingTasks,
|
|
21
|
+
taskProgress: result.taskProgress,
|
|
20
22
|
summary: result.summary,
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
|
|
27
|
+
pi.on("input", (event) => {
|
|
28
|
+
if (event.source === "extension") {
|
|
29
|
+
return { action: "continue" as const };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const transformed = longTaskInputTransform(event.text);
|
|
33
|
+
if (!transformed) {
|
|
34
|
+
return { action: "continue" as const };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { action: "transform" as const, text: transformed };
|
|
38
|
+
});
|
|
39
|
+
|
|
25
40
|
pi.registerTool({
|
|
26
41
|
name: "pi_long_task",
|
|
27
42
|
label: "Pi Long Task",
|
|
28
|
-
description:
|
|
43
|
+
description:
|
|
44
|
+
"Run long or multi-step coding tasks from a request or TODO plan. Use this when the user asks to run/start/handle a long task; set commit true only when they ask for commits or committing as work progresses.",
|
|
29
45
|
parameters: PiLongTaskParams,
|
|
30
46
|
renderCall: renderLongTaskToolCall,
|
|
31
47
|
renderResult: renderLongTaskToolResult,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const LONG_TASK_RE = /\b(?:long[-\s]?task|longtask|large[-\s]?task|big[-\s]?task|multi[-\s]?step(?:\s+task)?)\b/i;
|
|
2
|
+
const DIRECT_LONG_TASK_REQUEST_RE =
|
|
3
|
+
/\b(?:run|start|do|handle|execute|launch|kick\s+off|use)\s+(?:a\s+|the\s+)?(?:long[-\s]?task|longtask|large[-\s]?task|big[-\s]?task|multi[-\s]?step(?:\s+task)?)\b/i;
|
|
4
|
+
const WANT_LONG_TASK_RE =
|
|
5
|
+
/\b(?:please|can\s+you|could\s+you|i\s+want|i\s+need|i'd\s+like|i\s+would\s+like|let's|lets)\b[\s\S]*\b(?:long[-\s]?task|longtask|large[-\s]?task|big[-\s]?task|multi[-\s]?step(?:\s+task)?)\b/i;
|
|
6
|
+
const NEGATED_LONG_TASK_RE =
|
|
7
|
+
/\b(?:do\s+not|don't|dont|never)\s+(?:run|start|do|handle|execute|launch|kick\s+off|use)\s+(?:a\s+|the\s+)?(?:long[-\s]?task|longtask|large[-\s]?task|big[-\s]?task|multi[-\s]?step(?:\s+task)?)\b/i;
|
|
8
|
+
const INFORMATION_QUESTION_RE = /^\s*(?:how|what|why|when|where|who)\b/i;
|
|
9
|
+
const EXPLICIT_TOOL_RE = /\bpi_long_task\b/i;
|
|
10
|
+
|
|
11
|
+
const COMMIT_FALSE_RE =
|
|
12
|
+
/\b(?:without|no|disable|disabled|off)\s+commits?\b|\bcommits?\s*(?:false|off|disabled)\b|\bcommit\s*:\s*(?:false|off|no)\b|\b(?:do\s+not|don't|dont)\s+commit\b/i;
|
|
13
|
+
const COMMIT_TRUE_RE =
|
|
14
|
+
/\bwith\s+commits?\b|\bcommits?\s*(?:true|on|enabled)\b|\bcommit\s*:\s*(?:true|on|yes)\b|\bcommit(?:ting)?\s+as\s+(?:you|we)\s+go\b|\b(?:make|create|include|allow|enable)\s+commits?\b/i;
|
|
15
|
+
|
|
16
|
+
export function longTaskInputTransform(text: string): string | undefined {
|
|
17
|
+
if (!isNaturalLanguageLongTaskRequest(text)) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const commit = inferCommitSetting(text) ?? false;
|
|
22
|
+
return buildLongTaskToolPrompt(text, commit);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isNaturalLanguageLongTaskRequest(text: string): boolean {
|
|
26
|
+
const trimmed = text.trim();
|
|
27
|
+
if (!trimmed || trimmed.startsWith("/") || EXPLICIT_TOOL_RE.test(trimmed)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (!LONG_TASK_RE.test(trimmed) || INFORMATION_QUESTION_RE.test(trimmed) || NEGATED_LONG_TASK_RE.test(trimmed)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return DIRECT_LONG_TASK_REQUEST_RE.test(trimmed) || WANT_LONG_TASK_RE.test(trimmed);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function inferCommitSetting(text: string): boolean | undefined {
|
|
37
|
+
if (COMMIT_FALSE_RE.test(text)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (COMMIT_TRUE_RE.test(text)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildLongTaskToolPrompt(originalText: string, commit: boolean): string {
|
|
47
|
+
return [
|
|
48
|
+
"Use the pi_long_task tool for this request.",
|
|
49
|
+
`Set commit to ${commit ? "true" : "false"}.`,
|
|
50
|
+
"Set inputText to the user's original request below. Do not perform the work directly outside pi_long_task.",
|
|
51
|
+
"",
|
|
52
|
+
"Original request:",
|
|
53
|
+
"```text",
|
|
54
|
+
originalText.trim(),
|
|
55
|
+
"```",
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
+
import type { TaskProgressModel, TaskProgressStatus, TaskProgressTask } from "./task_progress.ts";
|
|
4
5
|
import type { CoordinatorCommitSummary, CoordinatorRemainingTask, CoordinatorStatus } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
export interface CoordinatorResultForRendering {
|
|
@@ -15,6 +16,7 @@ export interface CoordinatorResultForRendering {
|
|
|
15
16
|
taskResultPath?: string;
|
|
16
17
|
commits?: CoordinatorCommitSummary[];
|
|
17
18
|
remainingTasks?: CoordinatorRemainingTask[];
|
|
19
|
+
taskProgress?: TaskProgressModel;
|
|
18
20
|
error?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -22,7 +24,7 @@ export interface CoordinatorToolRenderDetails extends CoordinatorResultForRender
|
|
|
22
24
|
runId?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
type ProgressItemStatus = "empty" | "in_progress" | "done";
|
|
27
|
+
type ProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
|
|
26
28
|
|
|
27
29
|
interface ProgressTaskRenderDetails {
|
|
28
30
|
taskId: string;
|
|
@@ -35,6 +37,70 @@ interface ProgressSubtaskRenderDetails {
|
|
|
35
37
|
status: ProgressItemStatus;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
const SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH = 84;
|
|
41
|
+
const SIDEBAR_MIN_WIDTH = 26;
|
|
42
|
+
const SIDEBAR_MAX_WIDTH = 40;
|
|
43
|
+
const SIDEBAR_GAP = 2;
|
|
44
|
+
|
|
45
|
+
class LongTaskSidebarShell implements Component {
|
|
46
|
+
private readonly mainText: string;
|
|
47
|
+
private readonly taskProgress: TaskProgressModel;
|
|
48
|
+
private readonly theme: Theme;
|
|
49
|
+
|
|
50
|
+
constructor(mainText: string, taskProgress: TaskProgressModel, theme: Theme) {
|
|
51
|
+
this.mainText = mainText;
|
|
52
|
+
this.taskProgress = taskProgress;
|
|
53
|
+
this.theme = theme;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
render(width: number): string[] {
|
|
57
|
+
if (width < SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH) {
|
|
58
|
+
return this.renderStacked(width);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sidebarWidth = clamp(Math.floor(width * 0.32), SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
|
62
|
+
const mainWidth = width - sidebarWidth - SIDEBAR_GAP;
|
|
63
|
+
if (mainWidth < 40) {
|
|
64
|
+
return this.renderStacked(width);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const mainLines = renderWrappedLines(this.mainText, mainWidth);
|
|
68
|
+
const sidebarLines = this.renderSidebar(sidebarWidth);
|
|
69
|
+
const height = Math.max(mainLines.length, sidebarLines.length);
|
|
70
|
+
const lines: string[] = [];
|
|
71
|
+
for (let idx = 0; idx < height; idx += 1) {
|
|
72
|
+
const main = padLine(mainLines[idx] ?? "", mainWidth);
|
|
73
|
+
const sidebar = sidebarLines[idx] ?? "";
|
|
74
|
+
lines.push(truncateToWidth(`${main}${" ".repeat(SIDEBAR_GAP)}${sidebar}`, width));
|
|
75
|
+
}
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
invalidate(): void {
|
|
80
|
+
// Rendering is computed from current state on each pass.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private renderStacked(width: number): string[] {
|
|
84
|
+
const mainLines = renderWrappedLines(this.mainText, width);
|
|
85
|
+
const sidebarLines = this.renderSidebar(width);
|
|
86
|
+
return [...mainLines, ...sidebarLines].map((line) => truncateToWidth(line, width));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private renderSidebar(width: number): string[] {
|
|
90
|
+
if (width < 8) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const innerWidth = Math.max(0, width - 2);
|
|
95
|
+
const rows = sidebarRows(this.taskProgress, this.theme);
|
|
96
|
+
return [
|
|
97
|
+
sidebarBorder("Long Task", width, this.theme),
|
|
98
|
+
...rows.map((row) => sidebarRow(row, innerWidth, this.theme)),
|
|
99
|
+
this.theme.fg("borderMuted", `└${"─".repeat(innerWidth)}┘`),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
38
104
|
export function formatCoordinatorResultMessage(result: CoordinatorResultForRendering): string {
|
|
39
105
|
const resultPath = result.resultPath ?? result.taskResultPath ?? "unknown";
|
|
40
106
|
const remaining = result.remainingTasks ?? [];
|
|
@@ -84,10 +150,12 @@ export function renderLongTaskToolResult(
|
|
|
84
150
|
result: AgentToolResult<unknown>,
|
|
85
151
|
options: ToolRenderResultOptions,
|
|
86
152
|
theme: Theme,
|
|
87
|
-
):
|
|
153
|
+
): Component {
|
|
88
154
|
const details = recordOrUndefined(result.details);
|
|
89
155
|
if (options.isPartial) {
|
|
90
|
-
|
|
156
|
+
const taskProgress = taskProgressModel(details?.taskProgress);
|
|
157
|
+
const main = renderLongTaskProgress(details, contentText(result), theme);
|
|
158
|
+
return taskProgress ? new LongTaskSidebarShell(main, taskProgress, theme) : new Text(main, 0, 0);
|
|
91
159
|
}
|
|
92
160
|
|
|
93
161
|
const finalDetails = longTaskDetails(details);
|
|
@@ -95,7 +163,10 @@ export function renderLongTaskToolResult(
|
|
|
95
163
|
return new Text(contentText(result), 0, 0);
|
|
96
164
|
}
|
|
97
165
|
|
|
98
|
-
|
|
166
|
+
const main = renderLongTaskSummary(finalDetails, options.expanded, theme);
|
|
167
|
+
return finalDetails.taskProgress
|
|
168
|
+
? new LongTaskSidebarShell(main, finalDetails.taskProgress, theme)
|
|
169
|
+
: new Text(main, 0, 0);
|
|
99
170
|
}
|
|
100
171
|
|
|
101
172
|
function renderLongTaskProgress(details: Record<string, unknown> | undefined, fallback: string, theme: Theme): string {
|
|
@@ -173,6 +244,171 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
|
|
|
173
244
|
return lines.join("\n");
|
|
174
245
|
}
|
|
175
246
|
|
|
247
|
+
function renderWrappedLines(text: string, width: number): string[] {
|
|
248
|
+
return new Text(text, 0, 0).render(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function padLine(line: string, width: number): string {
|
|
252
|
+
return truncateToWidth(line, width, "…", true);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function sidebarRows(taskProgress: TaskProgressModel, theme: Theme): string[] {
|
|
256
|
+
const summary = normalizedTaskProgressSummary(taskProgress);
|
|
257
|
+
const rows = [theme.fg("toolTitle", theme.bold("Task sidebar")), theme.fg("dim", "Centered timeline")];
|
|
258
|
+
if (summary.totalTasks === 0) {
|
|
259
|
+
rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
|
|
260
|
+
return rows;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
rows.push("", progressBarLine(summary.completedTasks, summary.totalTasks, summary.completedPercent, theme));
|
|
264
|
+
rows.push(progressCountsLine(summary, theme));
|
|
265
|
+
|
|
266
|
+
const currentIndex = focusedTaskIndex(taskProgress);
|
|
267
|
+
if (currentIndex >= 0) {
|
|
268
|
+
rows.push(theme.fg("warning", `Focus: TODO ${taskProgress.tasks[currentIndex]?.taskId ?? "?"}`));
|
|
269
|
+
} else {
|
|
270
|
+
rows.push(theme.fg("success", "No active task"));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
rows.push("", theme.fg("muted", "Timeline"));
|
|
274
|
+
for (const [index, task] of taskProgress.tasks.entries()) {
|
|
275
|
+
if (currentIndex >= 0 && index === currentIndex && index > 0) {
|
|
276
|
+
rows.push(theme.fg("dim", "──── current ────"));
|
|
277
|
+
}
|
|
278
|
+
rows.push(renderSidebarTaskRow(task, theme));
|
|
279
|
+
if (currentIndex >= 0 && index === currentIndex && index < taskProgress.tasks.length - 1) {
|
|
280
|
+
rows.push(theme.fg("dim", "──── future ─────"));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return rows;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface NormalizedTaskProgressSummary {
|
|
288
|
+
totalTasks: number;
|
|
289
|
+
completedTasks: number;
|
|
290
|
+
failedTasks: number;
|
|
291
|
+
blockedTasks: number;
|
|
292
|
+
pendingTasks: number;
|
|
293
|
+
currentTasks: number;
|
|
294
|
+
completedPercent: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizedTaskProgressSummary(taskProgress: TaskProgressModel): NormalizedTaskProgressSummary {
|
|
298
|
+
const totalTasks = taskProgress.summary?.totalTasks ?? taskProgress.tasks.length;
|
|
299
|
+
const completedTasks = taskProgress.summary?.completedTasks ?? countTasksByStatus(taskProgress.tasks, "completed");
|
|
300
|
+
const failedTasks = taskProgress.summary?.failedTasks ?? countTasksByStatus(taskProgress.tasks, "failed");
|
|
301
|
+
const blockedTasks = taskProgress.summary?.blockedTasks ?? countTasksByStatus(taskProgress.tasks, "blocked");
|
|
302
|
+
const pendingTasks = taskProgress.summary?.pendingTasks ?? countTasksByStatus(taskProgress.tasks, "pending");
|
|
303
|
+
const currentTasks = taskProgress.summary?.currentTasks ?? countTasksByStatus(taskProgress.tasks, "current");
|
|
304
|
+
const completedPercent =
|
|
305
|
+
taskProgress.summary?.completedPercent ??
|
|
306
|
+
(totalTasks === 0 ? 100 : Math.round((completedTasks / totalTasks) * 100));
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
totalTasks,
|
|
310
|
+
completedTasks,
|
|
311
|
+
failedTasks,
|
|
312
|
+
blockedTasks,
|
|
313
|
+
pendingTasks,
|
|
314
|
+
currentTasks,
|
|
315
|
+
completedPercent,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function countTasksByStatus(tasks: readonly TaskProgressTask[], status: TaskProgressStatus): number {
|
|
320
|
+
return tasks.filter((task) => task.status === status).length;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function progressBarLine(completedTasks: number, totalTasks: number, percent: number, theme: Theme): string {
|
|
324
|
+
const width = 10;
|
|
325
|
+
const filled = clamp(totalTasks === 0 ? width : Math.round((completedTasks / totalTasks) * width), 0, width);
|
|
326
|
+
const empty = Math.max(0, width - filled);
|
|
327
|
+
return `${theme.fg("muted", "Progress")} [${theme.fg("success", "#".repeat(filled))}${theme.fg(
|
|
328
|
+
"dim",
|
|
329
|
+
"-".repeat(empty),
|
|
330
|
+
)}] ${completedTasks}/${totalTasks} ${percent}%`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function progressCountsLine(summary: NormalizedTaskProgressSummary, theme: Theme): string {
|
|
334
|
+
const parts = [
|
|
335
|
+
theme.fg("success", `✓ ${summary.completedTasks}`),
|
|
336
|
+
summary.currentTasks ? theme.fg("warning", `▶ ${summary.currentTasks}`) : undefined,
|
|
337
|
+
summary.pendingTasks ? theme.fg("dim", `○ ${summary.pendingTasks}`) : undefined,
|
|
338
|
+
summary.failedTasks ? theme.fg("error", `✗ ${summary.failedTasks}`) : undefined,
|
|
339
|
+
summary.blockedTasks ? theme.fg("warning", `! ${summary.blockedTasks}`) : undefined,
|
|
340
|
+
].filter(Boolean);
|
|
341
|
+
return parts.join(" · ");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function focusedTaskIndex(taskProgress: TaskProgressModel): number {
|
|
345
|
+
if (typeof taskProgress.currentIndex === "number" && taskProgress.currentIndex >= 0) {
|
|
346
|
+
return taskProgress.currentIndex;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const currentIndex = taskProgress.tasks.findIndex((task) => task.status === "current" || task.position === "current");
|
|
350
|
+
if (currentIndex >= 0) {
|
|
351
|
+
return currentIndex;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (typeof taskProgress.nextIndex === "number" && taskProgress.nextIndex >= 0) {
|
|
355
|
+
return taskProgress.nextIndex;
|
|
356
|
+
}
|
|
357
|
+
return taskProgress.tasks.findIndex((task) => task.status === "pending");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function renderSidebarTaskRow(task: TaskProgressTask, theme: Theme): string {
|
|
361
|
+
const { icon, color, label } = sidebarTaskStatusDetails(task.status);
|
|
362
|
+
const attempts =
|
|
363
|
+
task.attempts > 0 && task.status !== "completed"
|
|
364
|
+
? ` · ${task.attempts} attempt${task.attempts === 1 ? "" : "s"}`
|
|
365
|
+
: "";
|
|
366
|
+
const text = `${icon} [${label}] TODO ${task.taskId} — ${task.title}${attempts}`;
|
|
367
|
+
return task.status === "current" ? theme.fg(color, theme.bold(text)) : theme.fg(color, text);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function sidebarTaskStatusDetails(status: TaskProgressStatus): {
|
|
371
|
+
icon: string;
|
|
372
|
+
color: "success" | "warning" | "error" | "dim" | "muted";
|
|
373
|
+
label: string;
|
|
374
|
+
} {
|
|
375
|
+
switch (status) {
|
|
376
|
+
case "completed":
|
|
377
|
+
return { icon: "✓", color: "success", label: "completed" };
|
|
378
|
+
case "current":
|
|
379
|
+
return { icon: "▶", color: "warning", label: "current" };
|
|
380
|
+
case "failed":
|
|
381
|
+
return { icon: "✗", color: "error", label: "failed" };
|
|
382
|
+
case "blocked":
|
|
383
|
+
return { icon: "!", color: "warning", label: "blocked" };
|
|
384
|
+
case "pending":
|
|
385
|
+
return { icon: "○", color: "dim", label: "pending" };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function sidebarBorder(title: string, width: number, theme: Theme): string {
|
|
390
|
+
const innerWidth = Math.max(0, width - 2);
|
|
391
|
+
const titleText = truncateToWidth(` ${title} `, innerWidth, "");
|
|
392
|
+
const remaining = Math.max(0, innerWidth - visibleWidth(titleText));
|
|
393
|
+
return theme.fg("borderMuted", `┌${titleText}${"─".repeat(remaining)}┐`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function sidebarRow(row: string, innerWidth: number, theme: Theme): string {
|
|
397
|
+
return `${theme.fg("borderMuted", "│")}${truncateToWidth(row, innerWidth, "…", true)}${theme.fg("borderMuted", "│")}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function taskProgressModel(value: unknown): TaskProgressModel | undefined {
|
|
401
|
+
const record = recordOrUndefined(value);
|
|
402
|
+
if (!record || !Array.isArray(record.tasks)) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
return value as TaskProgressModel;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function clamp(value: number, min: number, max: number): number {
|
|
409
|
+
return Math.min(max, Math.max(min, value));
|
|
410
|
+
}
|
|
411
|
+
|
|
176
412
|
function progressTaskDetails(value: unknown): ProgressTaskRenderDetails | undefined {
|
|
177
413
|
const record = recordOrUndefined(value);
|
|
178
414
|
const taskId = stringValue(record?.taskId);
|
|
@@ -200,18 +436,32 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
|
|
|
200
436
|
}
|
|
201
437
|
|
|
202
438
|
function progressItemStatus(value: unknown): ProgressItemStatus | undefined {
|
|
203
|
-
return value === "empty" || value === "in_progress" || value === "done"
|
|
439
|
+
return value === "empty" || value === "in_progress" || value === "done" || value === "failed" || value === "blocked"
|
|
440
|
+
? value
|
|
441
|
+
: undefined;
|
|
204
442
|
}
|
|
205
443
|
|
|
206
444
|
function progressBubble(status: ProgressItemStatus, theme: Theme): string {
|
|
207
|
-
|
|
445
|
+
switch (status) {
|
|
446
|
+
case "empty":
|
|
447
|
+
return theme.fg("dim", "○");
|
|
448
|
+
case "failed":
|
|
449
|
+
return theme.fg("error", "✗");
|
|
450
|
+
case "blocked":
|
|
451
|
+
return theme.fg("warning", "!");
|
|
452
|
+
default:
|
|
453
|
+
return theme.fg(progressTextColor(status), "●");
|
|
454
|
+
}
|
|
208
455
|
}
|
|
209
456
|
|
|
210
|
-
function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" {
|
|
457
|
+
function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" | "error" {
|
|
211
458
|
if (status === "done") {
|
|
212
459
|
return "success";
|
|
213
460
|
}
|
|
214
|
-
if (status === "
|
|
461
|
+
if (status === "failed") {
|
|
462
|
+
return "error";
|
|
463
|
+
}
|
|
464
|
+
if (status === "in_progress" || status === "blocked") {
|
|
215
465
|
return "warning";
|
|
216
466
|
}
|
|
217
467
|
return "dim";
|
|
@@ -252,6 +502,7 @@ function longTaskDetails(details: Record<string, unknown> | undefined): Coordina
|
|
|
252
502
|
runId: stringValue(details.runId),
|
|
253
503
|
commits: commitSummaries(details.commits),
|
|
254
504
|
remainingTasks: remainingTaskSummaries(details.remainingTasks),
|
|
505
|
+
taskProgress: taskProgressModel(details.taskProgress),
|
|
255
506
|
error: stringValue(details.error),
|
|
256
507
|
};
|
|
257
508
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Task, TaskStatusItem } from "./todo_parser.ts";
|
|
2
|
+
|
|
3
|
+
export const TASK_PROGRESS_STATUS_VALUES = ["pending", "current", "completed", "failed", "blocked"] as const;
|
|
4
|
+
export type TaskProgressStatus = (typeof TASK_PROGRESS_STATUS_VALUES)[number];
|
|
5
|
+
|
|
6
|
+
export const TASK_PROGRESS_POSITION_VALUES = ["past", "current", "future"] as const;
|
|
7
|
+
export type TaskProgressPosition = (typeof TASK_PROGRESS_POSITION_VALUES)[number];
|
|
8
|
+
|
|
9
|
+
export interface TaskProgressAttempt {
|
|
10
|
+
taskId: string;
|
|
11
|
+
attempt?: number;
|
|
12
|
+
reportedStatus?: string;
|
|
13
|
+
done?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TaskProgressTaskStatusItem = TaskStatusItem;
|
|
17
|
+
|
|
18
|
+
export interface TaskProgressTask {
|
|
19
|
+
taskId: string;
|
|
20
|
+
title: string;
|
|
21
|
+
status: TaskProgressStatus;
|
|
22
|
+
position: TaskProgressPosition;
|
|
23
|
+
done: boolean;
|
|
24
|
+
statusItems: TaskProgressTaskStatusItem[];
|
|
25
|
+
attempts: number;
|
|
26
|
+
lastReportedStatus?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TaskProgressSummary {
|
|
30
|
+
totalTasks: number;
|
|
31
|
+
completedTasks: number;
|
|
32
|
+
failedTasks: number;
|
|
33
|
+
blockedTasks: number;
|
|
34
|
+
pendingTasks: number;
|
|
35
|
+
currentTasks: number;
|
|
36
|
+
attemptedTasks: number;
|
|
37
|
+
completionRatio: number;
|
|
38
|
+
completedPercent: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TaskProgressModel {
|
|
42
|
+
tasks: TaskProgressTask[];
|
|
43
|
+
summary: TaskProgressSummary;
|
|
44
|
+
currentTaskId?: string;
|
|
45
|
+
currentIndex?: number;
|
|
46
|
+
currentTask?: TaskProgressTask;
|
|
47
|
+
nextTaskId?: string;
|
|
48
|
+
nextIndex?: number;
|
|
49
|
+
nextTask?: TaskProgressTask;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BuildTaskProgressModelOptions {
|
|
53
|
+
tasks: readonly Task[];
|
|
54
|
+
attempts?: readonly TaskProgressAttempt[];
|
|
55
|
+
currentTaskId?: string;
|
|
56
|
+
currentTaskStatus?: TaskProgressStatus;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildTaskProgressModel(options: BuildTaskProgressModelOptions): TaskProgressModel {
|
|
60
|
+
const attempts = options.attempts ?? [];
|
|
61
|
+
const attemptStats = attemptsByTask(attempts);
|
|
62
|
+
const activeIndex = activeTaskIndex(options.tasks, options.currentTaskId);
|
|
63
|
+
|
|
64
|
+
const progressTasks = options.tasks.map((task, index) => {
|
|
65
|
+
const stats = attemptStats.get(task.taskId);
|
|
66
|
+
const isActive = index === activeIndex;
|
|
67
|
+
const status = taskProgressStatus(task, stats?.lastAttempt, isActive, options.currentTaskStatus);
|
|
68
|
+
|
|
69
|
+
const progressTask: TaskProgressTask = {
|
|
70
|
+
taskId: task.taskId,
|
|
71
|
+
title: task.title,
|
|
72
|
+
status,
|
|
73
|
+
position: taskProgressPosition(index, activeIndex, status),
|
|
74
|
+
done: status === "completed",
|
|
75
|
+
statusItems: task.statusItems.map((item) => ({ ...item })),
|
|
76
|
+
attempts: stats?.attempts ?? 0,
|
|
77
|
+
};
|
|
78
|
+
if (stats?.lastAttempt.reportedStatus) {
|
|
79
|
+
progressTask.lastReportedStatus = stats.lastAttempt.reportedStatus;
|
|
80
|
+
}
|
|
81
|
+
return progressTask;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const currentIndex = progressTasks.findIndex((task) => task.position === "current");
|
|
85
|
+
const nextIndex = progressTasks.findIndex((task) => task.status === "pending");
|
|
86
|
+
const summary = taskProgressSummary(progressTasks, attempts.length);
|
|
87
|
+
|
|
88
|
+
const model: TaskProgressModel = {
|
|
89
|
+
tasks: progressTasks,
|
|
90
|
+
summary,
|
|
91
|
+
};
|
|
92
|
+
if (currentIndex >= 0) {
|
|
93
|
+
model.currentIndex = currentIndex;
|
|
94
|
+
model.currentTask = progressTasks[currentIndex];
|
|
95
|
+
model.currentTaskId = progressTasks[currentIndex].taskId;
|
|
96
|
+
}
|
|
97
|
+
if (nextIndex >= 0) {
|
|
98
|
+
model.nextIndex = nextIndex;
|
|
99
|
+
model.nextTask = progressTasks[nextIndex];
|
|
100
|
+
model.nextTaskId = progressTasks[nextIndex].taskId;
|
|
101
|
+
}
|
|
102
|
+
return model;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface TaskAttemptStats {
|
|
106
|
+
attempts: number;
|
|
107
|
+
lastAttempt: TaskProgressAttempt;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function attemptsByTask(attempts: readonly TaskProgressAttempt[]): Map<string, TaskAttemptStats> {
|
|
111
|
+
const stats = new Map<string, TaskAttemptStats>();
|
|
112
|
+
for (const attempt of attempts) {
|
|
113
|
+
const existing = stats.get(attempt.taskId);
|
|
114
|
+
stats.set(attempt.taskId, {
|
|
115
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
116
|
+
lastAttempt: attempt,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return stats;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function activeTaskIndex(tasks: readonly Task[], currentTaskId: string | undefined): number {
|
|
123
|
+
if (!currentTaskId) {
|
|
124
|
+
return -1;
|
|
125
|
+
}
|
|
126
|
+
return tasks.findIndex((task) => task.taskId === currentTaskId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function taskProgressStatus(
|
|
130
|
+
task: Task,
|
|
131
|
+
lastAttempt: TaskProgressAttempt | undefined,
|
|
132
|
+
isActive: boolean,
|
|
133
|
+
currentTaskStatus: TaskProgressStatus | undefined,
|
|
134
|
+
): TaskProgressStatus {
|
|
135
|
+
if (task.done || lastAttempt?.done || (isActive && currentTaskStatus === "completed")) {
|
|
136
|
+
return "completed";
|
|
137
|
+
}
|
|
138
|
+
if (isActive) {
|
|
139
|
+
if (currentTaskStatus === "failed" || currentTaskStatus === "blocked") {
|
|
140
|
+
return currentTaskStatus;
|
|
141
|
+
}
|
|
142
|
+
return "current";
|
|
143
|
+
}
|
|
144
|
+
if (lastAttempt?.reportedStatus === "blocked") {
|
|
145
|
+
return "blocked";
|
|
146
|
+
}
|
|
147
|
+
if (lastAttempt) {
|
|
148
|
+
return "failed";
|
|
149
|
+
}
|
|
150
|
+
return "pending";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function taskProgressPosition(index: number, activeIndex: number, status: TaskProgressStatus): TaskProgressPosition {
|
|
154
|
+
if (activeIndex >= 0) {
|
|
155
|
+
if (index < activeIndex) {
|
|
156
|
+
return "past";
|
|
157
|
+
}
|
|
158
|
+
if (index === activeIndex) {
|
|
159
|
+
return "current";
|
|
160
|
+
}
|
|
161
|
+
return "future";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return status === "pending" || status === "current" ? "future" : "past";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function taskProgressSummary(tasks: readonly TaskProgressTask[], attemptedTasks: number): TaskProgressSummary {
|
|
168
|
+
const totalTasks = tasks.length;
|
|
169
|
+
const completedTasks = tasks.filter((task) => task.status === "completed").length;
|
|
170
|
+
const failedTasks = tasks.filter((task) => task.status === "failed").length;
|
|
171
|
+
const blockedTasks = tasks.filter((task) => task.status === "blocked").length;
|
|
172
|
+
const pendingTasks = tasks.filter((task) => task.status === "pending").length;
|
|
173
|
+
const currentTasks = tasks.filter((task) => task.status === "current").length;
|
|
174
|
+
const completionRatio = totalTasks === 0 ? 1 : completedTasks / totalTasks;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
totalTasks,
|
|
178
|
+
completedTasks,
|
|
179
|
+
failedTasks,
|
|
180
|
+
blockedTasks,
|
|
181
|
+
pendingTasks,
|
|
182
|
+
currentTasks,
|
|
183
|
+
attemptedTasks,
|
|
184
|
+
completionRatio,
|
|
185
|
+
completedPercent: Math.round(completionRatio * 100),
|
|
186
|
+
};
|
|
187
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { Static } from "typebox";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
+
import type { TaskProgressModel } from "./task_progress.ts";
|
|
4
5
|
import type { SessionOutcome } from "./worker_session.ts";
|
|
5
6
|
|
|
6
7
|
export const PiLongTaskParams = Type.Object(
|
|
7
8
|
{
|
|
8
|
-
inputText: Type.String({
|
|
9
|
-
|
|
9
|
+
inputText: Type.String({
|
|
10
|
+
description: "TODO file content or the user's long-task instructions to process.",
|
|
11
|
+
}),
|
|
12
|
+
commit: Type.Boolean({
|
|
13
|
+
description:
|
|
14
|
+
"Whether Pi Long Task may commit completed worker changes. Use true when the user asks for commits or committing as work progresses; otherwise use false.",
|
|
15
|
+
}),
|
|
10
16
|
},
|
|
11
17
|
{ additionalProperties: false },
|
|
12
18
|
);
|
|
@@ -55,6 +61,7 @@ export interface PiLongTaskResult {
|
|
|
55
61
|
commitError?: string;
|
|
56
62
|
commitSkipped?: string;
|
|
57
63
|
}>;
|
|
64
|
+
taskProgress: TaskProgressModel;
|
|
58
65
|
commit: boolean;
|
|
59
66
|
error?: string;
|
|
60
67
|
}
|