pi-long-task 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,11 +18,11 @@ Use it when a coding request is bigger than one focused interaction. Pi Long Tas
18
18
  When you ask Pi to run a long task, Pi Long Task:
19
19
 
20
20
  1. Recognizes natural-language requests like "run a long task with commits" and routes them to `pi_long_task`.
21
- 2. Creates or cleans up a TODO plan from your request.
21
+ 2. Creates or cleans up a TODO plan from your request. Natural-language planning uses a bounded planner session; if generated TODO markdown is invalid, Pi Long Task asks the planner to repair it once before failing the run.
22
22
  3. Works through each unfinished TODO task in order using isolated worker sessions.
23
23
  4. Registers a Pi TUI sidebar/widget when UI support is available and updates it with the current task, inferred subtask progress, and full task timeline while the run is active.
24
24
  5. Retries unfinished tasks up to the configured attempt limit.
25
- 6. Records progress, task artifacts, and final results under `tmp/pi-long-task/<run-id>/`.
25
+ 6. Records progress, planner diagnostics, task artifacts, and final results under `tmp/pi-long-task/<run-id>/`.
26
26
  7. Returns a summary with completed, failed, blocked, and remaining task counts, plus worker spend when available.
27
27
  8. Optionally commits completed work after each task.
28
28
 
@@ -230,6 +230,8 @@ That smoke test creates disposable git repos and verifies both `commit: false` a
230
230
  ## Limitations and expectations
231
231
 
232
232
  - Tasks run sequentially, one TODO at a time; Pi Long Task prioritizes isolation, progress tracking, and safe handoff over parallel execution.
233
+ - Natural-language TODO planning has a bounded time budget (five minutes by default, with a short graceful-shutdown request). If planning times out or is aborted before a valid plan exists, the run fails before worker tasks start and records planner diagnostics in `TASK_RESULT.md`.
234
+ - If the planner returns invalid TODO markdown, Pi Long Task makes one repair attempt. A second invalid response fails planning with diagnostics instead of guessing at a plan.
233
235
  - Real runs require usable Pi model credentials, such as a working Pi login or API key for the selected model.
234
236
  - Worker spend is added to the main Pi `$ spent` total as cost-only usage. Token counts are not merged into the main thread because worker sessions have separate context windows, and merging their token usage would corrupt the main conversation's context statistics.
