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/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,8 @@ export interface CoordinatorResultForRendering {
15
16
  taskResultPath?: string;
16
17
  commits?: CoordinatorCommitSummary[];
17
18
  remainingTasks?: CoordinatorRemainingTask[];
19
+ taskProgress?: TaskProgressModel;
20
+ workerCostTotal?: number;
18
21
  error?: string;
19
22
  }
20
23
 
@@ -22,7 +25,7 @@ export interface CoordinatorToolRenderDetails extends CoordinatorResultForRender
22
25
  runId?: string;
23
26
  }
24
27
 
25
- type ProgressItemStatus = "empty" | "in_progress" | "done";
28
+ type ProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
26
29
 
27
30
  interface ProgressTaskRenderDetails {
28
31
  taskId: string;
@@ -35,6 +38,72 @@ interface ProgressSubtaskRenderDetails {
35
38
  status: ProgressItemStatus;
36
39
  }
37
40
 
41
+ const SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH = 84;
42
+ const SIDEBAR_MIN_WIDTH = 26;
43
+ const SIDEBAR_MAX_WIDTH = 40;
44
+ const SIDEBAR_GAP = 2;
45
+
46
+ class LongTaskSidebarShell implements Component {
47
+ private readonly mainText: string;
48
+ private readonly taskProgress: TaskProgressModel;
49
+ private readonly workerCostTotal: number | undefined;
50
+ private readonly theme: Theme;
51
+
52
+ constructor(mainText: string, taskProgress: TaskProgressModel, theme: Theme, workerCostTotal?: number) {
53
+ this.mainText = mainText;
54
+ this.taskProgress = taskProgress;
55
+ this.workerCostTotal = workerCostTotal;
56
+ this.theme = theme;
57
+ }
58
+
59
+ render(width: number): string[] {
60
+ if (width < SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH) {
61
+ return this.renderStacked(width);
62
+ }
63
+
64
+ const sidebarWidth = clamp(Math.floor(width * 0.32), SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
65
+ const mainWidth = width - sidebarWidth - SIDEBAR_GAP;
66
+ if (mainWidth < 40) {
67
+ return this.renderStacked(width);
68
+ }
69
+
70
+ const mainLines = renderWrappedLines(this.mainText, mainWidth);
71
+ const sidebarLines = this.renderSidebar(sidebarWidth);
72
+ const height = Math.max(mainLines.length, sidebarLines.length);
73
+ const lines: string[] = [];
74
+ for (let idx = 0; idx < height; idx += 1) {
75
+ const main = padLine(mainLines[idx] ?? "", mainWidth);
76
+ const sidebar = sidebarLines[idx] ?? "";
77
+ lines.push(truncateToWidth(`${main}${" ".repeat(SIDEBAR_GAP)}${sidebar}`, width));
78
+ }
79
+ return lines;
80
+ }
81
+
82
+ invalidate(): void {
83
+ // Rendering is computed from current state on each pass.
84
+ }
85
+
86
+ private renderStacked(width: number): string[] {
87
+ const mainLines = renderWrappedLines(this.mainText, width);
88
+ const sidebarLines = this.renderSidebar(width);
89
+ return [...mainLines, ...sidebarLines].map((line) => truncateToWidth(line, width));
90
+ }
91
+
92
+ private renderSidebar(width: number): string[] {
93
+ if (width < 8) {
94
+ return [];
95
+ }
96
+
97
+ const innerWidth = Math.max(0, width - 2);
98
+ const rows = sidebarRows(this.taskProgress, this.theme, this.workerCostTotal);
99
+ return [
100
+ sidebarBorder("Long Task", width, this.theme),
101
+ ...rows.map((row) => sidebarRow(row, innerWidth, this.theme)),
102
+ this.theme.fg("borderMuted", `└${"─".repeat(innerWidth)}┘`),
103
+ ];
104
+ }
105
+ }
106
+
38
107
  export function formatCoordinatorResultMessage(result: CoordinatorResultForRendering): string {
39
108
  const resultPath = result.resultPath ?? result.taskResultPath ?? "unknown";
40
109
  const remaining = result.remainingTasks ?? [];
@@ -47,6 +116,10 @@ export function formatCoordinatorResultMessage(result: CoordinatorResultForRende
47
116
  `TODO file: ${result.todoPath}`,
48
117
  ];
49
118
 
119
+ if (result.workerCostTotal) {
120
+ lines.push(`Worker spend: ${formatCost(result.workerCostTotal)}`);
121
+ }
122
+
50
123
  const commitLines = commits
51
124
  .filter((commit) => commit.hash || commit.error)
52
125
  .map((commit) => {
@@ -84,10 +157,13 @@ export function renderLongTaskToolResult(
84
157
  result: AgentToolResult<unknown>,
85
158
  options: ToolRenderResultOptions,
86
159
  theme: Theme,
87
- ): Text {
160
+ ): Component {
88
161
  const details = recordOrUndefined(result.details);
89
162
  if (options.isPartial) {
90
- return new Text(renderLongTaskProgress(details, contentText(result), theme), 0, 0);
163
+ const taskProgress = taskProgressModel(details?.taskProgress);
164
+ const workerCostTotal = numberValue(details?.workerCostTotal);
165
+ const main = renderLongTaskProgress(details, contentText(result), theme);
166
+ return taskProgress ? new LongTaskSidebarShell(main, taskProgress, theme, workerCostTotal) : new Text(main, 0, 0);
91
167
  }
92
168
 
93
169
  const finalDetails = longTaskDetails(details);
@@ -95,7 +171,10 @@ export function renderLongTaskToolResult(
95
171
  return new Text(contentText(result), 0, 0);
96
172
  }
97
173
 
98
- return new Text(renderLongTaskSummary(finalDetails, options.expanded, theme), 0, 0);
174
+ const main = renderLongTaskSummary(finalDetails, options.expanded, theme);
175
+ return finalDetails.taskProgress
176
+ ? new LongTaskSidebarShell(main, finalDetails.taskProgress, theme, finalDetails.workerCostTotal)
177
+ : new Text(main, 0, 0);
99
178
  }
100
179
 
101
180
  function renderLongTaskProgress(details: Record<string, unknown> | undefined, fallback: string, theme: Theme): string {
@@ -137,6 +216,7 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
137
216
  details.blockedTasks ? theme.fg("warning", `${details.blockedTasks} blocked`) : undefined,
138
217
  remainingCount ? theme.fg("muted", `${remainingCount} remaining`) : undefined,
139
218
  commitCount ? theme.fg("success", `${commitCount} commit${commitCount === 1 ? "" : "s"}`) : undefined,
219
+ details.workerCostTotal ? theme.fg("muted", `worker ${formatCost(details.workerCostTotal)}`) : undefined,
140
220
  ].filter(Boolean);
141
221
 
142
222
  if (!expanded) {
@@ -173,6 +253,174 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
173
253
  return lines.join("\n");
174
254
  }
175
255
 
256
+ function renderWrappedLines(text: string, width: number): string[] {
257
+ return new Text(text, 0, 0).render(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
258
+ }
259
+
260
+ function padLine(line: string, width: number): string {
261
+ return truncateToWidth(line, width, "…", true);
262
+ }
263
+
264
+ function sidebarRows(taskProgress: TaskProgressModel, theme: Theme, workerCostTotal?: number): string[] {
265
+ const summary = normalizedTaskProgressSummary(taskProgress);
266
+ const rows = [theme.fg("toolTitle", theme.bold("Task sidebar")), theme.fg("dim", "Centered timeline")];
267
+ if (workerCostTotal) {
268
+ rows.push(theme.fg("muted", `Worker spend: ${formatCost(workerCostTotal)}`));
269
+ }
270
+ if (summary.totalTasks === 0) {
271
+ rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
272
+ return rows;
273
+ }
274
+
275
+ rows.push("", progressBarLine(summary.completedTasks, summary.totalTasks, summary.completedPercent, theme));
276
+ rows.push(progressCountsLine(summary, theme));
277
+
278
+ const currentIndex = focusedTaskIndex(taskProgress);
279
+ if (currentIndex >= 0) {
280
+ rows.push(theme.fg("warning", `Focus: TODO ${taskProgress.tasks[currentIndex]?.taskId ?? "?"}`));
281
+ } else {
282
+ rows.push(theme.fg("success", "No active task"));
283
+ }
284
+
285
+ rows.push("", theme.fg("muted", "Timeline"));
286
+ for (const [index, task] of taskProgress.tasks.entries()) {
287
+ if (currentIndex >= 0 && index === currentIndex && index > 0) {
288
+ rows.push(theme.fg("dim", "──── current ────"));
289
+ }
290
+ rows.push(renderSidebarTaskRow(task, theme));
291
+ if (currentIndex >= 0 && index === currentIndex && index < taskProgress.tasks.length - 1) {
292
+ rows.push(theme.fg("dim", "──── future ─────"));
293
+ }
294
+ }
295
+
296
+ return rows;
297
+ }
298
+
299
+ interface NormalizedTaskProgressSummary {
300
+ totalTasks: number;
301
+ completedTasks: number;
302
+ failedTasks: number;
303
+ blockedTasks: number;
304
+ pendingTasks: number;
305
+ currentTasks: number;
306
+ completedPercent: number;
307
+ }
308
+
309
+ function normalizedTaskProgressSummary(taskProgress: TaskProgressModel): NormalizedTaskProgressSummary {
310
+ const totalTasks = taskProgress.summary?.totalTasks ?? taskProgress.tasks.length;
311
+ const completedTasks = taskProgress.summary?.completedTasks ?? countTasksByStatus(taskProgress.tasks, "completed");
312
+ const failedTasks = taskProgress.summary?.failedTasks ?? countTasksByStatus(taskProgress.tasks, "failed");
313
+ const blockedTasks = taskProgress.summary?.blockedTasks ?? countTasksByStatus(taskProgress.tasks, "blocked");
314
+ const pendingTasks = taskProgress.summary?.pendingTasks ?? countTasksByStatus(taskProgress.tasks, "pending");
315
+ const currentTasks = taskProgress.summary?.currentTasks ?? countTasksByStatus(taskProgress.tasks, "current");
316
+ const completedPercent =
317
+ taskProgress.summary?.completedPercent ??
318
+ (totalTasks === 0 ? 100 : Math.round((completedTasks / totalTasks) * 100));
319
+
320
+ return {
321
+ totalTasks,
322
+ completedTasks,
323
+ failedTasks,
324
+ blockedTasks,
325
+ pendingTasks,
326
+ currentTasks,
327
+ completedPercent,
328
+ };
329
+ }
330
+
331
+ function countTasksByStatus(tasks: readonly TaskProgressTask[], status: TaskProgressStatus): number {
332
+ return tasks.filter((task) => task.status === status).length;
333
+ }
334
+
335
+ function progressBarLine(completedTasks: number, totalTasks: number, percent: number, theme: Theme): string {
336
+ const width = 10;
337
+ const filled = clamp(totalTasks === 0 ? width : Math.round((completedTasks / totalTasks) * width), 0, width);
338
+ const empty = Math.max(0, width - filled);
339
+ return `${theme.fg("muted", "Progress")} [${theme.fg("success", "#".repeat(filled))}${theme.fg(
340
+ "dim",
341
+ "-".repeat(empty),
342
+ )}] ${completedTasks}/${totalTasks} ${percent}%`;
343
+ }
344
+
345
+ function progressCountsLine(summary: NormalizedTaskProgressSummary, theme: Theme): string {
346
+ const parts = [
347
+ theme.fg("success", `✓ ${summary.completedTasks}`),
348
+ summary.currentTasks ? theme.fg("warning", `▶ ${summary.currentTasks}`) : undefined,
349
+ summary.pendingTasks ? theme.fg("dim", `○ ${summary.pendingTasks}`) : undefined,
350
+ summary.failedTasks ? theme.fg("error", `✗ ${summary.failedTasks}`) : undefined,
351
+ summary.blockedTasks ? theme.fg("warning", `! ${summary.blockedTasks}`) : undefined,
352
+ ].filter(Boolean);
353
+ return parts.join(" · ");
354
+ }
355
+
356
+ function focusedTaskIndex(taskProgress: TaskProgressModel): number {
357
+ if (typeof taskProgress.currentIndex === "number" && taskProgress.currentIndex >= 0) {
358
+ return taskProgress.currentIndex;
359
+ }
360
+
361
+ const currentIndex = taskProgress.tasks.findIndex((task) => task.status === "current" || task.position === "current");
362
+ if (currentIndex >= 0) {
363
+ return currentIndex;
364
+ }
365
+
366
+ if (typeof taskProgress.nextIndex === "number" && taskProgress.nextIndex >= 0) {
367
+ return taskProgress.nextIndex;
368
+ }
369
+ return taskProgress.tasks.findIndex((task) => task.status === "pending");
370
+ }
371
+
372
+ function renderSidebarTaskRow(task: TaskProgressTask, theme: Theme): string {
373
+ const { icon, color, label } = sidebarTaskStatusDetails(task.status);
374
+ const attempts =
375
+ task.attempts > 0 && task.status !== "completed"
376
+ ? ` · ${task.attempts} attempt${task.attempts === 1 ? "" : "s"}`
377
+ : "";
378
+ const text = `${icon} [${label}] TODO ${task.taskId} — ${task.title}${attempts}`;
379
+ return task.status === "current" ? theme.fg(color, theme.bold(text)) : theme.fg(color, text);
380
+ }
381
+
382
+ function sidebarTaskStatusDetails(status: TaskProgressStatus): {
383
+ icon: string;
384
+ color: "success" | "warning" | "error" | "dim" | "muted";
385
+ label: string;
386
+ } {
387
+ switch (status) {
388
+ case "completed":
389
+ return { icon: "✓", color: "success", label: "completed" };
390
+ case "current":
391
+ return { icon: "▶", color: "warning", label: "current" };
392
+ case "failed":
393
+ return { icon: "✗", color: "error", label: "failed" };
394
+ case "blocked":
395
+ return { icon: "!", color: "warning", label: "blocked" };
396
+ case "pending":
397
+ return { icon: "○", color: "dim", label: "pending" };
398
+ }
399
+ }
400
+
401
+ function sidebarBorder(title: string, width: number, theme: Theme): string {
402
+ const innerWidth = Math.max(0, width - 2);
403
+ const titleText = truncateToWidth(` ${title} `, innerWidth, "");
404
+ const remaining = Math.max(0, innerWidth - visibleWidth(titleText));
405
+ return theme.fg("borderMuted", `┌${titleText}${"─".repeat(remaining)}┐`);
406
+ }
407
+
408
+ function sidebarRow(row: string, innerWidth: number, theme: Theme): string {
409
+ return `${theme.fg("borderMuted", "│")}${truncateToWidth(row, innerWidth, "…", true)}${theme.fg("borderMuted", "│")}`;
410
+ }
411
+
412
+ function taskProgressModel(value: unknown): TaskProgressModel | undefined {
413
+ const record = recordOrUndefined(value);
414
+ if (!record || !Array.isArray(record.tasks)) {
415
+ return undefined;
416
+ }
417
+ return value as TaskProgressModel;
418
+ }
419
+
420
+ function clamp(value: number, min: number, max: number): number {
421
+ return Math.min(max, Math.max(min, value));
422
+ }
423
+
176
424
  function progressTaskDetails(value: unknown): ProgressTaskRenderDetails | undefined {
177
425
  const record = recordOrUndefined(value);
178
426
  const taskId = stringValue(record?.taskId);
@@ -200,18 +448,32 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
200
448
  }
201
449
 
202
450
  function progressItemStatus(value: unknown): ProgressItemStatus | undefined {
203
- return value === "empty" || value === "in_progress" || value === "done" ? value : undefined;
451
+ return value === "empty" || value === "in_progress" || value === "done" || value === "failed" || value === "blocked"
452
+ ? value
453
+ : undefined;
204
454
  }
205
455
 
206
456
  function progressBubble(status: ProgressItemStatus, theme: Theme): string {
207
- return status === "empty" ? theme.fg("dim", "○") : theme.fg(progressTextColor(status), "●");
457
+ switch (status) {
458
+ case "empty":
459
+ return theme.fg("dim", "○");
460
+ case "failed":
461
+ return theme.fg("error", "✗");
462
+ case "blocked":
463
+ return theme.fg("warning", "!");
464
+ default:
465
+ return theme.fg(progressTextColor(status), "●");
466
+ }
208
467
  }
209
468
 
210
- function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" {
469
+ function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" | "error" {
211
470
  if (status === "done") {
212
471
  return "success";
213
472
  }
214
- if (status === "in_progress") {
473
+ if (status === "failed") {
474
+ return "error";
475
+ }
476
+ if (status === "in_progress" || status === "blocked") {
215
477
  return "warning";
216
478
  }
217
479
  return "dim";
@@ -252,6 +514,8 @@ function longTaskDetails(details: Record<string, unknown> | undefined): Coordina
252
514
  runId: stringValue(details.runId),
253
515
  commits: commitSummaries(details.commits),
254
516
  remainingTasks: remainingTaskSummaries(details.remainingTasks),
517
+ taskProgress: taskProgressModel(details.taskProgress),
518
+ workerCostTotal: nonNegativeNumberValue(details.workerCostTotal),
255
519
  error: stringValue(details.error),
256
520
  };
257
521
  }
@@ -341,6 +605,20 @@ function numberValue(value: unknown): number | undefined {
341
605
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
342
606
  }
343
607
 
608
+ function nonNegativeNumberValue(value: unknown): number | undefined {
609
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
610
+ }
611
+
612
+ function formatCost(value: number): string {
613
+ if (value === 0) {
614
+ return "$0";
615
+ }
616
+ if (value < 0.01) {
617
+ return `$${value.toFixed(4)}`;
618
+ }
619
+ return `$${value.toFixed(2)}`;
620
+ }
621
+
344
622
  function recordOrUndefined(value: unknown): Record<string, unknown> | undefined {
345
623
  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
346
624
  }
@@ -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,6 +1,7 @@
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(
@@ -60,6 +61,8 @@ export interface PiLongTaskResult {
60
61
  commitError?: string;
61
62
  commitSkipped?: string;
62
63
  }>;
64
+ taskProgress: TaskProgressModel;
65
+ workerCostTotal: number;
63
66
  commit: boolean;
64
67
  error?: string;
65
68
  }