pi-long-task 0.6.0 → 0.7.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.
@@ -30,6 +30,7 @@ import {
30
30
  type NetworkRecoveryConfig,
31
31
  type NetworkRecoveryConfigInput,
32
32
  } from "./network_recovery_config.ts";
33
+ import { validatePlannerGracefulShutdownMs, validatePlannerTimeoutMs } from "./planner_config.ts";
33
34
  import { runGoalReviewSession, type GoalReviewResult, type GoalReviewerRunner } from "./goal_review.ts";
34
35
  import {
35
36
  runGoalTodoExecutionLongTask,
@@ -109,6 +110,8 @@ export interface RunGoalLoopOptions extends GoalLoopLimitInput {
109
110
  thinkingLevel?: string;
110
111
  maxBashTimeoutMs?: number;
111
112
  maxAttemptsPerTask?: number;
113
+ todoTimeoutMs?: number;
114
+ todoGracefulShutdownMs?: number;
112
115
  networkRecovery?: NetworkRecoveryConfigInput;
113
116
  commit?: boolean;
114
117
  now?: () => Date;
@@ -140,6 +143,8 @@ export class GoalLoopOrchestratorError extends Error {
140
143
  export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoopRunResult> {
141
144
  const now = options.now ?? (() => new Date());
142
145
  const networkRecovery = resolveNetworkRecoveryConfig(options.networkRecovery);
146
+ const todoTimeoutMs = validatePlannerTimeoutMs(options.todoTimeoutMs);
147
+ const todoGracefulShutdownMs = validatePlannerGracefulShutdownMs(options.todoGracefulShutdownMs);
143
148
  let state =
144
149
  options.initialState ??
145
150
  createGoalLoopState({
@@ -282,6 +287,8 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
282
287
  modelName: options.modelName,
283
288
  thinkingLevel: options.thinkingLevel,
284
289
  maxBashTimeoutMs: options.maxBashTimeoutMs,
290
+ todoTimeoutMs,
291
+ todoGracefulShutdownMs,
285
292
  networkRecovery,
286
293
  now,
287
294
  goalSpecification,
@@ -313,6 +320,8 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
313
320
  modelName: options.modelName,
314
321
  thinkingLevel: options.thinkingLevel,
315
322
  maxBashTimeoutMs: options.maxBashTimeoutMs,
323
+ todoTimeoutMs,
324
+ todoGracefulShutdownMs,
316
325
  networkRecovery,
317
326
  now,
318
327
  goalSpecification,
@@ -346,6 +355,8 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
346
355
  thinkingLevel: options.thinkingLevel,
347
356
  maxBashTimeoutMs: options.maxBashTimeoutMs,
348
357
  maxAttemptsPerTask: options.maxAttemptsPerTask,
358
+ todoTimeoutMs,
359
+ todoGracefulShutdownMs,
349
360
  networkRecovery,
350
361
  commit: options.commit,
351
362
  now,
@@ -33,6 +33,8 @@ export interface GoalTodoExecutionOptions {
33
33
  thinkingLevel?: string;
34
34
  maxBashTimeoutMs?: number;
35
35
  maxAttemptsPerTask?: number;
36
+ todoTimeoutMs?: number;
37
+ todoGracefulShutdownMs?: number;
36
38
  networkRecovery?: Readonly<NetworkRecoveryConfig>;
37
39
  commit?: boolean;
38
40
  now?: () => Date;
@@ -154,6 +156,8 @@ export async function runGoalTodoExecutionLongTask(
154
156
  taskTimeoutMs: childTimeoutMs,
155
157
  maxBashTimeoutMs: options.maxBashTimeoutMs,
156
158
  maxAttemptsPerTask: options.maxAttemptsPerTask,
159
+ todoTimeoutMs: options.todoTimeoutMs,
160
+ todoGracefulShutdownMs: options.todoGracefulShutdownMs,
157
161
  networkRecovery: options.networkRecovery,
158
162
  onProgress: (update) => {
159
163
  progressEvents.push(update);
@@ -19,6 +19,7 @@ import { parseTasks } from "./todo_parser.ts";
19
19
  import {
20
20
  applyGoalInstructionsToTodoMarkdown,
21
21
  extractAndValidateTodoMarkdown,
22
+ todoPlanningOnlyPromptBlock,
22
23
  validateTodoMarkdown,
23
24
  } from "./todo_generator.ts";
24
25
 
@@ -38,6 +39,8 @@ export interface GoalTodoGenerationOptions {
38
39
  modelName?: string;
39
40
  thinkingLevel?: string;
40
41
  maxBashTimeoutMs?: number;
42
+ todoTimeoutMs?: number;
43
+ todoGracefulShutdownMs?: number;
41
44
  networkRecovery?: Readonly<NetworkRecoveryConfig>;
42
45
  now?: () => Date;
43
46
  additionalContext?: string;
@@ -122,6 +125,8 @@ export async function runGoalTodoGenerationLongTask(
122
125
  taskThinking: options.thinkingLevel,
123
126
  taskTimeoutMs: childTimeoutMs,
124
127
  maxBashTimeoutMs: options.maxBashTimeoutMs,
128
+ todoTimeoutMs: options.todoTimeoutMs,
129
+ todoGracefulShutdownMs: options.todoGracefulShutdownMs,
125
130
  networkRecovery: options.networkRecovery,
126
131
  onNetworkRecovery: captureOutage,
127
132
  };
@@ -225,16 +230,17 @@ export function buildGoalTodoGenerationTaskPayload(options: {
225
230
 
226
231
  - Long task goal: ${oneLine(options.state.goal)}
227
232
  - This is goal-loop TODO generation iteration ${options.iteration} for goal run ${options.state.goalRunId}.
228
- - Only generate TODO markdown for future workers; do not implement, edit, test, refactor, or otherwise perform the goal work in this generation run.
229
233
  - Write the generated Pi Long Task-compatible TODO markdown to \`${options.outputPath}\`.
230
234
  - Do not wrap the generated file in a code fence and do not include commentary outside the TODO markdown in that file.
231
235
  - Keep generated tasks focused, independently assignable, and safe for separate worker sessions.
232
236
  ${iterationPolicy}
233
237
  ${
234
238
  options.goalSpecification
235
- ? "- A persisted goal specification is available; derive implementation TODOs from that specification rather than only the original vague goal.\n- Ensure generated tasks explicitly cover relevant requirement IDs, milestones, acceptance criteria, verification gates, constraints, and definition-of-done items from the specification.\n- Include spec IDs (for example REQ-*, MS-*, AC-*, VG-*) in generated task goals/status/verification/done-when guidance wherever applicable.\n"
239
+ ? "- A persisted goal specification is available; derive implementation TODOs from it rather than only the original vague goal.\n- Preserve its requirement IDs, milestones, acceptance criteria, verification gates, constraints, and definition of done in the relevant tasks. Reference each applicable spec ID (for example REQ-*, MS-*, AC-*, VG-*) where it is most useful instead of duplicating it in every task field.\n"
236
240
  : ""
237
241
  }
242
+ ${todoPlanningOnlyPromptBlock()}
243
+
238
244
  ## Progress
239
245
 
240
246
  - [ ] TODO 1 — Generate Pi Long Task TODO markdown
@@ -246,16 +252,12 @@ ${
246
252
  **Goal:** Convert the high-level goal into a valid Pi Long Task TODO plan for the next implementation long task.
247
253
 
248
254
  **Status:**
249
- - [ ] Analyze the high-level goal and any iteration context.
250
- - [ ] Create TODO markdown that starts with \`# Pi Long Task TODO\`.
251
- - [ ] Include a \`## Progress\` section with one unchecked \`- [ ] TODO N Title\` line per generated task.
252
- - [ ] Include a \`---\` separator before generated task sections.
253
- - [ ] Include sequential \`## TODO N — Title\` sections with \`**Goal:**\`, \`**Status:**\`, \`**Verify:**\`, and \`**Done when:**\` guidance.
254
- - [ ] Write only the generated TODO markdown to \`${options.outputPath}\`.
255
+ - [ ] Derive focused future-worker tasks from the goal, constraints, and iteration context without performing that work.
256
+ - [ ] Render the required Progress list and sequential task sections with Goal, Status, Verify, and Done when fields.
257
+ - [ ] Write only the concise generated TODO markdown to \`${options.outputPath}\`.
255
258
 
256
259
  **Verify:**
257
- - Confirm the file at \`${options.outputPath}\` exists.
258
- - Confirm it starts with \`# Pi Long Task TODO\`, has a \`## Progress\` section, a \`---\` separator, sequential TODO sections, unchecked status checkboxes, and concrete verification instructions.
260
+ - Confirm \`${options.outputPath}\` contains valid TODO markdown with preserved constraints, compact task sections, unchecked statuses, and concrete checks.
259
261
 
260
262
  **Done when:**
261
263
  - \`${options.outputPath}\` contains valid Pi Long Task-compatible TODO markdown for achieving the high-level goal.
package/src/index.ts CHANGED
@@ -80,6 +80,8 @@ function toolDetails(result: CoordinatorResult) {
80
80
  remainingTasks: result.remainingTasks,
81
81
  taskProgress: result.taskProgress,
82
82
  workerCostTotal: result.workerCostTotal,
83
+ plannerBudget: result.plannerBudget,
84
+ capabilityWarnings: result.capabilityWarnings,
83
85
  summary: result.summary,
84
86
  goal: result.goal,
85
87
  error: result.error,
@@ -349,7 +351,15 @@ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme:
349
351
  rows.push(renderSidebarStateLine(update, theme));
350
352
 
351
353
  if (!progress || progress.tasks.length === 0) {
352
- rows.push("", sidebarHeading("Context", theme), theme.fg("muted", "Waiting for TODO plan"));
354
+ const planningStatus =
355
+ update.phase === "planning" ? normalizeActiveStatus(update.activeStatus ?? update.message) : undefined;
356
+ rows.push(
357
+ "",
358
+ sidebarHeading(planningStatus ? "Planning timing" : "Context", theme),
359
+ ...(planningStatus
360
+ ? wrapPlainText(planningStatus, width, 8).map((line) => theme.fg("accent", line))
361
+ : [theme.fg("muted", "Waiting for TODO plan")]),
362
+ );
353
363
  if (update.workerCostTotal > 0) {
354
364
  rows.push(theme.fg("muted", `${formatCost(update.workerCostTotal)} spent`));
355
365
  }
@@ -566,6 +576,8 @@ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
566
576
  }
567
577
 
568
578
  switch (update.phase) {
579
+ case "capability_warning":
580
+ return { icon: "!", label: "Capability warning", color: "warning" };
569
581
  case "planning":
570
582
  return { icon: "+", label: "Planning", color: "warning" };
571
583
  case "planned":
@@ -91,9 +91,9 @@ export function formatNetworkRecoveryStatus(event: NetworkRecoveryEvent): string
91
91
 
92
92
  function formatRecoveryDuration(milliseconds: number): string {
93
93
  const safeMs = Math.max(0, Math.round(milliseconds));
94
- if (safeMs < 1_000) return `${safeMs}ms`;
94
+ if (safeMs > 0 && safeMs < 1_000) return "less than 1 second";
95
95
  const seconds = safeMs / 1_000;
96
- return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)}s`;
96
+ return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)} seconds`;
97
97
  }
98
98
 
99
99
  export class NetworkOutageExpiredError extends Error {
@@ -0,0 +1,214 @@
1
+ export const MAX_PLANNER_DURATION_MS = 2_147_483_647;
2
+
3
+ /** Thinking levels accepted by the supported Pi SDK, in increasing reasoning-budget order. */
4
+ export const SUPPORTED_PLANNER_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
5
+ export type PlannerThinkingLevel = (typeof SUPPORTED_PLANNER_THINKING_LEVELS)[number];
6
+ /**
7
+ * Planner-only quality/latency balance. `high` retains enough reasoning budget
8
+ * for dependency-aware complex plans without imposing `xhigh` latency on every
9
+ * ordinary request. Explicit caller values remain authoritative.
10
+ */
11
+ export const DEFAULT_PLANNER_THINKING_LEVEL: PlannerThinkingLevel = "high";
12
+
13
+ /** Normal planning budget used for requests that do not contain a scale signal. */
14
+ export const DEFAULT_PLANNER_TIMEOUT_MS = 300_000;
15
+ /** Adaptive planning never reduces the normal five-minute budget. */
16
+ export const MIN_ADAPTIVE_PLANNER_TIMEOUT_MS = DEFAULT_PLANNER_TIMEOUT_MS;
17
+ /** Adaptive planning is capped at fifteen minutes, even for very large requests. */
18
+ export const MAX_ADAPTIVE_PLANNER_TIMEOUT_MS = 900_000;
19
+ /** The normal budget includes up to four requested deliverables. */
20
+ export const PLANNER_ITEMS_INCLUDED_IN_BASE_BUDGET = 4;
21
+ /** Every additional detected deliverable adds thirty seconds until the cap. */
22
+ export const PLANNER_TIMEOUT_PER_ADDITIONAL_ITEM_MS = 30_000;
23
+
24
+ export type PlannerComplexitySignalKind =
25
+ | "explicit_item_count"
26
+ | "enumerated_deliverables"
27
+ | "separately_planned_tasks";
28
+
29
+ export interface PlannerComplexitySignal {
30
+ kind: PlannerComplexitySignalKind;
31
+ itemCount: number;
32
+ }
33
+
34
+ export type PlannerBudgetSource = "explicit" | "default" | "adaptive";
35
+
36
+ /**
37
+ * Complete, machine-readable record of how a planner deadline was selected.
38
+ * `trigger` is present only when a deterministic complexity signal actually
39
+ * extended the normal budget.
40
+ */
41
+ export interface PlannerBudget {
42
+ timeoutMs: number;
43
+ baseTimeoutMs: number;
44
+ minimumTimeoutMs: number;
45
+ maximumTimeoutMs: number;
46
+ extensionApplied: boolean;
47
+ extensionMs: number;
48
+ source: PlannerBudgetSource;
49
+ signals: readonly PlannerComplexitySignal[];
50
+ trigger?: PlannerComplexitySignal;
51
+ }
52
+
53
+ export interface ResolvePlannerBudgetOptions {
54
+ inputText: string;
55
+ /** Structured or natural-language timeout configuration. It always wins exactly. */
56
+ explicitTimeoutMs?: number;
57
+ defaultTimeoutMs?: number;
58
+ }
59
+
60
+ const EXPLICIT_ITEM_COUNT_RE =
61
+ /\b(\d{1,6})\s+(?:(?:separate(?:ly)?|individual(?:ly)?|distinct|independent(?:ly)?)\s+(?:planned\s+)?)?(?:(?:user|job|work)\s+)?(?:stories|tasks|todos?|deliverables?|items?|work\s+items?|features?|components?|pages?|endpoints?|tests?|scenarios?|requirements?)\b/gi;
62
+ const ENUMERATED_DELIVERABLE_RE = /^\s*(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d{1,6}[.)]\s+)\S.*$/gm;
63
+ const SEPARATE_PLANNING_RE =
64
+ /\b(?:separately|individually|independently)\s+(?:plan(?:ned)?|scope(?:d)?|specif(?:y|ied)|assign(?:ed)?)\b|\b(?:plan|scope|specify)\s+(?:each|every)\b|\b(?:separate|individual|independent)\s+(?:plans?|tasks?|todos?|work\s+items?)\b/i;
65
+
66
+ /**
67
+ * Detects only reproducible textual scale signals:
68
+ *
69
+ * 1. an integer directly attached to a deliverable noun (for example,
70
+ * "24 stories"),
71
+ * 2. line-start bullet or numbered deliverables, and
72
+ * 3. explicit language requiring those items to be planned separately.
73
+ *
74
+ * The detector intentionally does not estimate semantic difficulty or ask the
75
+ * model to grade complexity. Signals are returned in stable priority order.
76
+ */
77
+ export function detectPlannerComplexitySignals(inputText: string): PlannerComplexitySignal[] {
78
+ const normalized = inputText.replace(/\r\n?/g, "\n");
79
+ const explicitItemCount = maximumMatchedInteger(normalized, EXPLICIT_ITEM_COUNT_RE);
80
+ const enumeratedItemCount = [...normalized.matchAll(ENUMERATED_DELIVERABLE_RE)].length;
81
+ const signals: PlannerComplexitySignal[] = [];
82
+
83
+ if (SEPARATE_PLANNING_RE.test(normalized)) {
84
+ const separatelyPlannedCount = Math.max(explicitItemCount ?? 0, enumeratedItemCount);
85
+ if (separatelyPlannedCount > 0) {
86
+ signals.push({ kind: "separately_planned_tasks", itemCount: separatelyPlannedCount });
87
+ }
88
+ }
89
+ if (explicitItemCount !== undefined) {
90
+ signals.push({ kind: "explicit_item_count", itemCount: explicitItemCount });
91
+ }
92
+ if (enumeratedItemCount > 0) {
93
+ signals.push({ kind: "enumerated_deliverables", itemCount: enumeratedItemCount });
94
+ }
95
+
96
+ return signals;
97
+ }
98
+
99
+ /**
100
+ * Selects a deterministic planner deadline. The normal five-minute budget
101
+ * covers four items; each additional item adds thirty seconds, capped at
102
+ * fifteen minutes. Explicit timeout configuration bypasses both detection and
103
+ * adaptive bounds and is returned unchanged after public duration validation.
104
+ */
105
+ export function resolvePlannerBudget(options: ResolvePlannerBudgetOptions): PlannerBudget {
106
+ const baseTimeoutMs = resolvePlannerTimeoutMs(options.defaultTimeoutMs, DEFAULT_PLANNER_TIMEOUT_MS);
107
+
108
+ if (options.explicitTimeoutMs !== undefined) {
109
+ const timeoutMs = resolvePlannerTimeoutMs(options.explicitTimeoutMs, baseTimeoutMs);
110
+ return {
111
+ timeoutMs,
112
+ baseTimeoutMs,
113
+ minimumTimeoutMs: MIN_ADAPTIVE_PLANNER_TIMEOUT_MS,
114
+ maximumTimeoutMs: MAX_ADAPTIVE_PLANNER_TIMEOUT_MS,
115
+ extensionApplied: false,
116
+ extensionMs: 0,
117
+ source: "explicit",
118
+ signals: [],
119
+ };
120
+ }
121
+
122
+ const signals = detectPlannerComplexitySignals(options.inputText);
123
+ const trigger = strongestComplexitySignal(signals);
124
+ const additionalItems = Math.max(0, (trigger?.itemCount ?? 0) - PLANNER_ITEMS_INCLUDED_IN_BASE_BUDGET);
125
+ const requestedTimeoutMs = baseTimeoutMs + additionalItems * PLANNER_TIMEOUT_PER_ADDITIONAL_ITEM_MS;
126
+ const timeoutMs = Math.min(
127
+ MAX_ADAPTIVE_PLANNER_TIMEOUT_MS,
128
+ Math.max(MIN_ADAPTIVE_PLANNER_TIMEOUT_MS, requestedTimeoutMs),
129
+ );
130
+ const extensionMs = Math.max(0, timeoutMs - baseTimeoutMs);
131
+ const extensionApplied = extensionMs > 0;
132
+
133
+ return {
134
+ timeoutMs,
135
+ baseTimeoutMs,
136
+ minimumTimeoutMs: MIN_ADAPTIVE_PLANNER_TIMEOUT_MS,
137
+ maximumTimeoutMs: MAX_ADAPTIVE_PLANNER_TIMEOUT_MS,
138
+ extensionApplied,
139
+ extensionMs,
140
+ source: extensionApplied ? "adaptive" : "default",
141
+ signals,
142
+ ...(extensionApplied && trigger ? { trigger } : {}),
143
+ };
144
+ }
145
+
146
+ export class PlannerDurationConfigError extends Error {
147
+ constructor(message: string) {
148
+ super(message);
149
+ this.name = "PlannerDurationConfigError";
150
+ }
151
+ }
152
+
153
+ export function resolvePlannerTimeoutMs(value: number | undefined, fallback: number): number {
154
+ return resolvePlannerDurationMs(value, fallback, "TODO planner timeout", false);
155
+ }
156
+
157
+ export function resolvePlannerGracefulShutdownMs(value: number | undefined, fallback: number): number {
158
+ return resolvePlannerDurationMs(value, fallback, "TODO planner graceful-shutdown duration", true);
159
+ }
160
+
161
+ export function validatePlannerTimeoutMs(value: number | undefined): number | undefined {
162
+ return value === undefined ? undefined : resolvePlannerTimeoutMs(value, value);
163
+ }
164
+
165
+ export function validatePlannerGracefulShutdownMs(value: number | undefined): number | undefined {
166
+ return value === undefined ? undefined : resolvePlannerGracefulShutdownMs(value, value);
167
+ }
168
+
169
+ function maximumMatchedInteger(value: string, expression: RegExp): number | undefined {
170
+ expression.lastIndex = 0;
171
+ let maximum: number | undefined;
172
+ for (const match of value.matchAll(expression)) {
173
+ const count = Number.parseInt(match[1], 10);
174
+ if (Number.isSafeInteger(count) && count > 0) {
175
+ maximum = Math.max(maximum ?? 0, count);
176
+ }
177
+ }
178
+ return maximum;
179
+ }
180
+
181
+ function strongestComplexitySignal(signals: readonly PlannerComplexitySignal[]): PlannerComplexitySignal | undefined {
182
+ // Stable input order is also the tie-break priority: separate planning,
183
+ // explicit counts, then plain enumeration.
184
+ return signals.reduce<PlannerComplexitySignal | undefined>(
185
+ (strongest, signal) => (!strongest || signal.itemCount > strongest.itemCount ? signal : strongest),
186
+ undefined,
187
+ );
188
+ }
189
+
190
+ function resolvePlannerDurationMs(
191
+ value: number | undefined,
192
+ fallback: number,
193
+ label: string,
194
+ allowZero: boolean,
195
+ ): number {
196
+ if (value === undefined) {
197
+ return fallback;
198
+ }
199
+
200
+ const minimumDescription = allowZero ? "a non-negative" : "a positive";
201
+ if (
202
+ typeof value !== "number" ||
203
+ !Number.isSafeInteger(value) ||
204
+ (!allowZero && value <= 0) ||
205
+ (allowZero && value < 0) ||
206
+ value > MAX_PLANNER_DURATION_MS
207
+ ) {
208
+ throw new PlannerDurationConfigError(
209
+ `${label} must be ${minimumDescription} whole-millisecond duration no greater than about 24.9 days (${MAX_PLANNER_DURATION_MS} milliseconds); received ${String(value)}.`,
210
+ );
211
+ }
212
+
213
+ return value;
214
+ }
@@ -0,0 +1,156 @@
1
+ import type { PlannerBudget, PlannerComplexitySignal } from "./planner_config.ts";
2
+
3
+ export type PlannerProgressState = "started" | "active" | "grace";
4
+
5
+ /**
6
+ * One user-facing planner timing update. Millisecond fields remain available to
7
+ * integrations while `message` consistently uses friendly duration wording.
8
+ */
9
+ export interface PlannerProgressEvent {
10
+ state: PlannerProgressState;
11
+ message: string;
12
+ budgetMs: number;
13
+ gracePeriodMs: number;
14
+ elapsedMs: number;
15
+ remainingMs: number;
16
+ graceRemainingMs?: number;
17
+ budget: Readonly<PlannerBudget>;
18
+ }
19
+
20
+ export type PlannerProgressHandler = (event: Readonly<PlannerProgressEvent>) => void;
21
+
22
+ /** Formats a duration for people rather than exposing raw millisecond counts. */
23
+ export function formatFriendlyDuration(durationMs: number): string {
24
+ const milliseconds = Math.max(0, Number.isFinite(durationMs) ? durationMs : 0);
25
+ if (milliseconds > 0 && milliseconds < 1_000) {
26
+ return "less than 1 second";
27
+ }
28
+
29
+ const totalSeconds = Math.round(milliseconds / 1_000);
30
+ if (totalSeconds < 60) {
31
+ return plural(totalSeconds, "second");
32
+ }
33
+
34
+ const totalMinutes = Math.floor(totalSeconds / 60);
35
+ const seconds = totalSeconds % 60;
36
+ if (totalMinutes < 60) {
37
+ return joinDurationParts(plural(totalMinutes, "minute"), seconds > 0 ? plural(seconds, "second") : undefined);
38
+ }
39
+
40
+ const totalHours = Math.floor(totalMinutes / 60);
41
+ const minutes = totalMinutes % 60;
42
+ if (totalHours < 24) {
43
+ return joinDurationParts(plural(totalHours, "hour"), minutes > 0 ? plural(minutes, "minute") : undefined);
44
+ }
45
+
46
+ const days = Math.floor(totalHours / 24);
47
+ const hours = totalHours % 24;
48
+ return joinDurationParts(plural(days, "day"), hours > 0 ? plural(hours, "hour") : undefined);
49
+ }
50
+
51
+ export function createPlannerStartedProgress(
52
+ budget: Readonly<PlannerBudget>,
53
+ gracePeriodMs: number,
54
+ ): PlannerProgressEvent {
55
+ const budgetText = formatFriendlyDuration(budget.timeoutMs);
56
+ const sourceText = budget.source === "explicit" ? " (explicitly configured)" : "";
57
+ const adaptiveText = adaptiveExtensionExplanation(budget);
58
+ const graceText =
59
+ gracePeriodMs > 0
60
+ ? ` A ${formatFriendlyDurationModifier(gracePeriodMs)} graceful-shutdown period is available afterward.`
61
+ : " No graceful-shutdown period is configured.";
62
+
63
+ return {
64
+ state: "started",
65
+ message: `Creating TODO plan. Effective planning budget: ${budgetText}${sourceText}.${adaptiveText}${graceText}`,
66
+ budgetMs: budget.timeoutMs,
67
+ gracePeriodMs,
68
+ elapsedMs: 0,
69
+ remainingMs: budget.timeoutMs,
70
+ budget,
71
+ };
72
+ }
73
+
74
+ export function createPlannerActiveProgress(
75
+ budget: Readonly<PlannerBudget>,
76
+ gracePeriodMs: number,
77
+ elapsedMs: number,
78
+ ): PlannerProgressEvent {
79
+ const elapsed = clamp(elapsedMs, 0, budget.timeoutMs);
80
+ const remaining = Math.max(0, budget.timeoutMs - elapsed);
81
+ return {
82
+ state: "active",
83
+ message: `Still planning: ${formatFriendlyDuration(elapsed)} elapsed; about ${formatFriendlyDuration(remaining)} remaining in the ${formatFriendlyDuration(budget.timeoutMs)} budget.`,
84
+ budgetMs: budget.timeoutMs,
85
+ gracePeriodMs,
86
+ elapsedMs: elapsed,
87
+ remainingMs: remaining,
88
+ budget,
89
+ };
90
+ }
91
+
92
+ export function createPlannerGraceProgress(
93
+ budget: Readonly<PlannerBudget>,
94
+ gracePeriodMs: number,
95
+ ): PlannerProgressEvent {
96
+ const grace = Math.max(0, gracePeriodMs);
97
+ return {
98
+ state: "grace",
99
+ message: `Planning budget reached after ${formatFriendlyDuration(budget.timeoutMs)}; entering a ${formatFriendlyDurationModifier(grace)} graceful-shutdown period to finish a valid plan.`,
100
+ budgetMs: budget.timeoutMs,
101
+ gracePeriodMs: grace,
102
+ elapsedMs: budget.timeoutMs,
103
+ remainingMs: 0,
104
+ graceRemainingMs: grace,
105
+ budget,
106
+ };
107
+ }
108
+
109
+ /** Three bounded checkpoints provide useful timing without per-second noise. */
110
+ export function plannerProgressCheckpoints(budgetMs: number): number[] {
111
+ const budget = Math.max(0, Math.floor(budgetMs));
112
+ if (budget <= 1) {
113
+ return [];
114
+ }
115
+ return [...new Set([0.25, 0.5, 0.75].map((fraction) => Math.max(1, Math.floor(budget * fraction))))].filter(
116
+ (checkpoint) => checkpoint < budget,
117
+ );
118
+ }
119
+
120
+ function formatFriendlyDurationModifier(durationMs: number): string {
121
+ return formatFriendlyDuration(durationMs).replace(
122
+ /(\d[\d,]*) (seconds?|minutes?|hours?|days?)/g,
123
+ (_match, value: string, unit: string) => `${value}-${unit.replace(/s$/, "")}`,
124
+ );
125
+ }
126
+
127
+ function adaptiveExtensionExplanation(budget: Readonly<PlannerBudget>): string {
128
+ if (!budget.extensionApplied || budget.extensionMs <= 0 || !budget.trigger) {
129
+ return "";
130
+ }
131
+ return ` Adaptive extension: ${formatFriendlyDuration(budget.extensionMs)} because the request includes ${complexitySignalText(budget.trigger)}.`;
132
+ }
133
+
134
+ function complexitySignalText(signal: Readonly<PlannerComplexitySignal>): string {
135
+ switch (signal.kind) {
136
+ case "separately_planned_tasks":
137
+ return `${signal.itemCount} separately planned ${signal.itemCount === 1 ? "task" : "tasks"}`;
138
+ case "explicit_item_count":
139
+ return `an explicit count of ${signal.itemCount} ${signal.itemCount === 1 ? "deliverable" : "deliverables"}`;
140
+ case "enumerated_deliverables":
141
+ return `${signal.itemCount} enumerated ${signal.itemCount === 1 ? "deliverable" : "deliverables"}`;
142
+ }
143
+ }
144
+
145
+ function plural(value: number, unit: string): string {
146
+ return `${value.toLocaleString("en-US")} ${unit}${value === 1 ? "" : "s"}`;
147
+ }
148
+
149
+ function joinDurationParts(first: string, second: string | undefined): string {
150
+ return second ? `${first} ${second}` : first;
151
+ }
152
+
153
+ function clamp(value: number, minimum: number, maximum: number): number {
154
+ const finite = Number.isFinite(value) ? value : minimum;
155
+ return Math.min(maximum, Math.max(minimum, Math.round(finite)));
156
+ }
package/src/render.ts CHANGED
@@ -4,6 +4,7 @@ import { Text, type Component } from "@earendil-works/pi-tui";
4
4
  import type { GoalLoopState, GoalLoopStatus } from "./goal_loop.ts";
5
5
  import type { TaskProgressModel } from "./task_progress.ts";
6
6
  import type { CoordinatorCommitSummary, CoordinatorRemainingTask, CoordinatorStatus } from "./types.ts";
7
+ import type { WorkerCapabilityWarning } from "./worker_capabilities.ts";
7
8
 
8
9
  export interface CoordinatorResultForRendering {
9
10
  status: CoordinatorStatus;
@@ -19,6 +20,7 @@ export interface CoordinatorResultForRendering {
19
20
  remainingTasks?: CoordinatorRemainingTask[];
20
21
  taskProgress?: TaskProgressModel;
21
22
  workerCostTotal?: number;
23
+ capabilityWarnings?: readonly WorkerCapabilityWarning[];
22
24
  goal?: string;
23
25
  error?: string;
24
26
  }
@@ -56,6 +58,10 @@ export function formatCoordinatorResultMessage(result: CoordinatorResultForRende
56
58
  lines.push(`Worker spend: ${formatCost(result.workerCostTotal)}`);
57
59
  }
58
60
 
61
+ if (result.capabilityWarnings?.length) {
62
+ lines.push("Worker capability warnings:", ...result.capabilityWarnings.map((warning) => `- ${warning.message}`));
63
+ }
64
+
59
65
  const commitLines = commits
60
66
  .filter((commit) => commit.hash || commit.error)
61
67
  .map((commit) => {
@@ -385,6 +391,9 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
385
391
  }
386
392
 
387
393
  const lines = [summary.join(" — "), theme.fg("muted", details.summary)];
394
+ for (const warning of details.capabilityWarnings ?? []) {
395
+ lines.push(theme.fg("warning", `Warning: ${warning.message}`));
396
+ }
388
397
  lines.push(theme.fg("dim", `Result: ${details.resultPath ?? details.taskResultPath ?? "unknown"}`));
389
398
  lines.push(theme.fg("dim", `TODO: ${details.todoPath}`));
390
399
 
@@ -450,6 +459,8 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
450
459
 
451
460
  function progressPhaseLabel(phase: string): string {
452
461
  switch (phase) {
462
+ case "capability_warning":
463
+ return "Warning";
453
464
  case "planning":
454
465
  case "planned":
455
466
  return "Thought";
@@ -605,10 +616,35 @@ function longTaskDetails(details: Record<string, unknown> | undefined): Coordina
605
616
  remainingTasks: remainingTaskSummaries(details.remainingTasks),
606
617
  taskProgress: taskProgressModel(details.taskProgress),
607
618
  workerCostTotal: nonNegativeNumberValue(details.workerCostTotal),
619
+ capabilityWarnings: capabilityWarningDetails(details.capabilityWarnings),
608
620
  error: stringValue(details.error),
609
621
  };
610
622
  }
611
623
 
624
+ function capabilityWarningDetails(value: unknown): WorkerCapabilityWarning[] {
625
+ if (!Array.isArray(value)) {
626
+ return [];
627
+ }
628
+ return value.flatMap((item) => {
629
+ const record = recordOrUndefined(item);
630
+ const code = stringValue(record?.code);
631
+ const message = stringValue(record?.message);
632
+ const planningConstraint = stringValue(record?.planningConstraint);
633
+ if (code !== "unavailable_browser_capability" || !message || !planningConstraint) {
634
+ return [];
635
+ }
636
+ return [
637
+ {
638
+ code,
639
+ message,
640
+ planningConstraint,
641
+ requestedCapabilities: stringArray(record?.requestedCapabilities),
642
+ availableTools: stringArray(record?.availableTools),
643
+ },
644
+ ];
645
+ });
646
+ }
647
+
612
648
  function commitSummaries(value: unknown): CoordinatorCommitSummary[] {
613
649
  if (!Array.isArray(value)) {
614
650
  return [];