@mjasnikovs/pi-task 0.13.13 → 0.13.14

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,5 +1,4 @@
1
1
  import { setAgentIdle } from './state.js';
2
- import { pushNotify } from './push.js';
3
2
  import { publishNotify } from './bridge.js';
4
3
  import { agentStart, appendText, textEnd, startTool, updateTool, endTool, agentEnd, addUserTurn, addError, addSystemNote } from './session-state.js';
5
4
  /** Mirror pi agent events into the authoritative SessionState. Each handler
@@ -22,10 +21,11 @@ export function setupEvents(pi) {
22
21
  if (errorMessage || ae.reason === 'error') {
23
22
  const message = errorMessage || 'Request failed';
24
23
  addError(message);
25
- // Always push; the service worker suppresses the banner when a
26
- // window is actually visible+focused (sw.ts), which is the only
27
- // reliable foreground signal an open WebSocket is not one.
28
- void pushNotify('Agent error', message, 'pi-error').catch(() => { });
24
+ // No push here: pi-task only notifies for the two cases the user
25
+ // cares about needing their input (the grill/clarify dialog, see
26
+ // bridge.ask) and a top-level /task or /task-auto run finishing
27
+ // (orchestrator/auto-orchestrator). A push on every host agent
28
+ // error — most of them outside any task — is just noise.
29
29
  }
30
30
  }
31
31
  });
@@ -44,7 +44,12 @@ export function setupEvents(pi) {
44
44
  pi.on('agent_end', (_event, ctx) => {
45
45
  setAgentIdle(true);
46
46
  agentEnd(ctx.getContextUsage());
47
- void pushNotify('Task finished', '', 'pi-end').catch(() => { });
47
+ // Deliberately no push: agent_end fires on EVERY host turn — every chat
48
+ // reply, every internal phase turn inside /task, and every internal /task
49
+ // run inside /task-auto — so a "Task finished" push here floods the device.
50
+ // The real "a run finished" push is fired from the top-level command
51
+ // handlers instead (orchestrator handleTask/handleTaskResume, and
52
+ // auto-orchestrator's runAutoLoop), which never fire for internal runs.
48
53
  });
49
54
  pi.on('input', (event, _ctx) => {
50
55
  if (event.source === 'interactive' && typeof event.text === 'string') {
@@ -16,6 +16,7 @@ import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath } from
16
16
  import { gitCommitAll } from './auto-commit.js';
17
17
  import { runPhaseChild, USER_CANCELLED } from './child-runner.js';
18
18
  import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
19
+ import { pushNotify } from '../remote/push.js';
19
20
  import { getConfig } from '../config/config.js';
20
21
  import { startAutoLoader } from './widget.js';
21
22
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
@@ -106,7 +107,7 @@ export async function planAuto(ctx, cwd, feature, deps) {
106
107
  })
107
108
  });
108
109
  if (a === undefined) {
109
- ctx.ui.notify('/task-auto cancelled.', 'warning');
110
+ announceDone(ctx, '/task-auto cancelled.', 'warning');
110
111
  return null;
111
112
  }
112
113
  const typed = a.trim();
@@ -139,7 +140,7 @@ export async function planAuto(ctx, cwd, feature, deps) {
139
140
  const listRaw = await deps.runChild('auto-decompose', 'read', AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications));
140
141
  const titles = parseDecomposeList(listRaw);
141
142
  if (titles.length === 0) {
142
- ctx.ui.notify('/task-auto: no tasks produced from the feature.', 'warning');
143
+ announceDone(ctx, '/task-auto: no tasks produced from the feature.', 'warning');
143
144
  return null;
144
145
  }
145
146
  // persist
@@ -220,6 +221,17 @@ let autoRunning = false;
220
221
  export function requestAutoCancel() {
221
222
  cancelRequested = true;
222
223
  }
224
+ /**
225
+ * Announce a terminal /task-auto-overall outcome both in the terminal and to
226
+ * subscribed devices. The push body reuses the exact terminal message, so a
227
+ * backgrounded phone learns the same thing the TUI shows. Used ONLY at the
228
+ * overall run's terminal points — never per internal task (those go through
229
+ * runSingleTask without notifyFinish, so they stay silent).
230
+ */
231
+ function announceDone(ctx, msg, level) {
232
+ ctx.ui.notify(msg, level);
233
+ void pushNotify('Task finished', msg, 'pi-end').catch(() => { });
234
+ }
223
235
  export async function runAutoLoop(ctx, cwd, id, deps) {
224
236
  cancelRequested = false;
225
237
  // Each task runs in its own fresh session (deps.runTask → ctx.newSession),
@@ -230,7 +242,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
230
242
  try {
231
243
  for (;;) {
232
244
  if (cancelRequested) {
233
- active.ui.notify(`${id} cancelled — resume with /task-auto-resume.`, 'warning');
245
+ announceDone(active, `${id} cancelled — resume with /task-auto-resume.`, 'warning');
234
246
  return;
235
247
  }
236
248
  const { body } = await readTaskFile(cwd, id);
@@ -238,7 +250,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
238
250
  const next = entries.find(e => !e.done);
239
251
  if (!next) {
240
252
  await updateTaskFrontMatter(cwd, id, { state: 'completed' });
241
- active.ui.notify(`${id} complete — all ${entries.length} tasks done.`, 'info');
253
+ announceDone(active, `${id} complete — all ${entries.length} tasks done.`, 'info');
242
254
  return;
243
255
  }
244
256
  active.ui.notify(`${id}: task ${next.index + 1}/${entries.length} — ${next.title}`, 'info');
@@ -277,7 +289,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
277
289
  });
278
290
  active = res.ctx ?? active;
279
291
  if (res.sessionCancelled) {
280
- active.ui.notify(`${id} paused — could not start a session. Run /task-auto-resume to retry.`, 'warning');
292
+ announceDone(active, `${id} paused — could not start a session. Run /task-auto-resume to retry.`, 'warning');
281
293
  return;
282
294
  }
283
295
  if (res.interrupted) {
@@ -287,12 +299,12 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
287
299
  // this task's spec to finish it. (A plain ESC that the user
288
300
  // follows with steering text never reaches here — that loops on
289
301
  // the same task inside runSingleTask until a turn completes.)
290
- active.ui.notify(`${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
302
+ announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
291
303
  return;
292
304
  }
293
305
  if (!res.ok) {
294
306
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
295
- active.ui.notify(`${id} stopped at "${next.title}" — fix and run /task-auto-resume.`, 'error');
307
+ announceDone(active, `${id} stopped at "${next.title}" — fix and run /task-auto-resume.`, 'error');
296
308
  return;
297
309
  }
298
310
  // res.ok === true means runner.run() completed, so res.taskId is the
@@ -319,11 +331,11 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
319
331
  // mirroring the in-loop per-task failure path.
320
332
  const msg = err instanceof Error ? err.message : String(err);
321
333
  if (msg === USER_CANCELLED) {
322
- active.ui.notify(`${id} cancelled — resume with /task-auto-resume.`, 'warning');
334
+ announceDone(active, `${id} cancelled — resume with /task-auto-resume.`, 'warning');
323
335
  return;
324
336
  }
325
337
  await updateTaskFrontMatter(cwd, id, { state: 'failed' }).catch(() => { });
326
- active.ui.notify(`${id} stopped: ${msg} — fix and run /task-auto-resume.`, 'error');
338
+ announceDone(active, `${id} stopped: ${msg} — fix and run /task-auto-resume.`, 'error');
327
339
  }
328
340
  finally {
329
341
  cancelRequested = false;
@@ -350,10 +362,10 @@ async function handleTaskAuto(args, ctx) {
350
362
  autoRunning = false;
351
363
  const msg = err instanceof Error ? err.message : String(err);
352
364
  if (msg === USER_CANCELLED) {
353
- ctx.ui.notify('/task-auto cancelled.', 'warning');
365
+ announceDone(ctx, '/task-auto cancelled.', 'warning');
354
366
  return;
355
367
  }
356
- ctx.ui.notify(`/task-auto planning failed: ${msg}`, 'error');
368
+ announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
357
369
  return;
358
370
  }
359
371
  if (!id) {
@@ -365,7 +377,7 @@ async function handleTaskAuto(args, ctx) {
365
377
  if (cancelRequested) {
366
378
  cancelRequested = false;
367
379
  autoRunning = false;
368
- ctx.ui.notify('/task-auto cancelled.', 'warning');
380
+ announceDone(ctx, '/task-auto cancelled.', 'warning');
369
381
  return;
370
382
  }
371
383
  await runAutoLoop(ctx, cwd, id, deps);
@@ -77,6 +77,13 @@ export interface RunSingleTaskOptions {
77
77
  * steer loop is testable without a real dialog.
78
78
  */
79
79
  promptSteer?: (ctx: ExtensionCommandContext) => Promise<string | undefined>;
80
+ /**
81
+ * Push a "Task finished" notification to subscribed devices when this run
82
+ * reaches a terminal state (completed / failed / cancelled). Set only by the
83
+ * top-level /task and /task-resume command handlers — NOT by /task-auto's
84
+ * internal per-task runs, which must stay silent. Default false.
85
+ */
86
+ notifyFinish?: boolean;
80
87
  }
81
88
  export interface RunSingleTaskResult {
82
89
  taskId: string;
@@ -24,6 +24,7 @@ import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parser
24
24
  import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
25
25
  import { startWidget } from './widget.js';
26
26
  import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
27
+ import { pushNotify } from '../remote/push.js';
27
28
  import { parseVerifyBlock } from './spec-validation.js';
28
29
  import { formatTimings } from './timings.js';
29
30
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
@@ -342,18 +343,29 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
342
343
  });
343
344
  if (result.cancelled) {
344
345
  // No replacement happened — the original ctx is still live.
346
+ if (opts.notifyFinish) {
347
+ void pushNotify('Task finished', `${taskId || 'Task'} cancelled — could not start a session.`, 'pi-end').catch(() => { });
348
+ }
345
349
  return { taskId, ok: false, sessionCancelled: true, ctx };
346
350
  }
347
351
  let ok = false;
352
+ let state;
348
353
  if (taskId) {
349
354
  try {
350
355
  const { frontMatter } = await readTaskFile(cwd, taskId);
351
- ok = frontMatter.state === 'completed';
356
+ state = frontMatter.state;
357
+ ok = state === 'completed';
352
358
  }
353
359
  catch {
354
360
  ok = false;
355
361
  }
356
362
  }
363
+ if (opts.notifyFinish) {
364
+ // One push per top-level /task or /task-resume, on any terminal end. The
365
+ // file state is the source of truth: 'completed' on success, 'failed' /
366
+ // 'cancelled' otherwise; an unreadable/absent file falls back to 'ended'.
367
+ void pushNotify('Task finished', `${taskId || 'Task'} ${state ?? 'ended'}.`, 'pi-end').catch(() => { });
368
+ }
357
369
  return { taskId, ok, sessionCancelled: false, ctx: freshCtx, interrupted };
358
370
  }
359
371
  // ─── Command handlers ────────────────────────────────────────────────────────
@@ -366,7 +378,7 @@ async function handleTask(args, ctx) {
366
378
  ctx.ui.notify('Type your prompt after /task (use @ for file completion).', 'info');
367
379
  return;
368
380
  }
369
- const { sessionCancelled } = await runSingleTask(ctx, cwd, raw);
381
+ const { sessionCancelled } = await runSingleTask(ctx, cwd, raw, { notifyFinish: true });
370
382
  if (sessionCancelled) {
371
383
  ctx.ui.notify('Could not start a fresh session for /task.', 'warning');
372
384
  }
@@ -449,7 +461,7 @@ async function handleTaskResume(args, ctx) {
449
461
  }
450
462
  id = candidates[0].id;
451
463
  }
452
- const { sessionCancelled } = await runSingleTask(ctx, cwd, '', { resumeId: id });
464
+ const { sessionCancelled } = await runSingleTask(ctx, cwd, '', { resumeId: id, notifyFinish: true });
453
465
  if (sessionCancelled) {
454
466
  ctx.ui.notify('Could not start a fresh session for /task-resume.', 'warning');
455
467
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.13",
3
+ "version": "0.13.14",
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",