235
237
  - Run artifacts are written under `tmp/pi-long-task/<run-id>/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "description": "Pi extension for breaking down and running long coding tasks safely.",
6
6
  "keywords": [
@@ -11,18 +11,20 @@ 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 { runGuardedSessionPrompt } from "./session_guard.ts";
14
15
  import { parseWorkerRuntimeConfig } from "./worker_config.ts";
15
16
  import { buildTaskProgressModel, type TaskProgressModel, type TaskProgressStatus } from "./task_progress.ts";
16
17
  import {
17
18
  buildTodoCreationPrompt,
18
- extractTodoMarkdown,
19
+ buildTodoRepairPrompt,
20
+ extractAndValidateTodoMarkdown,
21
+ TodoGenerationError,
19
22
  todoMarkdownFromString,
20
23
  validateTodoMarkdown,
21
24
  } from "./todo_generator.ts";
22
25
  import { markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
23
26
  import {
24
27
  createIsolatedWorkerSession,
25
- lastAssistantTextFromMessages,
26
28
  runWorkerTask,
27
29
  type RunWorkerTaskOptions,
28
30
  type SessionOutcome,
@@ -35,6 +37,8 @@ export type { CoordinatorStatus } from "./types.ts";
35
37
  export const DEFAULT_COORDINATOR_OPTIONS = {
36
38
  maxAttemptsPerTask: 3,
37
39
  taskTimeoutMs: 900_000,
40
+ todoTimeoutMs: 300_000,
41
+ todoGracefulShutdownMs: 15_000,
38
42
  maxBashTimeoutMs: 300_000,
39
43
  taskThinking: "high",
40
44
  todoThinking: "xhigh",
@@ -51,6 +55,18 @@ export type CoordinatorProgressPhase =
51
55
  | "task_failed"
52
56
  | "complete";
53
57
 
58
+ export type PlannerDiagnosticKind = "timeout" | "abort" | "invalid_output" | "repair_attempt" | "failure";
59
+
60
+ export interface PlannerDiagnostic {
61
+ kind: PlannerDiagnosticKind;
62
+ message: string;
63
+ diagnostics?: string[];
64
+ sessionFile?: string;
65
+ sessionId?: string;
66
+ }
67
+
68
+ export type PlannerDiagnosticHandler = (diagnostic: PlannerDiagnostic) => void;
69
+
54
70
  export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
55
71
 
56
72
  export interface CoordinatorProgressTask {
@@ -85,6 +101,10 @@ export interface CoordinatorProgressUpdate {
85
101
  currentTask?: CoordinatorProgressTask;
86
102
  subtasks?: CoordinatorProgressSubtask[];
87
103
  taskProgress?: TaskProgressModel;
104
+ plannerDiagnostic?: PlannerDiagnosticKind;
105
+ plannerDiagnostics?: string[];
106
+ plannerSessionFile?: string;
107
+ plannerSessionId?: string;
88
108
  }
89
109
 
90
110
  export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
@@ -102,6 +122,8 @@ export interface RunCoordinatorOptions extends PiLongTaskInput {
102
122
  workerModelName?: string;
103
123
  maxAttemptsPerTask?: number;
104
124
  taskTimeoutMs?: number;
125
+ todoTimeoutMs?: number;
126
+ todoGracefulShutdownMs?: number;
105
127
  maxBashTimeoutMs?: number;
106
128
  taskThinking?: string;
107
129
  todoThinking?: string;
@@ -116,7 +138,10 @@ export interface TodoPlannerOptions {
116
138
  thinkingLevel: string;
117
139
  model?: unknown;
118
140
  abortSignal?: AbortSignal;
141
+ timeoutMs?: number;
142
+ gracefulShutdownMs?: number;
119
143
  sessionFactory?: WorkerSessionFactory;
144
+ onDiagnostic?: PlannerDiagnosticHandler;
120
145
  }
121
146
 
122
147
  export interface TaskAttemptSummary {
@@ -175,6 +200,8 @@ interface RuntimeOptions {
175
200
  workerModelName?: string;
176
201
  taskThinking: string;
177
202
  todoThinking: string;
203
+ todoTimeoutMs: number;
204
+ todoGracefulShutdownMs: number;
178
205
  workerRunner: WorkerRunner;
179
206
  todoPlanner: TodoPlanner;
180
207
  abortSignal?: AbortSignal;
@@ -183,6 +210,7 @@ interface RuntimeOptions {
183
210
  now: () => Date;
184
211
  onProgress?: CoordinatorProgressHandler;
185
212
  workerCostState: WorkerCostState;
213
+ plannerDiagnostics: PlannerDiagnostic[];
186
214
  }
187
215
 
188
216
  export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
@@ -193,11 +221,13 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
193
221
 
194
222
  await mkdir(runtime.runDir, { recursive: true });
195
223
  await writeFile(runtime.taskResultPath, initialTaskResultMarkdown(runtime.runId), "utf8");
224
+ let planningComplete = false;
196
225
 
197
226
  try {
198
227
  emitProgress(runtime, "Creating TODO plan...", { phase: "planning" });
199
228
  let todoMarkdown = await generateOrNormalizeTodoMarkdown(options.inputText, runtime);
200
229
  validateTodoMarkdown(todoMarkdown);
230
+ planningComplete = true;
201
231
  await writeFile(runtime.todoPath, todoMarkdown, "utf8");
202
232
  const initialTasks = parseTasks(todoMarkdown);
203
233
  emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
@@ -379,9 +409,18 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
379
409
  return result;
380
410
  } catch (error) {
381
411
  const message = errorMessage(error);
382
- const summary = `Pi Long Task failed: ${message}`;
412
+ if (!planningComplete) {
413
+ recordPlannerDiagnostic(runtime, {
414
+ kind: "failure",
415
+ message: `TODO planning failed: ${message}`,
416
+ });
417
+ }
418
+ const resultError = !planningComplete
419
+ ? `${message} See ${runtime.taskResultPath} for planner diagnostics.`
420
+ : message;
421
+ const summary = `Pi Long Task failed: ${resultError}`;
383
422
  try {
384
- await appendFile(runtime.taskResultPath, `\n## Pi Long Task failure\n\n${message}\n`, "utf8");
423
+ await appendFailureNote(runtime.taskResultPath, message, !planningComplete ? runtime.plannerDiagnostics : []);
385
424
  } catch {
386
425
  // Best effort only; the original error is returned below.
387
426
  }
@@ -407,7 +446,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
407
446
  taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
408
447
  workerCostTotal: runtime.workerCostState.total,
409
448
  commit: options.commit,
410
- error: message,
449
+ error: resultError,
411
450
  };
412
451
  result.message = formatCoordinatorResultMessage(result);
