@mjasnikovs/pi-task 0.14.20 → 0.14.22

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.
@@ -65,6 +65,18 @@ export declare class SessionUI {
65
65
  private askLocal;
66
66
  }
67
67
  export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
68
+ /**
69
+ * Mirror a task lifecycle notice (the kind pi-task shows on the terminal via
70
+ * ctx.ui.notify) to connected remote viewers. Task failures and other
71
+ * ctx.ui.notify calls bypass the host agent's event stream — the only thing
72
+ * events.ts mirrors — so without this the remote view shows nothing when a task
73
+ * fails completely even though the terminal flashes red.
74
+ *
75
+ * An 'error' becomes a PERSISTENT red bubble in the transcript (addError) so it
76
+ * survives a reconnect, matching the terminal's red text; 'warning'/'info' are a
77
+ * transient toast since they don't need to linger.
78
+ */
79
+ export declare function publishLifecycleNotice(message: string, level: 'info' | 'warning' | 'error'): void;
68
80
  export declare function publishViewer(title: string, text: string): void;
69
81
  /**
70
82
  * Wraps an event-scoped ExtensionContext so it can be used as a command ctx
@@ -1,6 +1,6 @@
1
1
  import { broadcast as wsBroadcast } from './broadcast.js';
2
2
  import { pushNotify } from './push.js';
3
- import { setPrompt, clearPrompt } from './session-state.js';
3
+ import { setPrompt, clearPrompt, addError } from './session-state.js';
4
4
  import { askQuestionBox } from '../task/question-box.js';
5
5
  const g = globalThis;
6
6
  export function getBridge() {
@@ -108,6 +108,23 @@ export class SessionUI {
108
108
  export function publishNotify(message, level) {
109
109
  getBridge().broadcast({ type: 'notify', message, level });
110
110
  }
111
+ /**
112
+ * Mirror a task lifecycle notice (the kind pi-task shows on the terminal via
113
+ * ctx.ui.notify) to connected remote viewers. Task failures and other
114
+ * ctx.ui.notify calls bypass the host agent's event stream — the only thing
115
+ * events.ts mirrors — so without this the remote view shows nothing when a task
116
+ * fails completely even though the terminal flashes red.
117
+ *
118
+ * An 'error' becomes a PERSISTENT red bubble in the transcript (addError) so it
119
+ * survives a reconnect, matching the terminal's red text; 'warning'/'info' are a
120
+ * transient toast since they don't need to linger.
121
+ */
122
+ export function publishLifecycleNotice(message, level) {
123
+ if (level === 'error')
124
+ addError(message);
125
+ else
126
+ publishNotify(message, level);
127
+ }
111
128
  export function publishViewer(title, text) {
112
129
  getBridge().broadcast({ type: 'viewer', title, text });
113
130
  }
@@ -21,7 +21,7 @@ import { researchResolution, resolutionOptions, classifyResolutionAnswer } from
21
21
  import { runWorker } from '../workers/pi-worker-core.js';
22
22
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
23
23
  import { runPhaseChild, prependHint, formatLoopHint, USER_CANCELLED } from './child-runner.js';
24
- import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
24
+ import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
25
25
  import { pushNotify } from '../remote/push.js';
26
26
  import { getConfig } from '../config/config.js';
27
27
  import { startAutoLoader } from './widget.js';
