@mjasnikovs/pi-task 0.38.11 → 0.38.13

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.
Files changed (59) hide show
  1. package/README.md +8 -5
  2. package/dist/config/config.d.ts +0 -1
  3. package/dist/config/config.js +0 -1
  4. package/dist/config/register.js +0 -2
  5. package/dist/index.js +0 -2
  6. package/dist/shared/child-process.d.ts +8 -0
  7. package/dist/shared/command-watchdog.d.ts +1 -1
  8. package/dist/shared/command-watchdog.js +1 -1
  9. package/dist/task/accept-debt.d.ts +47 -0
  10. package/dist/task/accept-debt.js +127 -28
  11. package/dist/task/auto-orchestrator.js +91 -114
  12. package/dist/task/child-runner.d.ts +39 -25
  13. package/dist/task/child-runner.js +59 -31
  14. package/dist/task/child-status.d.ts +95 -0
  15. package/dist/task/child-status.js +99 -0
  16. package/dist/task/command-run.d.ts +36 -0
  17. package/dist/task/command-run.js +48 -1
  18. package/dist/task/command-watchdog.js +1 -1
  19. package/dist/task/context-usage.d.ts +4 -3
  20. package/dist/task/context-usage.js +4 -3
  21. package/dist/task/contracts.js +18 -35
  22. package/dist/task/deep-render-check.d.ts +47 -0
  23. package/dist/task/deep-render-check.js +110 -65
  24. package/dist/task/env-notes.d.ts +3 -3
  25. package/dist/task/env-notes.js +24 -35
  26. package/dist/task/final-gate-fix.d.ts +1 -1
  27. package/dist/task/final-gate-fix.js +1 -1
  28. package/dist/task/final-gate.d.ts +5 -151
  29. package/dist/task/final-gate.js +81 -379
  30. package/dist/task/gate-child.d.ts +8 -10
  31. package/dist/task/gate-child.js +15 -19
  32. package/dist/task/gate-deps.d.ts +29 -0
  33. package/dist/task/gate-deps.js +192 -206
  34. package/dist/task/gate-tally.d.ts +189 -0
  35. package/dist/task/gate-tally.js +249 -0
  36. package/dist/task/implementation-turn.d.ts +201 -0
  37. package/dist/task/implementation-turn.js +263 -0
  38. package/dist/task/launch-contract.js +27 -43
  39. package/dist/task/ledger.d.ts +38 -0
  40. package/dist/task/ledger.js +83 -0
  41. package/dist/task/loop-detector.d.ts +14 -8
  42. package/dist/task/loop-detector.js +36 -12
  43. package/dist/task/orchestrator.d.ts +61 -126
  44. package/dist/task/orchestrator.js +67 -294
  45. package/dist/task/plan-orchestrator.js +34 -33
  46. package/dist/task/requirements.d.ts +1 -1
  47. package/dist/task/requirements.js +50 -66
  48. package/dist/task/root-cause-repair.js +20 -32
  49. package/dist/task/run-bracket.d.ts +75 -0
  50. package/dist/task/run-bracket.js +41 -0
  51. package/dist/task/stall-detector.d.ts +110 -0
  52. package/dist/task/stall-detector.js +159 -0
  53. package/dist/task/verify-work.d.ts +53 -67
  54. package/dist/task/verify-work.js +15 -11
  55. package/dist/workers/single-read-extension.d.ts +1 -1
  56. package/dist/workers/single-read-extension.js +5 -4
  57. package/dist/workers/single-read-guard.d.ts +32 -10
  58. package/dist/workers/single-read-guard.js +67 -16
  59. package/package.json +1 -1
@@ -25,11 +25,10 @@ import { readTextFile } from '../shared/fs-text.js';
25
25
  import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
26
26
  import { startWidget } from './widget.js';
27
27
  import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
28
- import { publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand, getBridge, SessionUI } from '../remote/bridge.js';
28
+ import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
29
29
  import { pushNotify } from '../remote/push.js';
30
30
  import { getConfig } from '../config/config.js';
31
31
  import { gateDebugWriter } from './debug-log.js';
32
- import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
33
32
  import { buildGateDeps } from './gate-deps.js';
34
33
  import { runGatesForTask } from './task-gates.js';