413
452
  emitProgress(runtime, "Pi Long Task failed.", {
@@ -425,45 +464,228 @@ async function generateOrNormalizeTodoMarkdown(inputText: string, runtime: Runti
425
464
  return local;
426
465
  }
427
466
 
428
- const plannerText = await runtime.todoPlanner({
467
+ const plannerText = await requestTodoPlan(inputText, runtime);
468
+ return extractTodoMarkdownWithOneRepair(
469
+ inputText,
470
+ plannerText,
471
+ (repairPrompt) => requestTodoPlan(repairPrompt, runtime),
472
+ {
473
+ onInvalidOutput: (validationError) =>
474
+ recordPlannerDiagnostic(runtime, {
475
+ kind: "invalid_output",
476
+ message: `TODO planner returned invalid output: ${validationError}`,
477
+ }),
478
+ onRepairAttempt: (validationError) =>
479
+ recordPlannerDiagnostic(runtime, {
480
+ kind: "repair_attempt",
481
+ message: `Asking TODO planner to repair invalid output: ${validationError}`,
482
+ }),
483
+ onFailure: (validationError) =>
484
+ recordPlannerDiagnostic(runtime, {
485
+ kind: "failure",
486
+ message: `TODO planner repair failed: ${validationError}`,
487
+ }),
488
+ },
489
+ );
490
+ }
491
+
492
+ interface TodoExtractionRepairHooks {
493
+ onInvalidOutput?: (validationError: string) => void;
494
+ onRepairAttempt?: (validationError: string) => void;
495
+ onFailure?: (validationError: string) => void;
496
+ }
497
+
498
+ async function extractTodoMarkdownWithOneRepair(
499
+ inputText: string,
500
+ plannerText: string,
501
+ requestRepair: (repairPrompt: string) => Promise<string>,
502
+ hooks: TodoExtractionRepairHooks = {},
503
+ ): Promise<string> {
504
+ try {
505
+ return extractAndValidateTodoMarkdown(plannerText);
506
+ } catch (error) {
507
+ const validationError = errorMessage(error);
508
+ hooks.onInvalidOutput?.(validationError);
509
+ hooks.onRepairAttempt?.(validationError);
510
+ const repairText = await requestRepair(buildTodoRepairPrompt(inputText, plannerText, validationError));
511
+ try {
512
+ return extractAndValidateTodoMarkdown(repairText);
513
+ } catch (repairError) {
514
+ const repairMessage = errorMessage(repairError);
515
+ hooks.onFailure?.(repairMessage);
516
+ throw new TodoGenerationError(
517
+ `TODO planner returned invalid TODO markdown after one repair attempt: ${repairMessage}`,
518
+ );
519
+ }
520
+ }
521
+ }
522
+
523
+ async function requestTodoPlan(inputText: string, runtime: RuntimeOptions): Promise<string> {
524
+ return runtime.todoPlanner({
429
525
  inputText,
430
526
  cwd: runtime.cwd,
431
527
  runDir: runtime.runDir,
432
528
  thinkingLevel: runtime.todoThinking,
433
529
  model: runtime.workerModel,
434
530
  abortSignal: runtime.abortSignal,
531
+ timeoutMs: runtime.todoTimeoutMs,
532
+ gracefulShutdownMs: runtime.todoGracefulShutdownMs,
435
533
  sessionFactory: runtime.todoSessionFactory,
534
+ onDiagnostic: (diagnostic) => recordPlannerDiagnostic(runtime, diagnostic),
436
535
  });
437
- return extractTodoMarkdown(plannerText);
438
536
  }
439
537
 
538
+ // Planner/worker lifecycle differences are audited in docs/planner-worker-lifecycle-audit.md;
539
+ // keep this function's public contract stable while moving shared prompt guarding into a helper.
440
540
  export async function runTodoPlanner(options: TodoPlannerOptions): Promise<string> {
441
- let session: WorkerSessionLike | undefined;
541
+ const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
542
+ const result = await sessionFactory({
543
+ cwd: options.cwd,
544
+ tools: [],
545
+ model: options.model,
546
+ thinkingLevel: options.thinkingLevel,
547
+ });
548
+ const session = result.session;
549
+ const timeoutMs = positiveMilliseconds(options.timeoutMs, DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs);
550
+ const gracefulShutdownMs = options.gracefulShutdownMs ?? DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs;
551
+
552
+ let plannerMarkdown: string | undefined;
553
+ let plannerError: unknown;
554
+
442
555
  try {
443
- const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
444
- const result = await sessionFactory({
445
- cwd: options.cwd,
446
- tools: [],
447
- model: options.model,
448
- thinkingLevel: options.thinkingLevel,
556
+ const plannerText = await runTodoPlannerPrompt({
557
+ session,
558
+ prompt: buildTodoCreationPrompt(options.inputText),
559
+ abortSignal: options.abortSignal,
560
+ timeoutMs,
561
+ gracefulShutdownMs,
562
+ diagnostics: result.diagnostics,
563
+ onDiagnostic: options.onDiagnostic,
449
564
  });
450
- session = result.session;
451
565
 
452
- if (options.abortSignal?.aborted) {
453
- throw new Error("TODO planner aborted before start");
454
- }
566
+ plannerMarkdown = await extractTodoMarkdownWithOneRepair(
567
+ options.inputText,
568
+ plannerText,
569
+ (repairPrompt) =>
570
+ runTodoPlannerPrompt({
571
+ session,
572
+ prompt: repairPrompt,
573
+ abortSignal: options.abortSignal,
574
+ timeoutMs,
575
+ gracefulShutdownMs,
576
+ diagnostics: result.diagnostics,
577
+ onDiagnostic: options.onDiagnostic,
578
+ }),
579
+ {
580
+ onInvalidOutput: (validationError) =>
581
+ options.onDiagnostic?.({
582
+ kind: "invalid_output",
583
+ message: `TODO planner returned invalid output: ${validationError}`,
584
+ diagnostics: result.diagnostics,
585
+ sessionFile: session.sessionFile,
586
+ sessionId: session.sessionId,
587
+ }),
588
+ onRepairAttempt: (validationError) =>
589
+ options.onDiagnostic?.({
590
+ kind: "repair_attempt",
591
+ message: `Asking TODO planner to repair invalid output: ${validationError}`,
592
+ diagnostics: result.diagnostics,
593
+ sessionFile: session.sessionFile,
594
+ sessionId: session.sessionId,
595
+ }),
596
+ onFailure: (validationError) =>
597
+ options.onDiagnostic?.({
598
+ kind: "failure",
599
+ message: `TODO planner repair failed: ${validationError}`,
600
+ diagnostics: result.diagnostics,
601
+ sessionFile: session.sessionFile,
602
+ sessionId: session.sessionId,
603
+ }),
604
+ },
605
+ );
606
+ } catch (error) {
607
+ plannerError = error;
608
+ }
455
609
 
456
- await session.prompt(buildTodoCreationPrompt(options.inputText));
457
- const direct = session.getLastAssistantText?.();
458
- const fromMessages = lastAssistantTextFromMessages(session.messages);
459
- const text = direct || fromMessages;
460
- if (!text) {
461
- throw new Error("TODO planner did not return assistant text.");
462
- }
463
- return text;
464
- } finally {
465
- session?.dispose?.();
610
+ try {
611
+ await Promise.resolve(session.dispose?.());
612
+ } catch (error) {
613
+ plannerError = plannerError ?? new TodoGenerationError(`TODO planner dispose failed: ${errorMessage(error)}`);
614
+ }
615
+
616
+ if (plannerError) {
617
+ throw plannerError;
466
618
  }
619
+ if (!plannerMarkdown) {
620
+ throw new TodoGenerationError("TODO planner did not return valid TODO markdown.");
621
+ }
622
+ return plannerMarkdown;
623
+ }
624
+
625
+ async function runTodoPlannerPrompt(options: {
626
+ session: WorkerSessionLike;
627
+ prompt: string;
628
+ abortSignal?: AbortSignal;
629
+ timeoutMs: number;
630
+ gracefulShutdownMs: number;
631
+ diagnostics?: string[];
632
+ onDiagnostic?: PlannerDiagnosticHandler;
633
+ }): Promise<string> {
634
+ const promptResult = await runGuardedSessionPrompt({
635
+ session: options.session,
636
+ prompt: options.prompt,
637
+ abortSignal: options.abortSignal,
638
+ timeoutMs: options.timeoutMs,
639
+ gracefulShutdownMs: options.gracefulShutdownMs,
640
+ gracefulShutdownPrompt: buildTodoPlanningShutdownMessage(),
641
+ diagnostics: options.diagnostics,
642
+ dispose: false,
643
+ });
644
+
645
+ if (promptResult.timedOut) {
646
+ const message = `TODO planner timed out: ${promptResult.error ?? "time budget exceeded"}`;
647
+ options.onDiagnostic?.(plannerPromptDiagnostic("timeout", message, promptResult));
648
+ throw new TodoGenerationError(message);
649
+ }
650
+ if (promptResult.aborted) {
651
+ const message = `TODO planner aborted: ${promptResult.error ?? "outer abort signal"}`;
652
+ options.onDiagnostic?.(plannerPromptDiagnostic("abort", message, promptResult));
653
+ throw new TodoGenerationError(message);
654
+ }
655
+ if (promptResult.error) {
656
+ const message = `TODO planner failed: ${promptResult.error}`;
657
+ options.onDiagnostic?.(plannerPromptDiagnostic("failure", message, promptResult));
658
+ throw new TodoGenerationError(message);
659
+ }
660
+ if (!promptResult.assistantText) {
661
+ const message = "TODO planner did not return assistant text.";
662
+ options.onDiagnostic?.(plannerPromptDiagnostic("failure", message, promptResult));
663
+ throw new TodoGenerationError(message);
664
+ }
665
+ return promptResult.assistantText;
666
+ }
667
+
668
+ function plannerPromptDiagnostic(
669
+ kind: Extract<PlannerDiagnosticKind, "timeout" | "abort" | "failure">,
670
+ message: string,
671
+ promptResult: {
672
+ diagnostics: string[];
673
+ sessionFile?: string;
674
+ sessionId?: string;
675
+ },
676
+ ): PlannerDiagnostic {
677
+ return {
678
+ kind,
679
+ message,
680
+ diagnostics: promptResult.diagnostics,
681
+ sessionFile: promptResult.sessionFile,
682
+ sessionId: promptResult.sessionId,
683
+ };
684
+ }
685
+
686
+ function buildTodoPlanningShutdownMessage(): string {
687
+ return `Pi Long Task notice: TODO planning has reached its time budget.
688
+ Return the best valid Pi Long Task TODO markdown you can produce now, or stop if that is not possible.`;
467
689
  }
468
690
 
469
691
  function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
@@ -473,6 +695,8 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
473
695
  const parsedWorkerConfig = parseWorkerRuntimeConfig(options.inputText);
474
696
  const configuredAttempts = options.maxAttemptsPerTask ?? parsedWorkerConfig.maxAttemptsPerTask;
475
697
  const configuredTaskTimeoutMs = options.taskTimeoutMs ?? parsedWorkerConfig.taskTimeoutMs;
698
+ const configuredTodoTimeoutMs = options.todoTimeoutMs;
699
+ const configuredTodoGracefulShutdownMs = options.todoGracefulShutdownMs;
476
700
  const configuredMaxBashTimeoutMs = options.maxBashTimeoutMs ?? parsedWorkerConfig.maxBashTimeoutMs;
477
701
  const workerModelName = options.workerModelName ?? parsedWorkerConfig.modelName;
478
702
  const workerModel = workerModelName ? undefined : options.workerModel;
@@ -485,6 +709,11 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
485
709
  taskResultPath: path.join(runDir, "TASK_RESULT.md"),
486
710
  maxAttemptsPerTask: positiveInteger(configuredAttempts, DEFAULT_COORDINATOR_OPTIONS.maxAttemptsPerTask),
487
711
  taskTimeoutSeconds: positiveMilliseconds(configuredTaskTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.taskTimeoutMs) / 1000,
712
+ todoTimeoutMs: positiveMilliseconds(configuredTodoTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.todoTimeoutMs),
713
+ todoGracefulShutdownMs: positiveMilliseconds(
714
+ configuredTodoGracefulShutdownMs,
715
+ DEFAULT_COORDINATOR_OPTIONS.todoGracefulShutdownMs,
716
+ ),
488
717
  maxBashTimeoutSeconds:
489
718
  positiveMilliseconds(configuredMaxBashTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.maxBashTimeoutMs) / 1000,
490
719
  workerModel,
@@ -499,6 +728,7 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
499
728
  now: options.now ?? (() => new Date()),
500
729
  onProgress: options.onProgress,
501
730
  workerCostState: createWorkerCostState(),
731
+ plannerDiagnostics: [],
502
732
  };
503
733
  }
504
734
 
@@ -517,6 +747,31 @@ function emitProgress(
517
747
  });
518
748
  }
519
749
 
750
+ function recordPlannerDiagnostic(runtime: RuntimeOptions, diagnostic: PlannerDiagnostic): void {
751
+ const normalized: PlannerDiagnostic = {
752
+ kind: diagnostic.kind,
753
+ message: diagnostic.message,
754
+ diagnostics: diagnostic.diagnostics?.filter(Boolean),
755
+ sessionFile: diagnostic.sessionFile,
756
+ sessionId: diagnostic.sessionId,
757
+ };
758
+ const last = runtime.plannerDiagnostics.at(-1);
759
+ if (last?.kind === normalized.kind && last.message === normalized.message) {
760
+ return;
761
+ }
762
+ runtime.plannerDiagnostics.push(normalized);
763
+ emitProgress(runtime, normalized.message, {
764
+ phase: "planning",
765
+ status: normalized.kind,
766
+ isError: normalized.kind !== "repair_attempt",
767
+ plannerDiagnostic: normalized.kind,
768
+ plannerDiagnostics: normalized.diagnostics,
769
+ plannerSessionFile: normalized.sessionFile,
770
+ plannerSessionId: normalized.sessionId,
771
+ taskProgress: buildTaskProgressModel({ tasks: [] }),
772
+ });
773
+ }
774
+
520
775
  function createWorkerCostState(): WorkerCostState {
521
776
  return {
522
777
  total: 0,
@@ -771,6 +1026,30 @@ function initialTaskResultMarkdown(runId: string): string {
771
1026
  return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
772
1027
  }
773
1028
 
1029
+ async function appendFailureNote(
1030
+ pathname: string,
1031
+ message: string,
1032
+ plannerDiagnostics: readonly PlannerDiagnostic[],
1033
+ ): Promise<void> {
1034
+ const lines = ["", "## Pi Long Task failure", "", message];
1035
+ if (plannerDiagnostics.length > 0) {
1036
+ lines.push("", "### Planner diagnostics");
1037
+ for (const diagnostic of plannerDiagnostics) {
1038
+ lines.push("", `- ${diagnostic.kind}: ${diagnostic.message}`);
1039
+ if (diagnostic.sessionId) {
1040
+ lines.push(` - Session ID: ${diagnostic.sessionId}`);
1041
+ }
1042
+ if (diagnostic.sessionFile) {
1043
+ lines.push(` - Session file: ${diagnostic.sessionFile}`);
1044
+ }
1045
+ for (const item of diagnostic.diagnostics ?? []) {
1046
+ lines.push(` - Diagnostic: ${item}`);
1047
+ }
1048
+ }
1049
+ }
1050
+ await appendFile(pathname, `${lines.join("\n")}\n`, "utf8");
1051
+ }
1052
+
774
1053
  async function appendCommitNote(pathname: string, result: CommitAfterSessionResult): Promise<void> {
775
1054
  const lines = ["", "### Commit note", ""];
776
1055
  if (result.hash) {
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ function toolDetails(result: CoordinatorResult) {
72
72
  taskProgress: result.taskProgress,
73
73
  workerCostTotal: result.workerCostTotal,
74
74
  summary: result.summary,
75
+ error: result.error,
75
76
  };
76
77
  }
77
78
 
@@ -0,0 +1,287 @@
1
+ import {
2
+ assistantTextFromEvent,
3
+ lastAssistantTextFromEvents,
4
+ lastAssistantTextFromMessages,
5
+ type WorkerSessionLike,
6
+ } from "./worker_session.ts";
7
+
8
+ export interface GuardedSessionPromptOptions {
9
+ session: WorkerSessionLike;
10
+ prompt: string;
11
+ promptOptions?: Record<string, unknown>;
12
+ abortSignal?: AbortSignal;
13
+ timeoutMs?: number;
14
+ gracefulShutdownMs?: number;
15
+ gracefulShutdownPrompt?: string;
16
+ diagnostics?: string[];
17
+ onEvent?: (event: unknown) => void;
18
+ dispose?: boolean;
19
+ }
20
+
21
+ export interface GuardedSessionPromptResult {
22
+ assistantText: string;
23
+ timedOut: boolean;
24
+ aborted: boolean;
25
+ error?: string;
26
+ diagnostics: string[];
27
+ events: unknown[];
28
+ sessionFile?: string;
29
+ sessionId?: string;
30
+ }
31
+
32
+ export async function runGuardedSessionPrompt(
33
+ options: GuardedSessionPromptOptions,
34
+ ): Promise<GuardedSessionPromptResult> {
35
+ const session = options.session;
36
+ const diagnostics = [...(options.diagnostics ?? [])];
37
+ const events: unknown[] = [];
38
+ const timers = new Set<ReturnType<typeof setTimeout>>();
39
+ let assistantText = "";
40
+ let timedOut = false;
41
+ let aborted = false;
42
+ let error: string | undefined;
43
+ let promptSettled = false;
44
+ let finished = false;
45
+ let unsubscribe: (() => void) | undefined;
46
+ let complete: (() => void) | undefined;
47
+
48
+ const completed = new Promise<void>((resolve) => {
49
+ complete = resolve;
50
+ });
51
+
52
+ const resolveCompleted = () => {
53
+ complete?.();
54
+ complete = undefined;
55
+ };
56
+
57
+ const clearTimers = () => {
58
+ for (const timer of timers) {
59
+ clearTimeout(timer);
60
+ }
61
+ timers.clear();
62
+ };
63
+
64
+ const schedule = (fn: () => void, ms: number) => {
65
+ const timer = setTimeout(() => {
66
+ timers.delete(timer);
67
+ fn();
68
+ }, ms);
69
+ timers.add(timer);
70
+ };
71
+
72
+ const abortSession = (reason: string) => {
73
+ if (finished || aborted) {
74
+ return;
75
+ }
76
+ aborted = true;
77
+ error = error ?? reason;
78
+ try {
79
+ const abortResult = session.abort?.();
80
+ if (isPromiseLike(abortResult)) {
81
+ void abortResult.catch((exc: unknown) => {
82
+ diagnostics.push(`session abort failed: ${errorMessage(exc)}`);
83
+ });
84
+ }
85
+ } catch (exc) {
86
+ diagnostics.push(`session abort failed: ${errorMessage(exc)}`);
87
+ }
88
+ };
89
+
90
+ const requestGracefulShutdown = () => {
91
+ const message = options.gracefulShutdownPrompt?.trim();
92
+ if (!message || finished || promptSettled || aborted) {
93
+ return;
94
+ }
95
+
96
+ try {
97
+ if (session.isBashRunning && session.abortBash) {
98
+ session.abortBash();
99
+ diagnostics.push("aborted running bash before graceful shutdown request");
100
+ }
101
+
102
+ let request: Promise<unknown> | undefined;
103
+ if ((session.isStreaming || session.isBashRunning) && session.steer) {
104
+ request = session.steer(message);
105
+ } else if (session.followUp) {
106
+ request = session.followUp(message);
107
+ } else if (session.steer) {
108
+ request = session.steer(message);
109
+ }
110
+
111
+ if (!request) {
112
+ diagnostics.push("graceful shutdown request skipped: session does not support steer/followUp");
113
+ return;
114
+ }
115
+
116
+ void request.catch((exc: unknown) => {
117
+ diagnostics.push(`graceful shutdown request failed: ${errorMessage(exc)}`);
118
+ });
119
+ } catch (exc) {
120
+ diagnostics.push(`graceful shutdown request failed: ${errorMessage(exc)}`);
121
+ }
122
+ };
123
+
124
+ const triggerTimeout = () => {
125
+ if (finished || promptSettled || timedOut) {
126
+ return;
127
+ }
128
+ timedOut = true;
129
+ diagnostics.push(`session prompt timed out after ${formatMilliseconds(timeoutMs(options.timeoutMs))}`);
130
+ requestGracefulShutdown();
131
+
132
+ const graceMs = nonNegativeMilliseconds(options.gracefulShutdownMs);
133
+ const hardAbort = () => {
134
+ if (finished || promptSettled) {
135
+ return;
136
+ }
137
+ abortSession(`session prompt exceeded ${formatMilliseconds(timeoutMs(options.timeoutMs))} timeout`);
138
+ resolveCompleted();
139
+ };
140
+
141
+ if (graceMs > 0) {
142
+ schedule(hardAbort, graceMs);
143
+ } else {
144
+ hardAbort();
145
+ }
146
+ };
147
+
148
+ const abortListener = () => {
149
+ if (finished || promptSettled) {
150
+ return;
151
+ }
152
+ abortSession(abortReason(options.abortSignal, "session prompt aborted by outer signal"));
153
+ resolveCompleted();
154
+ };
155
+
156
+ try {
157
+ if (options.abortSignal?.aborted) {
158
+ aborted = true;
159
+ error = abortReason(options.abortSignal, "session prompt aborted before start");
160
+ } else {
161
+ unsubscribe = session.subscribe((event: unknown) => {
162
+ events.push(event);
163
+ const text = assistantTextFromEvent(event);
164
+ if (text) {
165
+ assistantText = text;
166
+ }
167
+ try {
168
+ options.onEvent?.(event);
169
+ } catch (exc) {
170
+ diagnostics.push(`event listener failed: ${errorMessage(exc)}`);
171
+ }
172
+ });
173
+
174
+ options.abortSignal?.addEventListener("abort", abortListener, { once: true });
175
+
176
+ const promptPromise = session.prompt(options.prompt, options.promptOptions).then(
177
+ () => {
178
+ promptSettled = true;
179
+ resolveCompleted();
180
+ },
181
+ (exc: unknown) => {
182
+ promptSettled = true;
183
+ error = error ?? errorMessage(exc);
184
+ resolveCompleted();
185
+ },
186
+ );
187
+ void promptPromise;
188
+
189
+ const limitMs = timeoutMs(options.timeoutMs);
190
+ if (limitMs > 0) {
191
+ schedule(triggerTimeout, limitMs);
192
+ }
193
+
194
+ await completed;
195
+ }
196
+ } catch (exc) {
197
+ error = error ?? errorMessage(exc);
198
+ } finally {
199
+ finished = true;
200
+ clearTimers();
201
+ options.abortSignal?.removeEventListener("abort", abortListener);
202
+ unsubscribe?.();
203
+ assistantText = latestAssistantText(session, events, assistantText);
204
+ if (options.dispose !== false) {
205
+ try {
206
+ const disposeResult = (session.dispose as (() => unknown) | undefined)?.();
207
+ if (isPromiseLike(disposeResult)) {
208
+ await disposeResult;
209
+ }
210
+ } catch (exc) {
211
+ const message = `session dispose failed: ${errorMessage(exc)}`;
212
+ diagnostics.push(message);
213
+ error = error ?? message;
214
+ }
215
+ }
216
+ }
217
+
218
+ return buildResult(session, events, assistantText, timedOut, aborted, error, diagnostics);
219
+ }
220
+
221
+ function buildResult(
222
+ session: WorkerSessionLike,
223
+ events: unknown[],
224
+ assistantText: string,
225
+ timedOut: boolean,
226
+ aborted: boolean,
227
+ error: string | undefined,
228
+ diagnostics: string[],
229
+ ): GuardedSessionPromptResult {
230
+ return {
231
+ assistantText: latestAssistantText(session, events, assistantText),
232
+ timedOut,
233
+ aborted,
234
+ error,
235
+ diagnostics: [...diagnostics],
236
+ events: [...events],
237
+ sessionFile: session.sessionFile,
238
+ sessionId: session.sessionId,
239
+ };
240
+ }
241
+
242
+ function latestAssistantText(session: WorkerSessionLike, events: unknown[], fallback: string): string {
243
+ const direct = session.getLastAssistantText?.();
244
+ if (direct) {
245
+ return direct;
246
+ }
247
+ const fromMessages = lastAssistantTextFromMessages(session.messages);
248
+ if (fromMessages) {
249
+ return fromMessages;
250
+ }
251
+ const fromEvents = lastAssistantTextFromEvents(events);
252
+ return fromEvents || fallback;
253
+ }
254
+
255
+ function timeoutMs(value: number | undefined): number {
256
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
257
+ return 0;
258
+ }
259
+ return Math.max(0, value);
260
+ }
261
+
262
+ function nonNegativeMilliseconds(value: number | undefined): number {
263
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
264
+ return 0;
265
+ }
266
+ return Math.max(0, value);
267
+ }
268
+
269
+ function formatMilliseconds(ms: number): string {
270
+ return `${(ms / 1000).toFixed(3)}s`;
271
+ }
272
+
273
+ function abortReason(signal: AbortSignal | undefined, fallback: string): string {
274
+ const reason = signal?.reason;
275
+ if (reason === undefined) {
276
+ return fallback;
277
+ }
278
+ return errorMessage(reason);
279
+ }
280
+
281
+ function errorMessage(error: unknown): string {
282
+ return error instanceof Error ? error.message : String(error);
283
+ }
284
+
285
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
286
+ return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
287
+ }
@@ -120,6 +120,16 @@ export function buildTodoCreationPrompt(rawInput: string): string {
120
120
  return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.\n\nRequirements:\n- Output only markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n\nRaw input:\n\n${rawInput.trim()}\n`;
121
121
  }
122
122
 
123
+ export function buildTodoRepairPrompt(rawInput: string, invalidOutput: string, validationError: string): string {
124
+ return `Your previous response was not valid Pi Long Task TODO markdown. Correct it now.\n\nValidation/extraction error:\n${validationError.trim() || "Unknown validation error."}\n\nRequirements:\n- Output only corrected markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n\nOriginal raw input:\n\n${rawInput.trim()}\n\nPrevious invalid output:\n\n${invalidOutput.trim()}\n`;
125
+ }
126
+
127
+ export function extractAndValidateTodoMarkdown(assistantText: string): string {
128
+ const markdown = extractTodoMarkdown(assistantText);
129
+ validateTodoMarkdown(markdown);
130
+ return markdown;
131
+ }
132
+
123
133
  export function extractTodoMarkdown(assistantText: string): string {
124
134
  for (const block of fencedMarkdownBlocks(assistantText)) {
125
135
  const candidate = normalizeCandidate(block);