pi-long-task 0.3.7 → 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.7",
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,17 +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";
15
+ import { parseWorkerRuntimeConfig } from "./worker_config.ts";
14
16
  import { buildTaskProgressModel, type TaskProgressModel, type TaskProgressStatus } from "./task_progress.ts";
15
17
  import {
16
18
  buildTodoCreationPrompt,
17
- extractTodoMarkdown,
19
+ buildTodoRepairPrompt,
20
+ extractAndValidateTodoMarkdown,
21
+ TodoGenerationError,
18
22
  todoMarkdownFromString,
19
23
  validateTodoMarkdown,
20
24
  } from "./todo_generator.ts";
21
25
  import { markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
22
26
  import {
23
27
  createIsolatedWorkerSession,
24
- lastAssistantTextFromMessages,
25
28
  runWorkerTask,
26
29
  type RunWorkerTaskOptions,
27
30
  type SessionOutcome,
@@ -34,6 +37,8 @@ export type { CoordinatorStatus } from "./types.ts";
34
37
  export const DEFAULT_COORDINATOR_OPTIONS = {
35
38
  maxAttemptsPerTask: 3,
36
39
  taskTimeoutMs: 900_000,
40
+ todoTimeoutMs: 300_000,
41
+ todoGracefulShutdownMs: 15_000,
37
42
  maxBashTimeoutMs: 300_000,
38
43
  taskThinking: "high",
39
44
  todoThinking: "xhigh",
@@ -50,6 +55,18 @@ export type CoordinatorProgressPhase =
50
55
  | "task_failed"
51
56
  | "complete";
52
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
+
53
70
  export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
54
71
 
55
72
  export interface CoordinatorProgressTask {
@@ -84,6 +101,10 @@ export interface CoordinatorProgressUpdate {
84
101
  currentTask?: CoordinatorProgressTask;
85
102
  subtasks?: CoordinatorProgressSubtask[];
86
103
  taskProgress?: TaskProgressModel;
104
+ plannerDiagnostic?: PlannerDiagnosticKind;
105
+ plannerDiagnostics?: string[];
106
+ plannerSessionFile?: string;
107
+ plannerSessionId?: string;
87
108
  }
88
109
 
89
110
  export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
@@ -97,8 +118,12 @@ export interface RunCoordinatorOptions extends PiLongTaskInput {
97
118
  todoPlanner?: TodoPlanner;
98
119
  workerSessionFactory?: WorkerSessionFactory;
99
120
  todoSessionFactory?: WorkerSessionFactory;
121
+ workerModel?: unknown;
122
+ workerModelName?: string;
100
123
  maxAttemptsPerTask?: number;
101
124
  taskTimeoutMs?: number;
125
+ todoTimeoutMs?: number;
126
+ todoGracefulShutdownMs?: number;
102
127
  maxBashTimeoutMs?: number;
103
128
  taskThinking?: string;
104
129
  todoThinking?: string;
@@ -111,8 +136,12 @@ export interface TodoPlannerOptions {
111
136
  cwd: string;
112
137
  runDir: string;
113
138
  thinkingLevel: string;
139
+ model?: unknown;
114
140
  abortSignal?: AbortSignal;
141
+ timeoutMs?: number;
142
+ gracefulShutdownMs?: number;
115
143
  sessionFactory?: WorkerSessionFactory;
144
+ onDiagnostic?: PlannerDiagnosticHandler;
116
145
  }
117
146
 
118
147
  export interface TaskAttemptSummary {
@@ -167,8 +196,12 @@ interface RuntimeOptions {
167
196
  maxAttemptsPerTask: number;
168
197
  taskTimeoutSeconds: number;
169
198
  maxBashTimeoutSeconds: number;
199
+ workerModel?: unknown;
200
+ workerModelName?: string;
170
201
  taskThinking: string;
171
202
  todoThinking: string;
203
+ todoTimeoutMs: number;
204
+ todoGracefulShutdownMs: number;
172
205
  workerRunner: WorkerRunner;
173
206
  todoPlanner: TodoPlanner;
174
207
  abortSignal?: AbortSignal;
@@ -177,6 +210,7 @@ interface RuntimeOptions {
177
210
  now: () => Date;
178
211
  onProgress?: CoordinatorProgressHandler;
179
212
  workerCostState: WorkerCostState;
213
+ plannerDiagnostics: PlannerDiagnostic[];
180
214
  }
181
215
 
182
216
  export async function runCoordinator(options: RunCoordinatorOptions): Promise<CoordinatorResult> {
@@ -187,11 +221,13 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
187
221
 
188
222
  await mkdir(runtime.runDir, { recursive: true });
189
223
  await writeFile(runtime.taskResultPath, initialTaskResultMarkdown(runtime.runId), "utf8");
224
+ let planningComplete = false;
190
225
 
191
226
  try {
192
227
  emitProgress(runtime, "Creating TODO plan...", { phase: "planning" });
193
228
  let todoMarkdown = await generateOrNormalizeTodoMarkdown(options.inputText, runtime);
194
229
  validateTodoMarkdown(todoMarkdown);
230
+ planningComplete = true;
195
231
  await writeFile(runtime.todoPath, todoMarkdown, "utf8");
196
232
  const initialTasks = parseTasks(todoMarkdown);
197
233
  emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
@@ -240,6 +276,8 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
240
276
  globalInstructions: todoGlobalInstructions(todoMarkdown),
241
277
  maxBashTimeoutSeconds: runtime.maxBashTimeoutSeconds,
242
278
  taskTimeoutSeconds: runtime.taskTimeoutSeconds,
279
+ model: runtime.workerModel,
280
+ modelName: runtime.workerModelName,
243
281
  thinkingLevel: runtime.taskThinking,
244
282
  abortSignal: runtime.abortSignal,
245
283
  sessionFactory: runtime.workerSessionFactory,
@@ -371,9 +409,18 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
371
409
  return result;
372
410
  } catch (error) {
373
411
  const message = errorMessage(error);
374
- 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}`;
375
422
  try {
376
- await appendFile(runtime.taskResultPath, `\n## Pi Long Task failure\n\n${message}\n`, "utf8");
423
+ await appendFailureNote(runtime.taskResultPath, message, !planningComplete ? runtime.plannerDiagnostics : []);
377
424
  } catch {
378
425
  // Best effort only; the original error is returned below.
379
426
  }
@@ -399,7 +446,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
399
446
  taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
400
447
  workerCostTotal: runtime.workerCostState.total,
401
448
  commit: options.commit,
402
- error: message,
449
+ error: resultError,
403
450
  };
404
451
  result.message = formatCoordinatorResultMessage(result);
405
452
  emitProgress(runtime, "Pi Long Task failed.", {
@@ -417,49 +464,242 @@ async function generateOrNormalizeTodoMarkdown(inputText: string, runtime: Runti
417
464
  return local;
418
465
  }
419
466
 
420
- 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({
421
525
  inputText,
422
526
  cwd: runtime.cwd,
423
527
  runDir: runtime.runDir,
424
528
  thinkingLevel: runtime.todoThinking,
529
+ model: runtime.workerModel,
425
530
  abortSignal: runtime.abortSignal,
531
+ timeoutMs: runtime.todoTimeoutMs,
532
+ gracefulShutdownMs: runtime.todoGracefulShutdownMs,
426
533
  sessionFactory: runtime.todoSessionFactory,
534
+ onDiagnostic: (diagnostic) => recordPlannerDiagnostic(runtime, diagnostic),
427
535
  });
428
- return extractTodoMarkdown(plannerText);
429
536
  }
430
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.
431
540
  export async function runTodoPlanner(options: TodoPlannerOptions): Promise<string> {
432
- 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
+
433
555
  try {
434
- const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
435
- const result = await sessionFactory({
436
- cwd: options.cwd,
437
- tools: [],
438
- 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,
439
564
  });
440
- session = result.session;
441
565
 
442
- if (options.abortSignal?.aborted) {
443
- throw new Error("TODO planner aborted before start");
444
- }
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
+ }
445
609
 
446
- await session.prompt(buildTodoCreationPrompt(options.inputText));
447
- const direct = session.getLastAssistantText?.();
448
- const fromMessages = lastAssistantTextFromMessages(session.messages);
449
- const text = direct || fromMessages;
450
- if (!text) {
451
- throw new Error("TODO planner did not return assistant text.");
452
- }
453
- return text;
454
- } finally {
455
- 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;
456
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.`;
457
689
  }
458
690
 
459
691
  function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
460
692
  const cwd = path.resolve(options.cwd ?? process.cwd());
461
693
  const runId = sanitizeRunId(options.runId ?? defaultRunId(options.now?.() ?? new Date()));
462
694
  const runDir = path.join(cwd, "tmp", "pi-long-task", runId);
695
+ const parsedWorkerConfig = parseWorkerRuntimeConfig(options.inputText);
696
+ const configuredAttempts = options.maxAttemptsPerTask ?? parsedWorkerConfig.maxAttemptsPerTask;
697
+ const configuredTaskTimeoutMs = options.taskTimeoutMs ?? parsedWorkerConfig.taskTimeoutMs;
698
+ const configuredTodoTimeoutMs = options.todoTimeoutMs;
699
+ const configuredTodoGracefulShutdownMs = options.todoGracefulShutdownMs;
700
+ const configuredMaxBashTimeoutMs = options.maxBashTimeoutMs ?? parsedWorkerConfig.maxBashTimeoutMs;
701
+ const workerModelName = options.workerModelName ?? parsedWorkerConfig.modelName;
702
+ const workerModel = workerModelName ? undefined : options.workerModel;
463
703
 
464
704
  return {
465
705
  cwd,
@@ -467,10 +707,17 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
467
707
  runDir,
468
708
  todoPath: path.join(runDir, "TODO.md"),
469
709
  taskResultPath: path.join(runDir, "TASK_RESULT.md"),
470
- maxAttemptsPerTask: positiveInteger(options.maxAttemptsPerTask, DEFAULT_COORDINATOR_OPTIONS.maxAttemptsPerTask),
471
- taskTimeoutSeconds: positiveMilliseconds(options.taskTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.taskTimeoutMs) / 1000,
710
+ maxAttemptsPerTask: positiveInteger(configuredAttempts, DEFAULT_COORDINATOR_OPTIONS.maxAttemptsPerTask),
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
+ ),
472
717
  maxBashTimeoutSeconds:
473
- positiveMilliseconds(options.maxBashTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.maxBashTimeoutMs) / 1000,
718
+ positiveMilliseconds(configuredMaxBashTimeoutMs, DEFAULT_COORDINATOR_OPTIONS.maxBashTimeoutMs) / 1000,
719
+ workerModel,
720
+ workerModelName,
474
721
  taskThinking: options.taskThinking ?? DEFAULT_COORDINATOR_OPTIONS.taskThinking,
475
722
  todoThinking: options.todoThinking ?? DEFAULT_COORDINATOR_OPTIONS.todoThinking,
476
723
  workerRunner: options.workerRunner ?? runWorkerTask,
@@ -481,6 +728,7 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
481
728
  now: options.now ?? (() => new Date()),
482
729
  onProgress: options.onProgress,
483
730
  workerCostState: createWorkerCostState(),
731
+ plannerDiagnostics: [],
484
732
  };
485
733
  }
486
734
 
@@ -499,6 +747,31 @@ function emitProgress(
499
747
  });
500
748
  }
501
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
+
502
775
  function createWorkerCostState(): WorkerCostState {
503
776
  return {
504
777
  total: 0,
@@ -753,6 +1026,30 @@ function initialTaskResultMarkdown(runId: string): string {
753
1026
  return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
754
1027
  }
755
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
+
756
1053
  async function appendCommitNote(pathname: string, result: CommitAfterSessionResult): Promise<void> {
757
1054
  const lines = ["", "### Commit note", ""];
758
1055
  if (result.hash) {
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { AssistantMessage } from "@earendil-works/pi-ai";
3
- import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth, type Component, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
4
4
 
5
5
  import { runCoordinator, type CoordinatorProgressUpdate, type CoordinatorResult } from "./coordinator.ts";
6
6
  import { longTaskInputTransform } from "./input_router.ts";
@@ -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
 
@@ -118,20 +119,24 @@ export function createLongTaskSidebarController(ctx: UiContext | undefined): Lon
118
119
  }
119
120
 
120
121
  let latestUpdate: CoordinatorProgressUpdate | undefined;
121
- let sidebarComponent: PiLongTaskSidebarComponent | undefined;
122
- let sidebarTui: TUI | undefined;
122
+ let widgetComponent: PiLongTaskSidebarComponent | undefined;
123
+ let widgetTui: TUI | undefined;
124
+ let overlayComponent: PiLongTaskSidebarComponent | undefined;
125
+ let overlayTui: TUI | undefined;
126
+ let overlayDone: ((result: undefined) => void) | undefined;
127
+ let overlayHandle: OverlayHandle | undefined;
123
128
  let closed = false;
124
129
 
125
130
  if (supportsTuiWidget(ctx)) {
126
131
  ctx.ui.setWidget(
127
132
  LONG_TASK_WIDGET_KEY,
128
133
  (tui, theme) => {
129
- sidebarTui = tui;
130
- sidebarComponent = new PiLongTaskSidebarComponent(theme, () => sidebarWidgetLineLimit(tui));
134
+ widgetTui = tui;
135
+ widgetComponent = new PiLongTaskSidebarComponent(theme, () => sidebarWidgetLineLimit(tui));
131
136
  if (latestUpdate) {
132
- sidebarComponent.setUpdate(latestUpdate);
137
+ widgetComponent.setUpdate(latestUpdate);
133
138
  }
134
- return sidebarComponent;
139
+ return widgetComponent;
135
140
  },
136
141
  { placement: "aboveEditor" },
137
142
  );
@@ -139,15 +144,59 @@ export function createLongTaskSidebarController(ctx: UiContext | undefined): Lon
139
144
  ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, ["Pi Long Task: preparing sidebar..."], { placement: "aboveEditor" });
140
145
  }
141
146
 
147
+ if (supportsTuiOverlay(ctx)) {
148
+ try {
149
+ const overlayPromise = ctx.ui.custom<undefined>(
150
+ (tui, theme, _keybindings, done) => {
151
+ overlayTui = tui;
152
+ overlayDone = done;
153
+ overlayComponent = new PiLongTaskSidebarComponent(theme);
154
+ if (latestUpdate) {
155
+ overlayComponent.setUpdate(latestUpdate);
156
+ }
157
+ if (closed) {
158
+ done(undefined);
159
+ }
160
+ return overlayComponent;
161
+ },
162
+ {
163
+ overlay: true,
164
+ overlayOptions: {
165
+ anchor: "right-center",
166
+ width: "34%",
167
+ minWidth: 32,
168
+ maxHeight: "85%",
169
+ margin: 1,
170
+ nonCapturing: true,
171
+ visible: (termWidth, termHeight) => termWidth >= 96 && termHeight >= 16,
172
+ },
173
+ onHandle: (handle) => {
174
+ overlayHandle = handle;
175
+ handle.unfocus();
176
+ },
177
+ },
178
+ );
179
+ void overlayPromise.catch(() => {
180
+ // The widget fallback remains active if overlay registration is unavailable.
181
+ });
182
+ } catch {
183
+ // The widget fallback remains active if overlay registration is unavailable.
184
+ }
185
+ }
186
+
142
187
  return {
143
188
  update(update: CoordinatorProgressUpdate): void {
144
189
  if (closed) {
145
190
  return;
146
191
  }
147
192
  latestUpdate = update;
148
- sidebarComponent?.setUpdate(update);
149
- sidebarTui?.requestRender();
150
- if (!sidebarComponent) {
193
+ widgetComponent?.setUpdate(update);
194
+ overlayComponent?.setUpdate(update);
195
+ widgetTui?.requestRender();
196
+ if (overlayTui && overlayTui !== widgetTui) {
197
+ overlayTui.requestRender();
198
+ }
199
+ if (!widgetComponent) {
151
200
  ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, renderSidebarWidgetLines(update), { placement: "aboveEditor" });
152
201
  }
153
202
  },
@@ -157,8 +206,17 @@ export function createLongTaskSidebarController(ctx: UiContext | undefined): Lon
157
206
  }
158
207
  closed = true;
159
208
  ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, undefined);
160
- sidebarComponent = undefined;
161
- sidebarTui = undefined;
209
+ if (overlayHandle) {
210
+ overlayHandle.hide();
211
+ } else {
212
+ overlayDone?.(undefined);
213
+ }
214
+ widgetComponent = undefined;
215
+ widgetTui = undefined;
216
+ overlayComponent = undefined;
217
+ overlayTui = undefined;
218
+ overlayDone = undefined;
219
+ overlayHandle = undefined;
162
220
  },
163
221
  };
164
222
  }
@@ -168,6 +226,11 @@ function supportsTuiWidget(ctx: UiContext): boolean {
168
226
  return mode === "tui" || mode === undefined;
169
227
  }
170
228
 
229
+ function supportsTuiOverlay(ctx: UiContext): boolean {
230
+ const mode = (ctx as UiContext & { mode?: string }).mode;
231
+ return mode === "tui" || mode === undefined;
232
+ }
233
+
171
234
  function sidebarWidgetLineLimit(tui: TUI): number {
172
235
  const terminalRows = tui.terminal.rows;
173
236
  const rows = Number.isFinite(terminalRows) ? Math.max(0, Math.floor(terminalRows)) : 24;
@@ -679,6 +742,7 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
679
742
  const result = await runCoordinator({
680
743
  ...params,
681
744
  cwd: ctx?.cwd,
745
+ workerModel: ctx?.model,
682
746
  abortSignal: signal,
683
747
  onProgress: publishProgress,
684
748
  });
@@ -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);
@@ -0,0 +1,291 @@
1
+ export interface ParsedWorkerRuntimeConfig {
2
+ modelName?: string;
3
+ maxAttemptsPerTask?: number;
4
+ taskTimeoutMs?: number;
5
+ maxBashTimeoutMs?: number;
6
+ }
7
+
8
+ const MODEL_TOKEN_RE = /[A-Za-z0-9][A-Za-z0-9._~:+/@-]*/;
9
+ const STOP_WORDS = new Set([
10
+ "and",
11
+ "as",
12
+ "for",
13
+ "from",
14
+ "in",
15
+ "is",
16
+ "of",
17
+ "on",
18
+ "per",
19
+ "please",
20
+ "task",
21
+ "tasks",
22
+ "the",
23
+ "to",
24
+ "use",
25
+ "with",
26
+ "worker",
27
+ "workers",
28
+ ]);
29
+
30
+ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfig {
31
+ const state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string } = {};
32
+
33
+ parseLineDirectives(text, state);
34
+ parseNaturalLanguageDirectives(text, state);
35
+
36
+ const modelName = combineProviderAndModel(state.provider, state.model);
37
+ return {
38
+ ...(modelName ? { modelName } : {}),
39
+ ...(state.maxAttemptsPerTask !== undefined ? { maxAttemptsPerTask: state.maxAttemptsPerTask } : {}),
40
+ ...(state.taskTimeoutMs !== undefined ? { taskTimeoutMs: state.taskTimeoutMs } : {}),
41
+ ...(state.maxBashTimeoutMs !== undefined ? { maxBashTimeoutMs: state.maxBashTimeoutMs } : {}),
42
+ };
43
+ }
44
+
45
+ function parseLineDirectives(
46
+ text: string,
47
+ state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
48
+ ): void {
49
+ for (const rawLine of text.split(/\r?\n/)) {
50
+ const line = rawLine.replace(/^\s{0,3}>+\s?/, "").trim();
51
+ const match = line.match(
52
+ /^(?:[-*+]\s*)?(?:(pi\s+long\s+task|long\s+task|worker|workers?|task)\s+)?([a-z][a-z\s-]{0,40})\s*(?::|=)\s*(.+)$/i,
53
+ );
54
+ if (!match) {
55
+ continue;
56
+ }
57
+
58
+ const prefix = normalizeWords(match[1] ?? "");
59
+ const key = normalizeWords(match[2] ?? "");
60
+ const fullKey = normalizeWords(`${prefix} ${key}`);
61
+ const value = match[3] ?? "";
62
+
63
+ applyDirective(fullKey, value, state);
64
+ }
65
+ }
66
+
67
+ function parseNaturalLanguageDirectives(
68
+ text: string,
69
+ state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
70
+ ): void {
71
+ captureTokens(
72
+ text,
73
+ /\bworker\s+(?:model|provider\/model)\s*(?:is|=|:|to|as)?\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi,
74
+ (token) => {
75
+ state.model = token;
76
+ },
77
+ );
78
+ captureTokens(
79
+ text,
80
+ /\b(?:use|using|with|set|run(?:ning)?)\s+(?:the\s+)?(?:worker\s+)?model\s*(?:to|as|is|=|:)?\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi,
81
+ (token) => {
82
+ state.model = token;
83
+ },
84
+ );
85
+ captureTokens(text, /\bmodel\s*(?:=|:)\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi, (token) => {
86
+ state.model = token;
87
+ });
88
+
89
+ captureTokens(
90
+ text,
91
+ /\bworker\s+provider\s*(?:is|=|:|to|as)?\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi,
92
+ (token) => {
93
+ state.provider = token;
94
+ },
95
+ );
96
+ captureTokens(text, /\bprovider\s*(?:=|:)\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi, (token) => {
97
+ state.provider = token;
98
+ });
99
+ captureTokens(
100
+ text,
101
+ /\b(?:use|using|with|set)\s+(?:the\s+)?(?:worker\s+)?provider\s*(?:to|as|is|=|:)?\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi,
102
+ (token) => {
103
+ state.provider = token;
104
+ },
105
+ );
106
+
107
+ captureNumbers(
108
+ text,
109
+ /\b(?:max(?:imum)?\s+)?(?:worker\s+|task\s+)?attempts?\s*(?:per\s+task)?\s*(?:is|=|:|to|at)?\s*(\d+)/gi,
110
+ (value) => {
111
+ state.maxAttemptsPerTask = value;
112
+ },
113
+ );
114
+ captureNumbers(text, /\b(\d+)\s+(?:worker\s+|task\s+)?attempts?\b/gi, (value) => {
115
+ state.maxAttemptsPerTask = value;
116
+ });
117
+ captureNumbers(text, /\btry\s+(?:each\s+task\s+)?(?:up\s+to\s+)?(\d+)\s+times\b/gi, (value) => {
118
+ state.maxAttemptsPerTask = value;
119
+ });
120
+
121
+ captureDurations(
122
+ text,
123
+ /\b(?<!bash\s)(?<!max\s)(?:worker\s+|task\s+)?timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
124
+ (value) => {
125
+ state.taskTimeoutMs = value;
126
+ },
127
+ );
128
+ captureDurations(
129
+ text,
130
+ /\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))\s+(?:worker\s+|task\s+)?timeout\b/gi,
131
+ (value) => {
132
+ state.taskTimeoutMs = value;
133
+ },
134
+ );
135
+ captureDurations(
136
+ text,
137
+ /\b(?:max\s+)?bash\s+timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
138
+ (value) => {
139
+ state.maxBashTimeoutMs = value;
140
+ },
141
+ );
142
+ }
143
+
144
+ function applyDirective(
145
+ key: string,
146
+ value: string,
147
+ state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
148
+ ): void {
149
+ if (/\bprovider\b/.test(key)) {
150
+ const token = modelToken(value);
151
+ if (token) {
152
+ state.provider = token;
153
+ }
154
+ return;
155
+ }
156
+
157
+ if (/\bmodel\b/.test(key)) {
158
+ const token = modelToken(value);
159
+ if (token) {
160
+ state.model = token;
161
+ }
162
+ return;
163
+ }
164
+
165
+ if (/\b(?:attempt|attempts|retry|retries)\b/.test(key)) {
166
+ const attempts = positiveIntegerFromText(value);
167
+ if (attempts !== undefined) {
168
+ state.maxAttemptsPerTask = attempts;
169
+ }
170
+ return;
171
+ }
172
+
173
+ if (/\bbash\b/.test(key) && /\btimeout\b/.test(key)) {
174
+ const timeout = durationMsFromText(value, { allowBareSeconds: true });
175
+ if (timeout !== undefined) {
176
+ state.maxBashTimeoutMs = timeout;
177
+ }
178
+ return;
179
+ }
180
+
181
+ if (/\btimeout\b/.test(key)) {
182
+ const timeout = durationMsFromText(value, { allowBareSeconds: true });
183
+ if (timeout !== undefined) {
184
+ state.taskTimeoutMs = timeout;
185
+ }
186
+ }
187
+ }
188
+
189
+ function captureTokens(text: string, pattern: RegExp, apply: (token: string) => void): void {
190
+ for (const match of text.matchAll(pattern)) {
191
+ const token = modelToken(match[1] ?? "");
192
+ if (token) {
193
+ apply(token);
194
+ }
195
+ }
196
+ }
197
+
198
+ function captureNumbers(text: string, pattern: RegExp, apply: (value: number) => void): void {
199
+ for (const match of text.matchAll(pattern)) {
200
+ const value = positiveIntegerFromText(match[1] ?? "");
201
+ if (value !== undefined) {
202
+ apply(value);
203
+ }
204
+ }
205
+ }
206
+
207
+ function captureDurations(text: string, pattern: RegExp, apply: (value: number) => void): void {
208
+ for (const match of text.matchAll(pattern)) {
209
+ const value = durationMsFromText(match[1] ?? "", { allowBareSeconds: true });
210
+ if (value !== undefined) {
211
+ apply(value);
212
+ }
213
+ }
214
+ }
215
+
216
+ function combineProviderAndModel(provider: string | undefined, model: string | undefined): string | undefined {
217
+ if (!model) {
218
+ return undefined;
219
+ }
220
+ if (model.includes("/") || !provider) {
221
+ return model;
222
+ }
223
+ return `${provider}/${model}`;
224
+ }
225
+
226
+ function modelToken(value: string): string | undefined {
227
+ const trimmed = trimDirectiveValue(value);
228
+ const match = MODEL_TOKEN_RE.exec(trimmed);
229
+ if (!match) {
230
+ return undefined;
231
+ }
232
+ const token = match[0].replace(/[.,;:]+$/g, "");
233
+ return token && !STOP_WORDS.has(token.toLowerCase()) ? token : undefined;
234
+ }
235
+
236
+ function trimDirectiveValue(value: string): string {
237
+ return value
238
+ .trim()
239
+ .replace(/^['"`]+/, "")
240
+ .replace(/['"`]+$/, "")
241
+ .trim();
242
+ }
243
+
244
+ function positiveIntegerFromText(value: string): number | undefined {
245
+ const match = /\d+/.exec(value);
246
+ if (!match) {
247
+ return undefined;
248
+ }
249
+ const parsed = Number.parseInt(match[0], 10);
250
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
251
+ }
252
+
253
+ function durationMsFromText(value: string, options: { allowBareSeconds: boolean }): number | undefined {
254
+ const match = /(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?\b/i.exec(
255
+ value,
256
+ );
257
+ if (!match) {
258
+ return undefined;
259
+ }
260
+
261
+ const amount = Number.parseFloat(match[1] ?? "");
262
+ if (!Number.isFinite(amount) || amount <= 0) {
263
+ return undefined;
264
+ }
265
+
266
+ const unit = (match[2] ?? "").toLowerCase();
267
+ if (!unit && !options.allowBareSeconds) {
268
+ return undefined;
269
+ }
270
+
271
+ const multiplier = durationMultiplier(unit || "seconds");
272
+ const milliseconds = Math.round(amount * multiplier);
273
+ return Number.isSafeInteger(milliseconds) && milliseconds > 0 ? milliseconds : undefined;
274
+ }
275
+
276
+ function durationMultiplier(unit: string): number {
277
+ if (unit === "ms" || unit.startsWith("millisecond") || unit.startsWith("msec")) {
278
+ return 1;
279
+ }
280
+ if (unit === "h" || unit.startsWith("hour") || unit.startsWith("hr")) {
281
+ return 60 * 60 * 1000;
282
+ }
283
+ if (unit === "m" || unit.startsWith("minute") || unit.startsWith("min")) {
284
+ return 60 * 1000;
285
+ }
286
+ return 1000;
287
+ }
288
+
289
+ function normalizeWords(value: string): string {
290
+ return value.toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
291
+ }
@@ -186,7 +186,6 @@ export const extractAssistantTextFromEvent = assistantTextFromEvent;
186
186
  export const extractLastAssistantTextFromEvents = lastAssistantTextFromEvents;
187
187
 
188
188
  export const DEFAULT_WORKER_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
189
- export const DEFAULT_WORKER_MODEL = "openai-codex/gpt-5.5";
190
189
  export const DEFAULT_WORKER_THINKING_LEVEL = "high";
191
190
  export const DEFAULT_TASK_TIMEOUT_SECONDS = 60 * 60;
192
191
  export const DEFAULT_GRACEFUL_SHUTDOWN_SECONDS = 60;
@@ -309,7 +308,8 @@ export async function createIsolatedWorkerSession(
309
308
  const resourceLoader = disableExtensionsForWorker(discoveredResourceLoader, () => pi.createExtensionRuntime());
310
309
  await resourceLoader.reload();
311
310
 
312
- const model = options.model ?? (await resolveWorkerModel(modelRegistry, options.modelName ?? DEFAULT_WORKER_MODEL));
311
+ const model =
312
+ options.model ?? (options.modelName ? await resolveWorkerModel(modelRegistry, options.modelName) : undefined);
313
313
  const createOptions: Record<string, unknown> = {
314
314
  cwd,
315
315
  agentDir,