35
34
  import { parseVerifyBlock } from './spec-validation.js';
@@ -37,11 +36,12 @@ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phanto
37
36
  import { titleForDisplay } from './parsers.js';
38
37
  import { USER_CANCELLED } from './child-runner.js';
39
38
  import { cancelCheckpoint } from './cancel-points.js';
40
- import { armCancelListener, disarmCancelListener, rearmCancelListener } from './cancel-input.js';
41
- import { beginRun, endRun, takeHeldInput } from './mid-run-input.js';
42
- import { reportDroppedInput } from './dropped-input.js';
39
+ import { rearmCancelListener } from './cancel-input.js';
40
+ import { takeHeldInput } from './mid-run-input.js';
41
+ import { withRun, announceTerminal } from './run-bracket.js';
43
42
  import { formatTimings } from './timings.js';
44
43
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
44
+ import { superviseImplementation } from './implementation-turn.js';
45
45
  import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
46
46
  // ─── Module-level state ──────────────────────────────────────────────────────
47
47
  let activeTask = null;
@@ -67,10 +67,7 @@ export class TaskRunner {
67
67
  _onStart;
68
68
  _planContext;
69
69
  _fixInstruction;
70
- /** True when the caller awaits the implementation turn (waitForImplementation):
71
- * the impl widget stays armed across the whole impl phase (incl. compaction /
72
- * steer turns) and is disarmed here. False for fire-and-forget /task, where the
73
- * widget is armed one-shot and its own agent_end disarms it. */
70
+ /** See {@link TaskRunnerOptions.implAwaited}. */
74
71
  _implAwaited;
75
72
  _abort = new AbortController();
76
73
  _startedAt;
@@ -87,16 +84,17 @@ export class TaskRunner {
87
84
  */
88
85
  _timings = [];
89
86
  _currentPhaseChildren = null;
90
- constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart, planContext, fixInstruction, implAwaited) {
87
+ constructor(opts) {
88
+ const { ctx, cwd, rawPrompt } = opts;
91
89
  this._ctx = ctx;
92
90
  this._cwd = cwd;
93
91
  this._rawPrompt = rawPrompt;
94
- this._resumeId = resumeId;
95
- this._sendSpec = sendSpec;
96
- this._onStart = onStart;
97
- this._planContext = planContext;
98
- this._fixInstruction = fixInstruction;
99
- this._implAwaited = implAwaited ?? false;
92
+ this._resumeId = opts.resumeId;
93
+ this._sendSpec = opts.sendSpec;
94
+ this._onStart = opts.onStart;
95
+ this._planContext = opts.planContext;
96
+ this._fixInstruction = opts.fixInstruction;
97
+ this._implAwaited = opts.implAwaited ?? false;
100
98
  this._startedAt = Date.now();
101
99
  // We'll populate id/title/phase lazily in run().
102
100
  // Placeholder — real values set in run().
@@ -111,7 +109,13 @@ export class TaskRunner {
111
109
  cwd,
112
110
  taskId: '',
113
111
  signal: this._abort.signal,
114
- spawn: spawnFn,
112
+ spawn: opts.spawnFn,
113
+ runChild: opts.runChild,
114
+ // Deliberately NOT a ChildStatus (child-status.ts): the phase widget's
115
+ // state is the whole-run WidgetState — task id, phase, label — shared by
116
+ // reference with PhaseContext and written by the phases themselves
117
+ // (grill sets `lastLine`, compose sets the title). Only these two
118
+ // callbacks overlap, and they call the same resolveContextUsage.
115
119
  onChildOutput: (line) => {
116
120
  this._widgetState.lastLine = line;
117
121
  },
@@ -151,14 +155,17 @@ export class TaskRunner {
151
155
  cancel() {
152
156
  this._abort.abort();
153
157
  }
154
- /** Execute the full task lifecycle. */
158
+ /** Execute the full task lifecycle. Mid-run input holds instead of starting
159
+ * a competing turn for the whole of it, and the terminal interception is
160
+ * armed for the same window (`withRun`); nested inside `runGatedTask` or
161
+ * the `/task-auto` loop the bracket refcounts, so this changes nothing there
162
+ * and covers the fire-and-forget `runSingleTask` path on its own. */
155
163
  async run() {
164
+ return withRun(this._ctx, {}, () => this._run());
165
+ }
166
+ async _run() {
156
167
  const cwd = this._cwd;
157
168
  const ctx = this._ctx;
158
- // Mid-run input holds instead of starting a competing turn from here on,
159
- // and the terminal interception is armed for the same window.
160
- beginRun();
161
- armCancelListener(ctx);
162
169
  // Initialise or resume the TASK file.
163
170
  let id;
164
171
  let title;
@@ -290,8 +297,6 @@ export class TaskRunner {
290
297
  finally {
291
298
  this._disposeWidget();
292
299
  clearActiveTask(this);
293
- disarmCancelListener();
294
- reportDroppedInput(endRun(), ctx);
295
300
  }
296
301
  }
297
302
  /** Stop the phase widget — clearing both the terminal and remote surfaces —
@@ -366,238 +371,6 @@ export class TaskRunner {
366
371
  return banners ? `${banners}\n\n${this._pc.spec}` : this._pc.spec;
367
372
  }
368
373
  }
369
- /** Dialog copy for the post-interrupt steering prompt. */
370
- const STEER_TITLE = 'Paused — steer the model';
371
- const STEER_PLACEHOLDER = 'Type guidance to continue this task, or leave empty to pause';
372
- /** Remote-card copy for the same prompt. The browser has no placeholder ghost
373
- * text, so the pause affordance must be spelled out in the question itself
374
- * (Skip = empty answer = pause, same as an empty local submit). */
375
- const STEER_QUESTION = 'Paused — the implementation was interrupted.\n'
376
- + 'Type guidance to continue this task, or Skip to pause the run.';
377
- /**
378
- * True when the most recent assistant turn ended because the user interrupted it
379
- * (pressed ESC). pi records a user abort as stopReason "aborted" on the assistant
380
- * message, distinct from a natural "stop". Read after the implementation wait so
381
- * the /task-auto loop can tell "user wants to steer" apart from "task finished".
382
- */
383
- function wasInterrupted(ctx) {
384
- const entries = ctx.sessionManager.getEntries();
385
- for (let i = entries.length - 1; i >= 0; i--) {
386
- const e = entries[i];
387
- if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
388
- return e.message.stopReason === 'aborted';
389
- }
390
- }
391
- return false;
392
- }
393
- /**
394
- * The error cause when the most recent assistant turn ended with stopReason
395
- * "error" — the model/provider failed mid-implementation (context overflow,
396
- * disconnect, provider 5xx) after pi exhausted its own retries. Returns the
397
- * provider's errorMessage, or undefined if the turn ended cleanly ("stop") or
398
- * was user-aborted ("aborted", handled by wasInterrupted).
399
- *
400
- * The /task-auto loop reads this AFTER the implementation wait: a task's file is
401
- * marked `completed` at spec-handoff (before implementation), so without this
402
- * check an implementation turn that died — e.g. "400 ... exceeds the available
403
- * context size" — would still read as ok and get checked off and committed.
404
- */
405
- function implementationError(ctx) {
406
- const entries = ctx.sessionManager.getEntries();
407
- for (let i = entries.length - 1; i >= 0; i--) {
408
- const e = entries[i];
409
- if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
410
- const m = e.message;
411
- return m.stopReason === 'error' ? (m.errorMessage ?? 'model error') : undefined;
412
- }
413
- }
414
- return undefined;
415
- }
416
- /**
417
- * True when the implementation turn went idle right after a context compaction —
418
- * the most recent entry in the branch is a `compaction` boundary sitting after the
419
- * last assistant message.
420
- *
421
- * A *threshold* auto-compaction (the runtime's "context is getting large" path)
422
- * compacts and then deliberately does NOT auto-continue: it returns to idle and
423
- * expects a manual continue (`_runAutoCompaction("threshold", false)` →
424
- * `hasQueuedMessages()` is false → the agent loop stops). Our implementation wait
425
- * resolves at exactly that idle. Without this check it reads as "the model
426
- * finished" (the last assistant message is a normal `stop`, not `aborted`/`error`),
427
- * so the run jumps straight to the verify gate and abandons a half-done task at the
428
- * compaction boundary — the failure this detector closes.
429
- *
430
- * Position-based, not timestamp-based: the runtime APPENDS the compaction entry to
431
- * the tail of the branch after the assistant message that triggered it
432
- * (`appendCompaction` → `_appendEntry` push), so a `compaction` after the last
433
- * assistant message means we are parked on a compaction with no continuation. A
434
- * genuinely finished turn ends on an assistant message with no trailing compaction;
435
- * an *overflow* compaction self-retries, so it never leaves us idle here.
436
- */
437
- export function endedAtCompactionBoundary(ctx) {
438
- const entries = ctx.sessionManager.getEntries();
439
- let lastAssistant = -1;
440
- let lastCompaction = -1;
441
- for (let i = 0; i < entries.length; i++) {
442
- const e = entries[i];
443
- if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
444
- lastAssistant = i;
445
- }
446
- else if (e.type === 'compaction') {
447
- lastCompaction = i;
448
- }
449
- }
450
- return lastCompaction > lastAssistant;
451
- }
452
- /**
453
- * Nudge that resumes an implementation turn the runtime parked at a compaction
454
- * boundary. It must let a turn that was genuinely finished (then tipped over the
455
- * threshold by its own final message) confirm completion without inventing busywork
456
- * — we cannot tell "paused mid-task by compaction" from "finished, then compacted"
457
- * from the boundary alone, so the wording lets a done turn end in one line.
458
- */
459
- export const CONTINUE_AFTER_COMPACTION = 'Your context was automatically compacted. Continue implementing this task from '
460
- + 'exactly where you left off, and keep going until it is fully done. If the '
461
- + 'implementation is already complete, say so in one line and stop — do not invent '
462
- + 'extra work or restart the task.';
463
- /**
464
- * Safety cap on compaction-driven resumes for a single implementation turn. Each
465
- * resume follows a real compaction (which only fires after the model produced a
466
- * turn large enough to cross the threshold), so a legitimately large task may
467
- * resume a handful of times; the cap exists only to stop a pathological loop from
468
- * auto-sending forever with no user in the loop. Hitting it stops resuming and lets
469
- * the verify gate / `/task-auto-resume` catch any leftover incompleteness.
470
- */
471
- export const MAX_COMPACTION_RESUMES = 20;
472
- /**
473
- * Resume an implementation turn that went idle at a threshold-compaction boundary.
474
- * The runtime compacts and parks at idle without auto-continuing; we send a
475
- * continue and wait again, repeating across successive compactions until the turn
476
- * ends on a real assistant message (genuine completion). A user ESC takes priority
477
- * (it is not a compaction boundary, and `wasInterrupted` guards the loop so the
478
- * steer loop handles it), and the safety cap bounds a runaway. Returns the number
479
- * of resumes performed (0 when the turn did not end on a compaction).
480
- */
481
- export async function resumeAcrossCompactions(ctx) {
482
- let resumes = 0;
483
- while (resumes < MAX_COMPACTION_RESUMES
484
- && !wasInterrupted(ctx)
485
- && endedAtCompactionBoundary(ctx)) {
486
- await ctx.sendUserMessage(CONTINUE_AFTER_COMPACTION, { deliverAs: 'followUp' });
487
- await ctx.waitForIdle();
488
- resumes++;
489
- }
490
- return resumes;
491
- }
492
- /**
493
- * True when the watchdog's reminder follow-up has been DELIVERED into the session
494
- * after the aborted assistant turn but its own turn has not finished yet — the
495
- * artifact that confirms a pending watchdog recovery. Scoped after the LAST
496
- * assistant entry so an earlier fire's reminder (already answered by its own
497
- * turn) never matches.
498
- */
499
- function watchdogReminderDelivered(ctx) {
500
- const entries = ctx.sessionManager.getEntries();
501
- let lastAssistant = -1;
502
- for (let i = 0; i < entries.length; i++) {
503
- const e = entries[i];
504
- if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
505
- lastAssistant = i;
506
- }
507
- }
508
- for (let i = lastAssistant + 1; i < entries.length; i++) {
509
- const e = entries[i];
510
- if (!('message' in e) || !('role' in e.message) || e.message.role !== 'user')
511
- continue;
512
- const content = e.message.content;
513
- const text = typeof content === 'string' ? content
514
- : Array.isArray(content) ?
515
- content
516
- .map(b => b !== null && typeof b === 'object' && 'text' in b ?
517
- String(b.text)
518
- : '')
519
- .join(' ')
520
- : '';
521
- if (text.includes(WATCHDOG_CANCEL_MARKER))
522
- return true;
523
- }
524
- return false;
525
- }
526
- const STEER_WATCHDOG_DEFAULTS = {
527
- consume: consumeWatchdogAbort,
528
- graceMs: 10_000,
529
- pollMs: 100
530
- };
531
- /**
532
- * Wait for a watchdog abort's queued follow-up turn instead of prompting. The
533
- * abort and the reminder follow-up are two separate steps in the watchdog's
534
- * onFire, so the steer loop can observe the aborted turn before the reminder is
535
- * delivered — poll (bounded) until it lands or the follow-up turn has already
536
- * completed. True = recovery observed, re-check the loop; false = grace expired
537
- * with no reminder (stale flag) — fall back to the human prompt.
538
- */
539
- async function awaitWatchdogFollowUp(ctx, wd) {
540
- const deadline = Date.now() + wd.graceMs;
541
- for (;;) {
542
- if (!wasInterrupted(ctx))
543
- return true; // follow-up turn already completed
544
- if (watchdogReminderDelivered(ctx)) {
545
- await ctx.waitForIdle(); // let the follow-up turn run to completion
546
- return true;
547
- }
548
- if (Date.now() >= deadline)
549
- return false;
550
- await new Promise(r => setTimeout(r, wd.pollMs));
551
- }
552
- }
553
- /**
554
- * After the implementation turn settles, honour a user ESC by letting them steer.
555
- *
556
- * `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
557
- * the turn → idle). When the last turn was aborted, the host's main input loop is
558
- * blocked inside our command handler, so a message typed in the editor would only
559
- * queue, never run (interactive-mode routes idle input through onInputCallback,
560
- * which is unset while we hold the loop). We therefore solicit the steering text
561
- * ourselves and feed it back as another turn via sendUserMessage — which runs to
562
- * completion when the session is idle. Repeat until a turn finishes uninterrupted.
563
- *
564
- * A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
565
- * from a human ESC by the session entries alone at that instant. The watchdog
566
- * queues its own recovery follow-up, so prompting there would show a steering
567
- * dialog to an empty room and wedge an unattended run on the race. The one-shot
568
- * flag (set synchronously before the abort) routes that case to
569
- * {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
570
- * followed by the ordinary prompt, never to a suppressed one.
571
- *
572
- * Returns true when the user declined to steer (empty/cancelled) and the run
573
- * should pause; false when the implementation completed (steered or not).
574
- */
575
- export async function steerUntilDone(ctx, promptSteer, watchdog) {
576
- const wd = { ...STEER_WATCHDOG_DEFAULTS, ...watchdog };
577
- // Fan the prompt out through the bridge (local TUI input + remote browser
578
- // card, first answer wins) instead of a raw ctx.ui.input: an interrupt can
579
- // come from the remote Stop button just as well as a terminal ESC, and a
580
- // terminal-only dialog leaves the remote viewer staring at a silently
581
- // paused run. Remote Skip returns '' → same pause path as an empty local
582
- // submit.
583
- const ask = promptSteer
584
- ?? (c => new SessionUI(c).ask({
585
- localTitle: STEER_TITLE,
586
- localPlaceholder: STEER_PLACEHOLDER,
587
- question: STEER_QUESTION,
588
- allowSkip: true
589
- }));
590
- while (wasInterrupted(ctx)) {
591
- if (wd.consume() && (await awaitWatchdogFollowUp(ctx, wd)))
592
- continue;
593
- const steer = await ask(ctx);
594
- if (steer === undefined || steer.trim().length === 0)
595
- return true; // pause
596
- await ctx.sendUserMessage(steer, { deliverAs: 'followUp' });
597
- await ctx.waitForIdle();
598
- }
599
- return false;
600
- }
601
374
  /**
602
375
  * Run one prompt through the full single-task pipeline in a fresh session and
603
376
  * deliver its spec. With waitForImplementation, block until the agent finishes
@@ -625,26 +398,35 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
625
398
  // happens at the START of each task — precisely the window a typed
626
399
  // /task-auto-cancel has to survive. No-op unless a run armed one.
627
400
  rearmCancelListener(newCtx);
628
- const runner = new TaskRunner(newCtx, cwd, rawPrompt, opts.resumeId, async (spec) => {
629
- // Queue-or-run: never throws, whatever else is on the session (issue #8).
630
- await newCtx.sendUserMessage(spec, { deliverAs: 'followUp' });
631
- if (opts.waitForImplementation) {
632
- await newCtx.waitForIdle();
633
- // A threshold auto-compaction parks the turn at idle WITHOUT
634
- // auto-continuing (the runtime expects a manual continue). Our
635
- // single waitForIdle resolves there, so without this the run
636
- // would treat a compaction mid-task as completion and jump
637
- // straight to the verify gate. Resume across any compaction
638
- // boundaries first, so steering/error inspection below see the
639
- // turn's REAL end, not a compaction pause.
640
- await resumeAcrossCompactions(newCtx);
641
- interrupted = await steerUntilDone(newCtx, opts.promptSteer);
642
- // A user-declined steer (interrupted) is its own paused
643
- // path; otherwise inspect how the turn actually ended.
644
- if (!interrupted)
645
- implError = implementationError(newCtx);
646
- }
647
- }, opts.spawnFn, opts.onStart, opts.planContext, opts.fixInstruction, opts.waitForImplementation);
401
+ const runner = new TaskRunner({
402
+ ctx: newCtx,
403
+ cwd,
404
+ rawPrompt,
405
+ resumeId: opts.resumeId,
406
+ sendSpec: async (spec) => {
407
+ // Queue-or-run: never throws, whatever else is on the session (issue #8).
408
+ await newCtx.sendUserMessage(spec, { deliverAs: 'followUp' });
409
+ if (opts.waitForImplementation) {
410
+ await newCtx.waitForIdle();
411
+ // A threshold auto-compaction parks the turn at idle WITHOUT
412
+ // auto-continuing, and a user ESC ends it "aborted": the
413
+ // first idle is not the turn's real end. superviseImplementation
414
+ // resumes across compactions, steers across interrupts, and
415
+ // reads how the turn ACTUALLY ended.
416
+ const outcome = await superviseImplementation(newCtx, {
417
+ promptSteer: opts.promptSteer
418
+ });
419
+ interrupted = outcome.interrupted;
420
+ implError = outcome.error;
421
+ }
422
+ },
423
+ spawnFn: opts.spawnFn,
424
+ runChild: opts.runChild,
425
+ onStart: opts.onStart,
426
+ planContext: opts.planContext,
427
+ fixInstruction: opts.fixInstruction,
428
+ implAwaited: opts.waitForImplementation
429
+ });
648
430
  await runner.run();
649
431
  taskId = runner.taskId;
650
432
  }
@@ -726,19 +508,13 @@ export async function runGatedTask(ctx, cwd, raw, opts = {}) {
726
508
  // TaskRunner would leave verify/enforce looking like "no run", which a live
727
509
  // run on pi 0.82.1 showed as runActive=false while the widget still read
728
510
  // "verifying work" (issue #8). The body has many early returns, so the
729
- // bracket lives in this wrapper rather than in a dozen places.
730
- beginRun();
731
- // Arm the raw-stdin interception for the WHOLE run. Without this a plain
732
- // /task had no terminal path at all only /task-auto armed one so a line
733
- // typed during it went into pi's queue and fired after the run (seen live).
734
- armCancelListener(ctx);
735
- try {
736
- await runGatedTaskInner(ctx, cwd, raw, opts);
737
- }
738
- finally {
739
- disarmCancelListener();
740
- reportDroppedInput(endRun(), ctx);
741
- }
511
+ // bracket lives in this wrapper rather than in a dozen places. The same
512
+ // bracket arms the raw-stdin interception for the WHOLE run: without it a
513
+ // plain /task had no terminal path at all only /task-auto armed one — so a
514
+ // line typed during it went into pi's queue and fired after the run (seen
515
+ // live). No onCancel: a typed /task-auto-cancel goes through the generic
516
+ // bridge dispatch here.
517
+ await withRun(ctx, {}, () => runGatedTaskInner(ctx, cwd, raw, opts));
742
518
  }
743
519
  async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
744
520
  const abort = new AbortController();
@@ -751,11 +527,8 @@ async function runGatedTaskInner(ctx, cwd, raw, opts = {}) {
751
527
  let active = ctx;
752
528
  // One push + remote bubble on the terminal outcome (parity with the
753
529
  // notifyFinish push the fire-and-forget path emits via runSingleTask).
754
- const announce = (msg, level) => {
755
- active.ui.notify(msg, level);
756
- publishLifecycleNotice(msg, level);
757
- void pushNotify('Task finished', msg, 'pi-end').catch(() => { });
758
- };
530
+ // Bound late to `active`: a gate autofix can replace the live session.
531
+ const announce = (msg, level) => announceTerminal(active, msg, level);
759
532
  // First implementation run (blocking).
760
533
  const res = await deps.runTask(active, cwd, raw, { resumeId: opts.resumeId });
761
534
  active = res.ctx ?? active;
@@ -20,7 +20,7 @@
20
20
  * /task receives that a bare /task would not is the decisions block.
21
21
  */
22
22
  import * as path from 'node:path';
23
- import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
23
+ import { prependHint, USER_CANCELLED } from './child-runner.js';
24
24
  import { PLAN_QUESTION_PROMPT, PLAN_ANSWER_PROMPT } from './plan-prompts.js';
25
25
  import { runPlanSession, ASK_TITLE } from './plan-session.js';
26
26
  import { allocatePlanId, buildPlanBody, buildHandoffPrompt, formatPlanDecisions } from './plan-io.js';
@@ -32,12 +32,13 @@ import { deriveTitle } from './parsers.js';
32
32
  import { renderInlineMarkdown } from './inline-markdown.js';
33
33
  import { expandFeatureMentions } from './auto-orchestrator.js';
34
34
  import { runSingleTask, runGatedTask } from './orchestrator.js';
35
- import { startAutoLoader } from './widget.js';
36
- import { SessionUI, publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand } from '../remote/bridge.js';
35
+ import { SessionUI, publishViewer, publishLifecycleNotice, registerBridgeCommand } from '../remote/bridge.js';
36
+ import { withRun, announceTerminal } from './run-bracket.js';
37
37
  import { getConfig } from '../config/config.js';
38
38
  import { isYoloMode } from './yolo.js';
39
39
  import { gateDebugWriter } from './debug-log.js';
40
- import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
40
+ import { getParentContextWindow } from './context-usage.js';
41
+ import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
41
42
  import * as fsp from 'node:fs/promises';
42
43
  /** Loader labels for the two planning children. */
43
44
  const PLAN_STEPS = {
@@ -52,10 +53,8 @@ const PLAN_STEPS = {
52
53
  export function buildPlanDeps(ctx, cwd, planId, task, signal) {
53
54
  const ui = new SessionUI(ctx);
54
55
  const theme = ctx.ui.theme;
55
- let lastLine;
56
56
  let status;
57
- let contextUsage;
58
- const parentContextWindow = getParentContextWindow(ctx);
57
+ const childStatus = new ChildStatus({ parentContextWindow: getParentContextWindow(ctx) });
59
58
  const title = deriveTitle(task);
60
59
  const logDebug = gateDebugWriter((msg) => {
61
60
  const line = `${new Date().toISOString()} ${msg}\n`;
@@ -65,12 +64,7 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
65
64
  cwd,
66
65
  taskId: planId,
67
66
  signal,
68
- onChildOutput: line => {
69
- lastLine = line;
70
- },
71
- onContextUsage: snapshot => {
72
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
73
- },
67
+ ...statusCallbacks(childStatus),
74
68
  ...(logDebug && { logDebug })
75
69
  };
76
70
  /**
@@ -82,25 +76,23 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
82
76
  * the plan file itself is written.
83
77
  */
84
78
  const child = async (name, prompt) => {
85
- lastLine = undefined;
86
- contextUsage = undefined;
87
- const startedAt = Date.now();
88
- const stopLoader = startAutoLoader(ctx, () => ({
89
- command: '/task-plan',
90
- title,
91
- step: status ?? PLAN_STEPS[name] ?? name,
92
- stepNum: 1,
93
- stepTotal: 1,
94
- startedAt,
95
- lastLine,
96
- contextUsage
97
- }));
98
79
  const before = await collectTreeChanges(cwd, signal).catch(() => null);
99
80
  try {
100
- return await runPhaseChild(phaseDeps, name, PLAN_TOOLS, prompt);
81
+ return await runPlanningChild({
82
+ ctx,
83
+ status: childStatus,
84
+ phaseDeps,
85
+ name,
86
+ tools: PLAN_TOOLS,
87
+ prompt,
88
+ loader: {
89
+ command: '/task-plan',
90
+ title,
91
+ step: n => ({ step: status ?? PLAN_STEPS[n] ?? n, stepNum: 1, stepTotal: 1 })
92
+ }
93
+ });
101
94
  }
102
95
  finally {
103
- stopLoader();
104
96
  // Outside a git repo `before` is null and there is nothing to compare
105
97
  // against — the same degrade every other tree-reading guard here takes.
106
98
  if (before) {
@@ -264,6 +256,16 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
264
256
  title: deriveTitle(raw)
265
257
  };
266
258
  await writeTaskFile(cwd, fm, buildPlanBody(task));
259
+ // The plan session owns the session the way a run does: its children run
260
+ // with the host idle, so an unbracketed line typed then went into pi's queue
261
+ // (terminal) or started a competing turn (browser) — the issue #8 shape.
262
+ // /task-auto brackets its planning for the same reason; this one was left
263
+ // out when /task-plan landed. Held lines reach the handed-off /task run's
264
+ // first turn (the bracket nests), or are reported when the plan ends without
265
+ // one. No onCancel: /task-auto-cancel is not this command's to acknowledge.
266
+ await withRun(ctx, {}, () => runPlanCommand(ctx, cwd, planId, task, commandDeps));
267
+ }
268
+ async function runPlanCommand(ctx, cwd, planId, task, commandDeps) {
267
269
  const abort = new AbortController();
268
270
  let outcome;
269
271
  try {
@@ -275,9 +277,9 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
275
277
  state: msg === USER_CANCELLED ? 'cancelled' : 'failed',
276
278
  reason: msg.slice(0, 200)
277
279
  }).catch(() => { });
278
- const line = `${planId} stopped ${msg.slice(0, 160)}`;
279
- ctx.ui.notify(line, 'error');
280
- publishLifecycleNotice(line, 'error');
280
+ // No push: a plan is a conversation, not a task; the run it hands off
281
+ // to pushes its own ending.
282
+ announceTerminal(ctx, `${planId} stopped — ${msg.slice(0, 160)}`, 'error', { push: false });
281
283
  return;
282
284
  }
283
285
  if (outcome.kind === 'cancelled') {
@@ -289,8 +291,7 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
289
291
  const line = outcome.entries.length === 0 ?
290
292
  `${planId} cancelled — nothing planned.`
291
293
  : `${planId} cancelled — ${outcome.entries.length} entr${outcome.entries.length === 1 ? 'y' : 'ies'} kept in .pi-tasks/${planId}.md`;
292
- ctx.ui.notify(line, 'warning');
293
- publishNotify(line, 'warning');
294
+ announceTerminal(ctx, line, 'warning', { push: false });
294
295
  return;
295
296
  }
296
297
  const prompt = buildHandoffPrompt(task, outcome.entries);
@@ -147,7 +147,7 @@ export declare function ownedRequirementsFile(cwd: string): string;
147
147
  /** Persist the task-mapped requirements (host-side, plan time). Overwrites —
148
148
  * the mapping is recomputed whole per plan round. Best-effort like the carried
149
149
  * artifact. */
150
- export declare function writeOwnedRequirements(cwd: string, owned: OwnedRequirement[]): Promise<void>;
150
+ export declare function writeOwnedRequirements(cwd: string, entries: OwnedRequirement[]): Promise<void>;
151
151
  export declare function readOwnedRequirements(cwd: string): Promise<OwnedRequirement[]>;
152
152
  export declare function parseOwnedRequirements(text: string): OwnedRequirement[];
153
153
  /** The owned entries whose plan title matches THIS task's title (normalised