@mjasnikovs/pi-task 0.39.2 → 0.39.4

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.
@@ -1,7 +1,7 @@
1
1
  import { AutofixLedger } from './autofix-ledger.js';
2
2
  import { describeDebt, recordDebt } from './accept-debt.js';
3
3
  import { cancelCheckpoint } from './cancel-points.js';
4
- import { SessionUI } from '../remote/bridge.js';
4
+ import { SessionUI, notifyBoth, notifyRun } from '../remote/bridge.js';
5
5
  import { isYoloMode, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
6
6
  import { ignoredWriteTrailLine, ignoredWriteDebtReason } from './write-guard.js';
7
7
  import { readOwnedRequirements } from './requirements.js';
@@ -76,7 +76,7 @@ export async function runFinalGateStage(active, deps, p) {
76
76
  // Set when the gate finished having observed NOTHING dynamic. Declared out here so
77
77
  // the run-completion announcement can say so.
78
78
  let unobservedNote = null;
79
- active.ui.notify(`${id}: running final integration gate…`, 'info');
79
+ notifyBoth(active, `${id}: running final integration gate…`, 'info');
80
80
  // Run-level gate trail on the parent task file — same durable auditability
81
81
  // contract as the per-task `## gates` records.
82
82
  const recGate = async (line) => {
@@ -111,7 +111,7 @@ export async function runFinalGateStage(active, deps, p) {
111
111
  unobservedNote = fin.unobserved;
112
112
  await recGate(`final-gate: UNOBSERVED — ${fin.reason.slice(0, 300)}`);
113
113
  await carryDebt(fin.unobserved);
114
- active.ui.notify(`${id}: the final integration gate observed NOTHING dynamic — `
114
+ notifyRun(active, `${id}: the final integration gate observed NOTHING dynamic — `
115
115
  + 'the run completed on static checks alone. Nothing verified '
116
116
  + 'that the assembled product builds, boots or works.', 'warning');
117
117
  }
@@ -132,7 +132,7 @@ export async function runFinalGateStage(active, deps, p) {
132
132
  for (const d of debts) {
133
133
  await recGate(`defect STILL OPEN — ${d.taskId || '(unknown task)'}: ${describeDebt(d)}: ${d.reason.slice(0, 240)}${d.conflict ? ` [CONFLICTING CLAIM — ${d.conflict}]` : ''}`);
134
134
  }
135
- active.ui.notify(`${id}: ${debts.length} recorded verify-FAIL defect(s) are STILL unresolved at run end — see the gate trail.`, 'warning');
135
+ notifyRun(active, `${id}: ${debts.length} recorded verify-FAIL defect(s) are STILL unresolved at run end — see the gate trail.`, 'warning');
136
136
  };
137
137
  // What was REPORTED, so a post-autofix re-derivation can be compared against it
138
138
  // rather than blindly re-printed.
@@ -149,7 +149,7 @@ export async function runFinalGateStage(active, deps, p) {
149
149
  + ` ${(o.pending ?? []).join(', ')}]`);
150
150
  }
151
151
  if (unclaimed.length > 0) {
152
- active.ui.notify(`${id}: ${unclaimed.length} authoritative design requirement(s) ended the run`
152
+ notifyRun(active, `${id}: ${unclaimed.length} authoritative design requirement(s) ended the run`
153
153
  + ' owned by NO task — the task they were mapped to could not touch the'
154
154
  + ' file, and nothing else claimed it. See the gate trail.', 'warning');
155
155
  }
@@ -315,7 +315,7 @@ export async function runFinalGateStage(active, deps, p) {
315
315
  // commit — the ACCEPT is a decision about the FAILING gate, never an
316
316
  // instruction to throw away work.
317
317
  await commitStranded('accepted');
318
- active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`
318
+ notifyRun(active, `${id}: final integration gate FAIL accepted by user — completing.`
319
319
  + (ledger.stranded().length > 0 ?
320
320
  ` ${ledger.stranded().length} uncommitted fix-pass change(s) committed separately.`
321
321
  : ''), 'warning');
@@ -324,7 +324,7 @@ export async function runFinalGateStage(active, deps, p) {
324
324
  if (choice.action === 'autofix' && canAutofix) {
325
325
  const attempt = ledger.attempt();
326
326
  await recGate(`final-gate: user chose AUTOFIX (attempt ${attempt}/${MAX_FINAL_GATE_AUTOFIX})`);
327
- active.ui.notify(`${id}: final-gate autofix (${attempt}/${MAX_FINAL_GATE_AUTOFIX}) — bounded fix pass, then the gate re-runs…`, 'info');
327
+ notifyRun(active, `${id}: final-gate autofix (${attempt}/${MAX_FINAL_GATE_AUTOFIX}) — bounded fix pass, then the gate re-runs…`, 'info');
328
328
  const seed = choice.guidance ? `${fin.reason}\n\nUser guidance: ${choice.guidance}` : fin.reason;
329
329
  const fix = await deps.finalGateFix(active, cwd, seed, [...ledger.ignoredWrites()]);
330
330
  // IGNORED-PATH WRITES. The pass wrote file(s) git ignores, so
@@ -355,7 +355,7 @@ export async function runFinalGateStage(active, deps, p) {
355
355
  await carryDebt(fix.unobserved);
356
356
  }
357
357
  await recGate(`final-gate: autofix ${fix.unobserved ? 'ended UNOBSERVED' : 'converged'} — ${fix.reason.slice(0, 200)}`);
358
- active.ui.notify(`${id}: final integration gate ${fix.unobserved ? 'is UNOBSERVED' : 'PASSES'} after autofix — ${fix.reason.slice(0, 140)}`, fix.unobserved ? 'warning' : 'info');
358
+ notifyRun(active, `${id}: final integration gate ${fix.unobserved ? 'is UNOBSERVED' : 'PASSES'} after autofix — ${fix.reason.slice(0, 140)}`, fix.unobserved ? 'warning' : 'info');
359
359
  // The gate's own outcome, WHOLE, with this door's reason on it.
360
360
  // Rebuilding it as a two-key literal would drop `openDebts` and
361
361
  // `observedFailures` from the value entirely.
@@ -380,7 +380,7 @@ export async function runFinalGateStage(active, deps, p) {
380
380
  await recGate(`final-gate: autofix attempt ${attempt} left ${ledger.stranded().length} `
381
381
  + `uncommitted change(s) — ${ledger.stranded().slice(0, 8).join(', ')}`);
382
382
  }
383
- active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
383
+ notifyRun(active, `${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
384
384
  // NON-PROGRESS CLASSIFIER. An attempt that changed the tree, re-ran the
385
385
  // gate, and got back the SAME ranked-first failure as the previous such
386
386
  // attempt is evidence about the CHECK, not about the fix — a check that
@@ -396,7 +396,7 @@ export async function runFinalGateStage(active, deps, p) {
396
396
  await recGate(`final-gate: check DEMOTED to UNOBSERVED after ${attempt} tree-changing `
397
397
  + `attempts returned an identical failure — carried as debt (origin final-gate) `
398
398
  + `and re-checked by the next run's gate: ${verdict.detail.slice(0, 240)}`);
399
- active.ui.notify(`${id}: final-gate check is unfalsifiable in this environment — carried as debt; `
399
+ notifyRun(active, `${id}: final-gate check is unfalsifiable in this environment — carried as debt; `
400
400
  + 'the remaining checks decide convergence.', 'warning');
401
401
  }
402
402
  // Convergence on the REMAINING checks: a demoted signature no longer counts
@@ -409,7 +409,7 @@ export async function runFinalGateStage(active, deps, p) {
409
409
  const converged = `converged on all remaining checks; ${ledger.demotedCount()} check(s) `
410
410
  + 'carried as UNOBSERVED debt (unfalsifiable in this environment)';
411
411
  await recGate(`final-gate: ${converged}`);
412
- active.ui.notify(`${id}: final integration gate converged — ${converged}.`, 'warning');
412
+ notifyRun(active, `${id}: final integration gate converged — ${converged}.`, 'warning');
413
413
  // The gate's own outcome, with this door's reason on it — the
414
414
  // whole value, so `openDebts` survives the assignment.
415
415
  fin = { ...fix.gate, ok: true, reason: converged };
@@ -1,6 +1,6 @@
1
1
  import { verifyFailClass } from './verify-work.js';
2
2
  import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
3
- import { SessionUI } from '../remote/bridge.js';
3
+ import { SessionUI, notifyBoth, notifyRun } from '../remote/bridge.js';
4
4
  import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
5
5
  import { extractFailingCommand, findRepairCandidate, summariseDefect } from './root-cause-repair.js';
6
6
  import { attributeEnforceFailure } from './enforce-attribution.js';
@@ -92,7 +92,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
92
92
  // accept-override leaves this false → enforce runs flag-only.
93
93
  let verifyCleanPass = false;
94
94
  if (deps.verify) {
95
- active.ui.notify(`${p.tag}: verifying "${p.title}"…`, 'info');
95
+ notifyBoth(active, `${p.tag}: verifying "${p.title}"…`, 'info');
96
96
  let verified = await deps.verify(active, p.cwd, p.title, p.taskId);
97
97
  await rec(verdictLine(verified));
98
98
  // A FAIL no longer dead-stops. When the research recommends AUTOFIX, pi
@@ -122,7 +122,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
122
122
  const failClass = verifyFailClass(verified);
123
123
  if (!lintFixAttempted && deps.lintFix && failClass === 'repo-health') {
124
124
  lintFixAttempted = true;
125
- active.ui.notify(`${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
125
+ notifyRun(active, `${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
126
126
  const fix = await deps.lintFix(active, p.cwd, p.title, p.taskId, failReason);
127
127
  await rec(`lint-fix: ${fix.ok ?
128
128
  `applied${fix.reason ? ` (${fix.reason})` : ''} — re-verifying`
@@ -239,7 +239,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
239
239
  `resolution: auto-AUTOFIX (${YOLO_STAMP} rescue — judge recommended ACCEPT with the unattended `
240
240
  + `budget unspent; one attempt, ${autoFixCount}/${MAX_AUTO_AUTOFIX})`
241
241
  : `resolution: auto-AUTOFIX (recommended, unattended ${autoFixCount}/${MAX_AUTO_AUTOFIX})`);
242
- active.ui.notify(`${p.tag}: verify FAIL on "${p.title}" — auto-fixing (${yoloRescueNow ? `${YOLO_STAMP} one attempt before accepting` : 'recommended'}, ${autoFixCount}/${MAX_AUTO_AUTOFIX})…`, 'info');
242
+ notifyRun(active, `${p.tag}: verify FAIL on "${p.title}" — auto-fixing (${yoloRescueNow ? `${YOLO_STAMP} one attempt before accepting` : 'recommended'}, ${autoFixCount}/${MAX_AUTO_AUTOFIX})…`, 'info');
243
243
  choice = { action: 'autofix' };
244
244
  }
245
245
  else {
@@ -286,7 +286,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
286
286
  // recording must never break the gate sequence
287
287
  }
288
288
  }
289
- active.ui.notify(`${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.${byYolo ? ` ${YOLO_STAMP}` : ''}`, 'warning');
289
+ notifyRun(active, `${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.${byYolo ? ` ${YOLO_STAMP}` : ''}`, 'warning');
290
290
  break;
291
291
  }
292
292
  // AUTOFIX: re-run the implementation turn with the failure (and any typed
@@ -302,7 +302,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
302
302
  if (!autoFixNow && !yoloRescueNow) {
303
303
  await rec('resolution: user chose AUTOFIX — re-running the implementation turn');
304
304
  }
305
- active.ui.notify(`${p.tag}: autofixing "${p.title}"…`, 'info');
305
+ notifyBoth(active, `${p.tag}: autofixing "${p.title}"…`, 'info');
306
306
  const diagnosis = (recOutcome.recommend === 'autofix'
307
307
  && recOutcome.rationale.length > 0
308
308
  && recOutcome.rationale !== failReason) ?
@@ -396,7 +396,7 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
396
396
  const reverted = await deps.revertFrozenPaths(p.cwd, frozen);
397
397
  if (reverted.length > 0) {
398
398
  await rec(`enforce: frozen-path write DENIED — reverted ${reverted.length} spec-frozen file(s) the edit pass modified: ${reverted.join(', ')}`);
399
- active.ui.notify(`${p.tag}: guideline edits on "${p.title}" touched spec-frozen path(s) (${reverted.join(', ').slice(0, 120)}) — reverted before commit.`, 'warning');
399
+ notifyRun(active, `${p.tag}: guideline edits on "${p.title}" touched spec-frozen path(s) (${reverted.join(', ').slice(0, 120)}) — reverted before commit.`, 'warning');
400
400
  }
401
401
  }
402
402
  }
@@ -408,7 +408,7 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
408
408
  const editsMade = mode === 'edit' && deps.dirty ? await deps.dirty(p.cwd) : undefined;
409
409
  await rec(`enforce(${mode}): ${verdict.ok ? `clean${verdict.reason ? ` (${verdict.reason})` : ''}` : (verdict.reason ?? 'not clean')}${editsMade ? ' — edits in tree' : ''}`);
410
410
  if (!verdict.ok) {
411
- active.ui.notify(`${p.tag}: guideline ${mode === 'edit' ? 'enforcement' : 'review'} on "${p.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
411
+ notifyRun(active, `${p.tag}: guideline ${mode === 'edit' ? 'enforcement' : 'review'} on "${p.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
412
412
  }
413
413
  // PRE-COMMIT health gate on enforce edits: an edit pass that breaks the
414
414
  // repo's own lint would otherwise burn a commit, a model re-verify and a
@@ -443,7 +443,7 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
443
443
  else {
444
444
  await rec(`enforce: edits REGRESSED repo health pre-commit (${after.reason}) — no discard available, left uncommitted${outputTail}`);
445
445
  }
446
- active.ui.notify(`${p.tag}: guideline edits on "${p.title}" regressed repo health (${after.reason.slice(0, 120)}) — discarded before commit.`, 'warning');
446
+ notifyRun(active, `${p.tag}: guideline edits on "${p.title}" regressed repo health (${after.reason.slice(0, 120)}) — discarded before commit.`, 'warning');
447
447
  }
448
448
  else if (!after.ok) {
449
449
  // Failing both before and after → not enforce's fault. Keep the edits;
@@ -512,7 +512,7 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
512
512
  if (!after.ok && rootCause) {
513
513
  await rec(`enforce: re-verify FAILED (${afterReason.slice(0, 200)}) but the failure is attributed to a PRE-EXISTING defect in \`${rootCause.file}\` `
514
514
  + `(${rootCause.owner}'s file, untouched by the ENFORCE COMMIT whose fate this differential decides) — edits KEPT, not reverted; repair task queued`);
515
- active.ui.notify(`${p.tag}: guideline fixes on "${p.title}" re-verified red on a pre-existing defect in ${rootCause.file} (${rootCause.owner}'s file) — keeping the fixes, queued a repair task.`, 'warning');
515
+ notifyRun(active, `${p.tag}: guideline fixes on "${p.title}" re-verified red on a pre-existing defect in ${rootCause.file} (${rootCause.owner}'s file) — keeping the fixes, queued a repair task.`, 'warning');
516
516
  }
517
517
  else if (!after.ok && attribution?.verdict === 'keep') {
518
518
  // KEEP, mechanically justified: every file the failing check named
@@ -547,7 +547,7 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
547
547
  // queueing a repair must never break the gate sequence
548
548
  }
549
549
  }
550
- active.ui.notify(`${p.tag}: guideline fixes on "${p.title}" re-verified red on ${attribution.file ?? 'a file'} — outside the enforce diff, so keeping the fixes and recording the defect.`, 'warning');
550
+ notifyRun(active, `${p.tag}: guideline fixes on "${p.title}" re-verified red on ${attribution.file ?? 'a file'} — outside the enforce diff, so keeping the fixes and recording the defect.`, 'warning');
551
551
  }
552
552
  else if (!after.ok) {
553
553
  if (deps.revert)
@@ -567,11 +567,11 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
567
567
  // gate re-checks and surfaces it (static-class auto-closes if a
568
568
  // later task fixed the statics; otherwise it stays open).
569
569
  await deps.recordDebt?.(p.cwd, p.taskId, after.reason ?? 'enforce re-verify failed', 'enforce-revert');
570
- active.ui.notify(`${p.tag}: guideline fixes regressed verification on "${p.title}" (${(after.reason ?? 'now fails').slice(0, 120)}) — ${deps.revert ? 'reverted them, kept the verified work' : 'left in place (no revert available)'}.`, 'warning');
570
+ notifyRun(active, `${p.tag}: guideline fixes regressed verification on "${p.title}" (${(after.reason ?? 'now fails').slice(0, 120)}) — ${deps.revert ? 'reverted them, kept the verified work' : 'left in place (no revert available)'}.`, 'warning');
571
571
  }
572
572
  else {
573
573
  await rec('enforce: fixes committed — re-verify PASS, kept');
574
- active.ui.notify(`${p.tag}: committed guideline fixes for "${p.title}".`, 'info');
574
+ notifyRun(active, `${p.tag}: committed guideline fixes for "${p.title}".`, 'info');
575
575
  }
576
576
  }
577
577
  else {
@@ -651,7 +651,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
651
651
  + `${commit.excluded.slice(0, 8).join(', ')}`
652
652
  + `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
653
653
  }
654
- active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
654
+ notifyBoth(active, `${p.tag}: committed "${p.title}".`, 'info');
655
655
  }
656
656
  else {
657
657
  await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
@@ -661,9 +661,15 @@ export async function runGatesForTask(ctxIn, deps, p) {
661
661
  // warnings to show for it. "blocked" is the unmerged-index refusal
662
662
  // (gitCommitAll) — the same severity: nothing can commit until it's resolved.
663
663
  const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
664
- active.ui.notify(gitFailure ?
664
+ const line = gitFailure ?
665
665
  `${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
666
- : `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
666
+ : `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`;
667
+ // A git failure means the whole task ran unguarded. A remote user cannot
668
+ // infer that from the widget, and a toast is gone before they look.
669
+ if (gitFailure)
670
+ notifyRun(active, line, 'error');
671
+ else
672
+ notifyBoth(active, line, 'warning');
667
673
  }
668
674
  await runEnforcePass(active, deps, p, rec, routeRootCause, { cleanPass, commit });
669
675
  return { kind: 'done', ctx: active };
@@ -115,8 +115,6 @@ export function buildWidgetData(s) {
115
115
  }
116
116
  // ─── Widget lifecycle ────────────────────────────────────────────────────────
117
117
  export function startWidget(ctx, getState) {
118
- if (!ctx.hasUI)
119
- return () => { };
120
118
  // pi's `ctx.ui` getter calls `runner.assertActive()`, which THROWS once the
121
119
  // ctx goes stale (/reload, session replacement) — so the theme read belongs
122
120
  // INSIDE the guard, not one line above it. render() runs from a timer, and a
@@ -128,7 +126,12 @@ export function startWidget(ctx, getState) {
128
126
  if (stale)
129
127
  return;
130
128
  const s = getState();
131
- const plain = s ? buildWidgetLines(s, undefined) : undefined; // un-themed for the wire; needs no ctx
129
+ // The wire half FIRST, and unguarded: it needs no ctx, so gating it on a
130
+ // local terminal that may not exist would leave a headless host's browser
131
+ // with no phase, no progress and no failure flash.
132
+ setTaskWidget(s ? buildWidgetLines(s, undefined) : undefined, s ? buildWidgetData(s) : null);
133
+ if (!ctx.hasUI)
134
+ return;
132
135
  try {
133
136
  const lines = s ? buildWidgetLines(s, ctx.ui.theme) : undefined;
134
137
  ctx.ui.setWidget(WIDGET_KEY, lines);
@@ -136,9 +139,7 @@ export function startWidget(ctx, getState) {
136
139
  catch {
137
140
  stale = true;
138
141
  clearInterval(timer);
139
- return;
140
142
  }
141
- setTaskWidget(plain, s ? buildWidgetData(s) : null);
142
143
  };
143
144
  // The timer is created BEFORE the first render so `timer` is always bound
144
145
  // when render's catch reaches for it. Calling render() first instead would
@@ -208,16 +209,15 @@ export function buildAutoLoaderData(s) {
208
209
  * (returns a no-op disposer) when there's no UI.
209
210
  */
210
211
  export function startAutoLoader(ctx, getState) {
211
- if (!ctx.hasUI)
212
- return () => { };
213
- // Same staleness hazard as startWidget: the theme read must sit inside the
214
- // guard, and a stale ctx stops the timer rather than throwing every tick.
212
+ // Same staleness hazard as startWidget, and the same wire-half-first order.
215
213
  let stale = false;
216
214
  const render = () => {
217
215
  if (stale)
218
216
  return;
219
217
  const s = getState();
220
- const plain = s ? buildAutoLoaderLines(s, undefined) : undefined; // needs no ctx
218
+ setTaskWidget(s ? buildAutoLoaderLines(s, undefined) : undefined, s ? buildAutoLoaderData(s) : null);
219
+ if (!ctx.hasUI)
220
+ return;
221
221
  try {
222
222
  const lines = s ? buildAutoLoaderLines(s, ctx.ui.theme) : undefined;
223
223
  ctx.ui.setWidget(AUTO_WIDGET_KEY, lines);
@@ -225,9 +225,7 @@ export function startAutoLoader(ctx, getState) {
225
225
  catch {
226
226
  stale = true;
227
227
  clearInterval(timer);
228
- return;
229
228
  }
230
- setTaskWidget(plain, s ? buildAutoLoaderData(s) : null);
231
229
  };
232
230
  // The timer is created BEFORE the first render so `timer` is always bound
233
231
  // when render's catch reaches for it. Calling render() first instead would
@@ -277,40 +275,33 @@ export function buildImplData(s) {
277
275
  return d;
278
276
  }
279
277
  export function flashTerminalWidget(ctx, state, taskId, reason) {
280
- if (!ctx.hasUI)
281
- return;
282
- // Same staleness hazard as the render timers: bail rather than throw.
283
- let theme;
284
- try {
285
- theme = ctx.ui.theme;
286
- }
287
- catch {
288
- return;
289
- }
290
- let line;
291
- let clearMs;
292
- if (state === 'cancelled') {
293
- line = theme.fg('warning', `⚠ ${taskId} cancelled`);
294
- clearMs = NOTIFY_CLEAR_MS;
295
- }
296
- else {
297
- line = theme.fg('error', `✘ ${taskId} failed${reason ? ': ' + reason : ''}`);
298
- clearMs = FAIL_CLEAR_MS;
299
- }
300
278
  const plainLine = state === 'cancelled' ?
301
279
  `⚠ ${taskId} cancelled`
302
280
  : `✘ ${taskId} failed${reason ? ': ' + reason : ''}`;
303
- try {
304
- ctx.ui.setWidget(WIDGET_KEY, [line]);
305
- setTaskWidget([plainLine]);
306
- }
307
- catch {
308
- /* stale ctx */
281
+ const clearMs = state === 'cancelled' ? NOTIFY_CLEAR_MS : FAIL_CLEAR_MS;
282
+ setTaskWidget([plainLine]);
283
+ // The wire half is already done, so a missing or stale local ctx no longer
284
+ // costs the browser the flash as well. Same staleness hazard as the render
285
+ // timers: bail rather than throw.
286
+ if (ctx.hasUI) {
287
+ try {
288
+ const theme = ctx.ui.theme;
289
+ const tint = state === 'cancelled' ? 'warning' : 'error';
290
+ ctx.ui.setWidget(WIDGET_KEY, [theme.fg(tint, plainLine)]);
291
+ }
292
+ catch {
293
+ /* stale ctx */
294
+ }
309
295
  }
310
296
  setTimeout(() => {
297
+ // Wire first, for the same reason as the flash above: a ctx that went
298
+ // stale in the meantime would otherwise leave the browser showing the
299
+ // flash for the rest of the session.
300
+ setTaskWidget(undefined);
301
+ if (!ctx.hasUI)
302
+ return;
311
303
  try {
312
304
  ctx.ui.setWidget(WIDGET_KEY, undefined);
313
- setTaskWidget(undefined);
314
305
  }
315
306
  catch {
316
307
  /* stale ctx */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.39.2",
3
+ "version": "0.39.4",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",