@@ -621,6 +621,10 @@ export function requestAutoCancel() {
621
621
  */
622
622
  function announceDone(ctx, msg, level) {
623
623
  ctx.ui.notify(msg, level);
624
+ // ctx.ui.notify is terminal-only and pushNotify is a backgrounded-device web
625
+ // push — neither shows up in a remote viewer that's watching live. Mirror it
626
+ // into the session view too (errors become a persistent red bubble).
627
+ publishLifecycleNotice(msg, level);
624
628
  void pushNotify('Task finished', msg, 'pi-end').catch(() => { });
625
629
  }
626
630
  export async function runAutoLoop(ctx, cwd, id, deps) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { updateTaskFrontMatter } from './task-io.js';
6
6
  import { flashTerminalWidget } from './widget.js';
7
+ import { publishLifecycleNotice } from '../remote/bridge.js';
7
8
  import { LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
8
9
  // ─── Classifier ──────────────────────────────────────────────────────────────
9
10
  export function classifyFailure(err, aborted) {
@@ -78,4 +79,7 @@ export async function handleFailure(err, ctx, cwd, id, aborted) {
78
79
  await updateTaskFrontMatter(cwd, id, { state: c.state, reason: c.reason });
79
80
  flashTerminalWidget(ctx, c.state, id, c.flash);
80
81
  ctx.ui.notify(`${id} ${c.notify}`, c.level);
82
+ // Mirror to remote viewers — ctx.ui.notify is terminal-only, so without this
83
+ // the remote view shows nothing when a task fails.
84
+ publishLifecycleNotice(`${id} ${c.notify}`, c.level);
81
85
  }
@@ -113,6 +113,16 @@ export interface RunSingleTaskOptions {
113
113
  */
114
114
  fixInstruction?: string;
115
115
  }
116
+ /**
117
+ * The slice of the replacement-session context the steer loop needs.
118
+ * `sendUserMessage` lives on ReplacedSessionContext (not the base command ctx,
119
+ * and not re-exported from the package), so we narrow to just what we call.
120
+ */
121
+ export type SteerCtx = ExtensionCommandContext & {
122
+ sendUserMessage(content: string, options?: {
123
+ deliverAs?: 'steer' | 'followUp';
124
+ }): Promise<void>;
125
+ };
116
126
  export interface RunSingleTaskResult {
117
127
  taskId: string;
118
128
  ok: boolean;
@@ -145,6 +155,55 @@ export interface RunSingleTaskResult {
145
155
  */
146
156
  reason?: string;
147
157
  }
158
+ /**
159
+ * True when the implementation turn went idle right after a context compaction —
160
+ * the most recent entry in the branch is a `compaction` boundary sitting after the
161
+ * last assistant message.
162
+ *
163
+ * A *threshold* auto-compaction (the runtime's "context is getting large" path)
164
+ * compacts and then deliberately does NOT auto-continue: it returns to idle and
165
+ * expects a manual continue (`_runAutoCompaction("threshold", false)` →
166
+ * `hasQueuedMessages()` is false → the agent loop stops). Our implementation wait
167
+ * resolves at exactly that idle. Without this check it reads as "the model
168
+ * finished" (the last assistant message is a normal `stop`, not `aborted`/`error`),
169
+ * so the run jumps straight to the verify gate and abandons a half-done task at the
170
+ * compaction boundary — the failure this detector closes.
171
+ *
172
+ * Position-based, not timestamp-based: the runtime APPENDS the compaction entry to
173
+ * the tail of the branch after the assistant message that triggered it
174
+ * (`appendCompaction` → `_appendEntry` push), so a `compaction` after the last
175
+ * assistant message means we are parked on a compaction with no continuation. A
176
+ * genuinely finished turn ends on an assistant message with no trailing compaction;
177
+ * an *overflow* compaction self-retries, so it never leaves us idle here.
178
+ */
179
+ export declare function endedAtCompactionBoundary(ctx: ExtensionCommandContext): boolean;
180
+ /**
181
+ * Nudge that resumes an implementation turn the runtime parked at a compaction
182
+ * boundary. It must let a turn that was genuinely finished (then tipped over the
183
+ * threshold by its own final message) confirm completion without inventing busywork
184
+ * — we cannot tell "paused mid-task by compaction" from "finished, then compacted"
185
+ * from the boundary alone, so the wording lets a done turn end in one line.
186
+ */
187
+ export declare const CONTINUE_AFTER_COMPACTION: string;
188
+ /**
189
+ * Safety cap on compaction-driven resumes for a single implementation turn. Each
190
+ * resume follows a real compaction (which only fires after the model produced a
191
+ * turn large enough to cross the threshold), so a legitimately large task may
192
+ * resume a handful of times; the cap exists only to stop a pathological loop from
193
+ * auto-sending forever with no user in the loop. Hitting it stops resuming and lets
194
+ * the verify gate / `/task-auto-resume` catch any leftover incompleteness.
195
+ */
196
+ export declare const MAX_COMPACTION_RESUMES = 20;
197
+ /**
198
+ * Resume an implementation turn that went idle at a threshold-compaction boundary.
199
+ * The runtime compacts and parks at idle without auto-continuing; we send a
200
+ * continue and wait again, repeating across successive compactions until the turn
201
+ * ends on a real assistant message (genuine completion). A user ESC takes priority
202
+ * (it is not a compaction boundary, and `wasInterrupted` guards the loop so the
203
+ * steer loop handles it), and the safety cap bounds a runaway. Returns the number
204
+ * of resumes performed (0 when the turn did not end on a compaction).
205
+ */
206
+ export declare function resumeAcrossCompactions(ctx: SteerCtx): Promise<number>;
148
207
  /**
149
208
  * Run one prompt through the full single-task pipeline in a fresh session and
150
209
  * deliver its spec. With waitForImplementation, block until the agent finishes
@@ -353,6 +353,82 @@ function implementationError(ctx) {
353
353
  }
354
354
  return undefined;
355
355
  }
356
+ /**
357
+ * True when the implementation turn went idle right after a context compaction —
358
+ * the most recent entry in the branch is a `compaction` boundary sitting after the
359
+ * last assistant message.
360
+ *
361
+ * A *threshold* auto-compaction (the runtime's "context is getting large" path)
362
+ * compacts and then deliberately does NOT auto-continue: it returns to idle and
363
+ * expects a manual continue (`_runAutoCompaction("threshold", false)` →
364
+ * `hasQueuedMessages()` is false → the agent loop stops). Our implementation wait
365
+ * resolves at exactly that idle. Without this check it reads as "the model
366
+ * finished" (the last assistant message is a normal `stop`, not `aborted`/`error`),
367
+ * so the run jumps straight to the verify gate and abandons a half-done task at the
368
+ * compaction boundary — the failure this detector closes.
369
+ *
370
+ * Position-based, not timestamp-based: the runtime APPENDS the compaction entry to
371
+ * the tail of the branch after the assistant message that triggered it
372
+ * (`appendCompaction` → `_appendEntry` push), so a `compaction` after the last
373
+ * assistant message means we are parked on a compaction with no continuation. A
374
+ * genuinely finished turn ends on an assistant message with no trailing compaction;
375
+ * an *overflow* compaction self-retries, so it never leaves us idle here.
376
+ */
377
+ export function endedAtCompactionBoundary(ctx) {
378
+ const entries = ctx.sessionManager.getEntries();
379
+ let lastAssistant = -1;
380
+ let lastCompaction = -1;
381
+ for (let i = 0; i < entries.length; i++) {
382
+ const e = entries[i];
383
+ if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
384
+ lastAssistant = i;
385
+ }
386
+ else if (e.type === 'compaction') {
387
+ lastCompaction = i;
388
+ }
389
+ }
390
+ return lastCompaction > lastAssistant;
391
+ }
392
+ /**
393
+ * Nudge that resumes an implementation turn the runtime parked at a compaction
394
+ * boundary. It must let a turn that was genuinely finished (then tipped over the
395
+ * threshold by its own final message) confirm completion without inventing busywork
396
+ * — we cannot tell "paused mid-task by compaction" from "finished, then compacted"
397
+ * from the boundary alone, so the wording lets a done turn end in one line.
398
+ */
399
+ export const CONTINUE_AFTER_COMPACTION = 'Your context was automatically compacted. Continue implementing this task from '
400
+ + 'exactly where you left off, and keep going until it is fully done. If the '
401
+ + 'implementation is already complete, say so in one line and stop — do not invent '
402
+ + 'extra work or restart the task.';
403
+ /**
404
+ * Safety cap on compaction-driven resumes for a single implementation turn. Each
405
+ * resume follows a real compaction (which only fires after the model produced a
406
+ * turn large enough to cross the threshold), so a legitimately large task may
407
+ * resume a handful of times; the cap exists only to stop a pathological loop from
408
+ * auto-sending forever with no user in the loop. Hitting it stops resuming and lets
409
+ * the verify gate / `/task-auto-resume` catch any leftover incompleteness.
410
+ */
411
+ export const MAX_COMPACTION_RESUMES = 20;
412
+ /**
413
+ * Resume an implementation turn that went idle at a threshold-compaction boundary.
414
+ * The runtime compacts and parks at idle without auto-continuing; we send a
415
+ * continue and wait again, repeating across successive compactions until the turn
416
+ * ends on a real assistant message (genuine completion). A user ESC takes priority
417
+ * (it is not a compaction boundary, and `wasInterrupted` guards the loop so the
418
+ * steer loop handles it), and the safety cap bounds a runaway. Returns the number
419
+ * of resumes performed (0 when the turn did not end on a compaction).
420
+ */
421
+ export async function resumeAcrossCompactions(ctx) {
422
+ let resumes = 0;
423
+ while (resumes < MAX_COMPACTION_RESUMES
424
+ && !wasInterrupted(ctx)
425
+ && endedAtCompactionBoundary(ctx)) {
426
+ await ctx.sendUserMessage(CONTINUE_AFTER_COMPACTION);
427
+ await ctx.waitForIdle();
428
+ resumes++;
429
+ }
430
+ return resumes;
431
+ }
356
432
  /**
357
433
  * After the implementation turn settles, honour a user ESC by letting them steer.
358
434
  *
@@ -404,6 +480,14 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
404
480
  await newCtx.sendUserMessage(spec);
405
481
  if (opts.waitForImplementation) {
406
482
  await newCtx.waitForIdle();
483
+ // A threshold auto-compaction parks the turn at idle WITHOUT
484
+ // auto-continuing (the runtime expects a manual continue). Our
485
+ // single waitForIdle resolves there, so without this the run
486
+ // would treat a compaction mid-task as completion and jump
487
+ // straight to the verify gate. Resume across any compaction
488
+ // boundaries first, so steering/error inspection below see the
489
+ // turn's REAL end, not a compaction pause.
490
+ await resumeAcrossCompactions(newCtx);
407
491
  interrupted = await steerUntilDone(newCtx, opts.promptSteer);
408
492
  // A user-declined steer (interrupted) is its own paused
409
493
  // path; otherwise inspect how the turn actually ended.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.14.20",
3
+ "version": "0.14.22",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",