@mjasnikovs/pi-task 0.13.11 → 0.13.12

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.
@@ -280,6 +280,16 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
280
280
  active.ui.notify(`${id} paused — could not start a session. Run /task-auto-resume to retry.`, 'warning');
281
281
  return;
282
282
  }
283
+ if (res.interrupted) {
284
+ // The user interrupted implementation (ESC) and then declined to
285
+ // steer (empty steer prompt) — they want to stop here. Pause
286
+ // without checking the task off, so /task-auto-resume re-delivers
287
+ // this task's spec to finish it. (A plain ESC that the user
288
+ // follows with steering text never reaches here — that loops on
289
+ // the same task inside runSingleTask until a turn completes.)
290
+ active.ui.notify(`${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
291
+ return;
292
+ }
283
293
  if (!res.ok) {
284
294
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
285
295
  active.ui.notify(`${id} stopped at "${next.title}" — fix and run /task-auto-resume.`, 'error');
@@ -69,6 +69,14 @@ export interface RunSingleTaskOptions {
69
69
  * work. Lets callers record the id (e.g. stamp the /task-auto entry) so an
70
70
  * interrupted run can be resumed instead of restarted. */
71
71
  onStart?: (taskId: string) => void | Promise<void>;
72
+ /**
73
+ * Ask the user for a steering message after they interrupt (ESC) the
74
+ * implementation turn. Return text to continue the same task as another turn,
75
+ * or undefined/empty to pause the run. Only consulted with
76
+ * waitForImplementation. Defaults to a ctx.ui.input prompt; injectable so the
77
+ * steer loop is testable without a real dialog.
78
+ */
79
+ promptSteer?: (ctx: ExtensionCommandContext) => Promise<string | undefined>;
72
80
  }
73
81
  export interface RunSingleTaskResult {
74
82
  taskId: string;
@@ -83,6 +91,16 @@ export interface RunSingleTaskResult {
83
91
  * only so test fakes that don't model session replacement can omit it.
84
92
  */
85
93
  ctx?: ExtensionCommandContext;
94
+ /**
95
+ * Set when the user interrupted the implementation (ESC) and then declined to
96
+ * steer (submitted an empty steer prompt) — i.e. they want the run to pause
97
+ * rather than continue. Only meaningful with waitForImplementation. The
98
+ * /task-auto loop reads this to pause (resumable) instead of checking the task
99
+ * off and advancing. A plain ESC that the user follows with steering text does
100
+ * NOT set this — that case loops on the same task until a turn finishes
101
+ * uninterrupted.
102
+ */
103
+ interrupted?: boolean;
86
104
  }
87
105
  /**
88
106
  * Run one prompt through the full single-task pipeline in a fresh session and
@@ -268,6 +268,50 @@ export class TaskRunner {
268
268
  }
269
269
  }
270
270
  }
271
+ /** Dialog copy for the post-interrupt steering prompt. */
272
+ const STEER_TITLE = 'Paused — steer the model';
273
+ const STEER_PLACEHOLDER = 'Type guidance to continue this task, or leave empty to pause';
274
+ /**
275
+ * True when the most recent assistant turn ended because the user interrupted it
276
+ * (pressed ESC). pi records a user abort as stopReason "aborted" on the assistant
277
+ * message, distinct from a natural "stop". Read after the implementation wait so
278
+ * the /task-auto loop can tell "user wants to steer" apart from "task finished".
279
+ */
280
+ function wasInterrupted(ctx) {
281
+ const entries = ctx.sessionManager.getEntries();
282
+ for (let i = entries.length - 1; i >= 0; i--) {
283
+ const e = entries[i];
284
+ if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
285
+ return e.message.stopReason === 'aborted';
286
+ }
287
+ }
288
+ return false;
289
+ }
290
+ /**
291
+ * After the implementation turn settles, honour a user ESC by letting them steer.
292
+ *
293
+ * `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
294
+ * the turn → idle). When the last turn was aborted, the host's main input loop is
295
+ * blocked inside our command handler, so a message typed in the editor would only
296
+ * queue, never run (interactive-mode routes idle input through onInputCallback,
297
+ * which is unset while we hold the loop). We therefore solicit the steering text
298
+ * ourselves and feed it back as another turn via sendUserMessage — which runs to
299
+ * completion when the session is idle. Repeat until a turn finishes uninterrupted.
300
+ *
301
+ * Returns true when the user declined to steer (empty/cancelled) and the run
302
+ * should pause; false when the implementation completed (steered or not).
303
+ */
304
+ async function steerUntilDone(ctx, promptSteer) {
305
+ const ask = promptSteer ?? (c => c.ui.input(STEER_TITLE, STEER_PLACEHOLDER));
306
+ while (wasInterrupted(ctx)) {
307
+ const steer = await ask(ctx);
308
+ if (steer === undefined || steer.trim().length === 0)
309
+ return true; // pause
310
+ await ctx.sendUserMessage(steer);
311
+ await ctx.waitForIdle();
312
+ }
313
+ return false;
314
+ }
271
315
  /**
272
316
  * Run one prompt through the full single-task pipeline in a fresh session and
273
317
  * deliver its spec. With waitForImplementation, block until the agent finishes
@@ -280,14 +324,17 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
280
324
  // UI after the original ctx is torn down. Defaults to the original for the
281
325
  // cancellation path (where no replacement occurs).
282
326
  let freshCtx = ctx;
327
+ let interrupted = false;
283
328
  const result = await ctx.newSession({
284
329
  withSession: async (newCtx) => {
285
330
  freshCtx = newCtx;
286
331
  getBridge().currentCtx = newCtx; // keep remote dispatch ctx fresh across session replacement
287
332
  const runner = new TaskRunner(newCtx, cwd, rawPrompt, opts.resumeId, async (spec) => {
288
333
  await newCtx.sendUserMessage(spec);
289
- if (opts.waitForImplementation)
334
+ if (opts.waitForImplementation) {
290
335
  await newCtx.waitForIdle();
336
+ interrupted = await steerUntilDone(newCtx, opts.promptSteer);
337
+ }
291
338
  }, opts.spawnFn, opts.onStart);
292
339
  await runner.run();
293
340
  taskId = runner.taskId;
@@ -307,7 +354,7 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
307
354
  ok = false;
308
355
  }
309
356
  }
310
- return { taskId, ok, sessionCancelled: false, ctx: freshCtx };
357
+ return { taskId, ok, sessionCancelled: false, ctx: freshCtx, interrupted };
311
358
  }
312
359
  // ─── Command handlers ────────────────────────────────────────────────────────
313
360
  async function handleTask(args, ctx) {
@@ -450,7 +450,7 @@ export async function critiqueWithFallback(d, p) {
450
450
  const msg = err instanceof Error ? err.message : String(err);
451
451
  if (msg !== 'no_verify_block')
452
452
  throw err;
453
- p.ctx.ui.notify('Critique couldn\'t produce a VERIFY block — using compose draft. Edit the spec manually if needed.', 'warning');
453
+ p.ctx.ui.notify("Critique couldn't produce a VERIFY block — using compose draft. Edit the spec manually if needed.", 'warning');
454
454
  return p.spec;
455
455
  }
456
456
  }
@@ -14,10 +14,10 @@ function buildFtsQuery(tokens) {
14
14
  }
15
15
  function fallbackChunks(cache, name, version) {
16
16
  const dts = cache.db
17
- .prepare('SELECT file_path, kind, content, 0 AS rank FROM chunks WHERE name = ? AND version = ? AND kind = \'dts\' ORDER BY file_path, id LIMIT 1')
17
+ .prepare("SELECT file_path, kind, content, 0 AS rank FROM chunks WHERE name = ? AND version = ? AND kind = 'dts' ORDER BY file_path, id LIMIT 1")
18
18
  .all(name, version);
19
19
  const readme = cache.db
20
- .prepare('SELECT file_path, kind, content, 0 AS rank FROM chunks WHERE name = ? AND version = ? AND kind = \'readme\' ORDER BY id LIMIT 1')
20
+ .prepare("SELECT file_path, kind, content, 0 AS rank FROM chunks WHERE name = ? AND version = ? AND kind = 'readme' ORDER BY id LIMIT 1")
21
21
  .all(name, version);
22
22
  const out = [];
23
23
  for (const r of dts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.11",
3
+ "version": "0.13.12",
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",