@mjasnikovs/pi-task 0.18.41 → 0.18.43

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.
package/README.md CHANGED
@@ -63,7 +63,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
63
63
  | `/task-resume [id]` | Resume the most recent (or named) unfinished task. |
64
64
  | `/task-cancel` | Cancel the running task (soft-terminal — still resumable). |
65
65
  | `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
66
- | `/task-auto-resume` | Resume the active `/task-auto` run at the next unfinished task. |
66
+ | `/task-auto-resume [--unattended]` | Resume the active `/task-auto` run at the next unfinished task. `--unattended` is the boot-hook form: in-flight runs only. |
67
67
  | `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
68
68
  | `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, orientation, verify work, enforce guidelines, command timeout, stream watchdog, and the extension whitelist for child sessions. |
69
69
  | `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
@@ -100,6 +100,7 @@ A real feature is usually several tasks, not one. `/task-auto` is a thin planner
100
100
  - **Clarify first.** It asks the few clarifying questions whose answers change how the feature splits, then decomposes the answers into an ordered list of task titles written to `.pi-tasks/TASK_AUTO_NNNN.md`.
101
101
  - **Sequential, blocking.** Each title runs through `/task` to a spec, the spec is implemented, and the loop waits for that to finish before starting the next title. No overlap.
102
102
  - **Crash- and cancel-safe.** Progress is the markdown checkboxes in the AUTO file. `/task-auto-resume` (no id) automatically picks up the active run at the first unchecked title. If a title's `/task` run fails, the loop stops and leaves the run resumable.
103
+ - **Restart-safe, unattended.** `/task-auto-resume --unattended` is the same resume with no human in the loop — for a boot hook or a container entrypoint. It continues **in-flight** runs only: a `failed` or `cancelled` run stopped for a reason a power cycle does not clear, so it is reported and left alone rather than re-entered against the same wall. Either way the resume banner states exactly what it measured — how long since the run last wrote, and that nothing was rolled back — and attributes no cause, because a stopped host, a hung child, and a slow task look identical from here. Pair it with `restart: unless-stopped` on long-running containers and an overnight outage costs minutes instead of the whole night.
103
104
  - **One commit per task.** When **auto-commit** is on (the default) and you're in a git repo, the working tree is snapshotted into a single commit after each title passes, so the run produces a clean per-task history. It's best-effort: outside a repo, with nothing to commit, or on any git error, the loop reports the reason and keeps going. Toggle it in `/task-config`.
104
105
 
105
106
  ## Remote — drive a task from your phone
@@ -276,11 +276,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
276
276
  {
277
277
  ...opts,
278
278
  onToolCall: call => {
279
- streamWatch.suspend();
279
+ // Keyed: a child's tool batch runs in parallel too, so the
280
+ // first result must not un-pause a still-running sibling.
281
+ streamWatch.suspend(call.toolCallId);
280
282
  return opts.onToolCall ? opts.onToolCall(call) : null;
281
283
  },
282
284
  onToolResult: r => {
283
- streamWatch.resume();
285
+ streamWatch.resume(r.toolCallId);
284
286
  opts.onToolResult?.(r);
285
287
  }
286
288
  }
@@ -64,20 +64,39 @@ export declare class StreamWatchdog {
64
64
  private timer;
65
65
  private lastEvent;
66
66
  private armedMs;
67
- /** True while a TOOL is executing: the model stream is legitimately idle then,
68
- * and that window belongs to the command watchdog, not to this one. Without
69
- * this, a 10-minute build would look identical to a hung stream. */
70
- private suspended;
67
+ /**
68
+ * The tool calls currently executing. While ANY is running the model stream is
69
+ * legitimately idle, and that window belongs to the command watchdog, not to
70
+ * this one — without it a 10-minute build looks identical to a hung stream.
71
+ *
72
+ * A SET, not a boolean: pi runs a tool batch in parallel (agent-loop.js
73
+ * `executeToolCallsParallel` emits every tool_execution_start up front, then one
74
+ * end per call as each settles, and answers an immediate call inline while an
75
+ * earlier one is still running). A boolean would be cleared by the FIRST end and
76
+ * leave the still-running sibling — the long build — exposed to a false fire.
77
+ * Same per-toolCallId idiom the command watchdog uses.
78
+ */
79
+ private readonly active;
80
+ /** Nesting depth for callers that cannot supply an id, counted so an unkeyed
81
+ * pair nests the same way a keyed one does. */
82
+ private keyless;
71
83
  private fired;
84
+ private get suspended();
72
85
  constructor(deps: StreamWatchdogDeps);
73
86
  /** Begin watching a model request. No-op when the watchdog is off or already armed. */
74
87
  start(): void;
75
88
  /** Any stream event of any kind: resets the idle clock. */
76
89
  note(): void;
77
- /** A tool started executing — pause the idle clock until it ends. */
78
- suspend(): void;
79
- /** A tool finished — the stream is expected to resume; restart the clock. */
80
- resume(): void;
90
+ /** A tool started executing — pause the idle clock until it (and every sibling
91
+ * still running) ends. `key` is the tool call id where the caller has one. */
92
+ suspend(key?: string): void;
93
+ /**
94
+ * A tool finished. The clock only restarts once the LAST one does — a fast tool
95
+ * settling first says nothing about a sibling that is still running. Unmatched
96
+ * ends (a watchdog armed mid-batch never saw the start) are ignored rather than
97
+ * clearing the whole set.
98
+ */
99
+ resume(key?: string): void;
81
100
  /** Stop watching (turn/agent/session end, or child exit). Safe to call twice. */
82
101
  stop(): void;
83
102
  /** @internal Exposed for the poll callback and tests. */
@@ -59,11 +59,26 @@ export class StreamWatchdog {
59
59
  timer;
60
60
  lastEvent = 0;
61
61
  armedMs = 0;
62
- /** True while a TOOL is executing: the model stream is legitimately idle then,
63
- * and that window belongs to the command watchdog, not to this one. Without
64
- * this, a 10-minute build would look identical to a hung stream. */
65
- suspended = false;
62
+ /**
63
+ * The tool calls currently executing. While ANY is running the model stream is
64
+ * legitimately idle, and that window belongs to the command watchdog, not to
65
+ * this one — without it a 10-minute build looks identical to a hung stream.
66
+ *
67
+ * A SET, not a boolean: pi runs a tool batch in parallel (agent-loop.js
68
+ * `executeToolCallsParallel` emits every tool_execution_start up front, then one
69
+ * end per call as each settles, and answers an immediate call inline while an
70
+ * earlier one is still running). A boolean would be cleared by the FIRST end and
71
+ * leave the still-running sibling — the long build — exposed to a false fire.
72
+ * Same per-toolCallId idiom the command watchdog uses.
73
+ */
74
+ active = new Set();
75
+ /** Nesting depth for callers that cannot supply an id, counted so an unkeyed
76
+ * pair nests the same way a keyed one does. */
77
+ keyless = 0;
66
78
  fired = false;
79
+ get suspended() {
80
+ return this.active.size > 0 || this.keyless > 0;
81
+ }
67
82
  constructor(deps) {
68
83
  this.deps = deps;
69
84
  }
@@ -80,7 +95,8 @@ export class StreamWatchdog {
80
95
  return;
81
96
  this.armedMs = ms;
82
97
  this.fired = false;
83
- this.suspended = false;
98
+ this.active.clear();
99
+ this.keyless = 0;
84
100
  this.lastEvent = this.deps.now();
85
101
  this.timer = this.deps.schedule(() => this.check(), pollIntervalMs(ms));
86
102
  }
@@ -88,21 +104,35 @@ export class StreamWatchdog {
88
104
  note() {
89
105
  this.lastEvent = this.deps.now();
90
106
  }
91
- /** A tool started executing — pause the idle clock until it ends. */
92
- suspend() {
93
- this.suspended = true;
107
+ /** A tool started executing — pause the idle clock until it (and every sibling
108
+ * still running) ends. `key` is the tool call id where the caller has one. */
109
+ suspend(key) {
110
+ if (key === undefined)
111
+ this.keyless++;
112
+ else
113
+ this.active.add(key);
94
114
  }
95
- /** A tool finished — the stream is expected to resume; restart the clock. */
96
- resume() {
97
- this.suspended = false;
98
- this.note();
115
+ /**
116
+ * A tool finished. The clock only restarts once the LAST one does — a fast tool
117
+ * settling first says nothing about a sibling that is still running. Unmatched
118
+ * ends (a watchdog armed mid-batch never saw the start) are ignored rather than
119
+ * clearing the whole set.
120
+ */
121
+ resume(key) {
122
+ if (key === undefined)
123
+ this.keyless = Math.max(0, this.keyless - 1);
124
+ else
125
+ this.active.delete(key);
126
+ if (!this.suspended)
127
+ this.note();
99
128
  }
100
129
  /** Stop watching (turn/agent/session end, or child exit). Safe to call twice. */
101
130
  stop() {
102
131
  if (this.timer !== undefined)
103
132
  this.deps.cancel(this.timer);
104
133
  this.timer = undefined;
105
- this.suspended = false;
134
+ this.active.clear();
135
+ this.keyless = 0;
106
136
  }
107
137
  /** @internal Exposed for the poll callback and tests. */
108
138
  check() {
@@ -1,3 +1,5 @@
1
+ import type { TaskState } from './task-types.js';
2
+ import type { AutoResumeCandidate } from './resume-gap.js';
1
3
  export interface TaskEntry {
2
4
  index: number;
3
5
  title: string;
@@ -58,5 +60,18 @@ export declare function stampTaskInProgress(cwd: string, id: string, index: numb
58
60
  * next step by "first unchecked" rather than by a cached index.
59
61
  */
60
62
  export declare function insertTaskAfter(cwd: string, id: string, afterIndex: number, title: string): Promise<boolean>;
61
- /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
63
+ /**
64
+ * Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
65
+ * last-write time the resume banner reports (see resume-gap.ts). Null when there
66
+ * is nothing resumable.
67
+ *
68
+ * `states` narrows which states count as resumable, and the UNATTENDED path passes
69
+ * UNATTENDED_STATES so the search answers the question that path actually asks —
70
+ * "is there an IN-FLIGHT run to pick up?". Selecting the newest human-resumable run
71
+ * and only then refusing it on state let one failed run shadow an in-flight one
72
+ * behind it: the boot hook refused every restart and the in-flight run stayed in
73
+ * exactly the dead air this feature exists to end.
74
+ */
75
+ export declare function findResumableAutoDetailed(cwd: string, states?: readonly TaskState[]): Promise<AutoResumeCandidate | null>;
76
+ /** Id-only form of {@link findResumableAutoDetailed}. */
62
77
  export declare function findResumableAuto(cwd: string): Promise<string | null>;
@@ -194,8 +194,19 @@ export async function insertTaskAfter(cwd, id, afterIndex, title) {
194
194
  await setTaskSection(cwd, id, 'tasks', lines.join('\n'));
195
195
  return true;
196
196
  }
197
- /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
198
- export async function findResumableAuto(cwd) {
197
+ /**
198
+ * Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
199
+ * last-write time the resume banner reports (see resume-gap.ts). Null when there
200
+ * is nothing resumable.
201
+ *
202
+ * `states` narrows which states count as resumable, and the UNATTENDED path passes
203
+ * UNATTENDED_STATES so the search answers the question that path actually asks —
204
+ * "is there an IN-FLIGHT run to pick up?". Selecting the newest human-resumable run
205
+ * and only then refusing it on state let one failed run shadow an in-flight one
206
+ * behind it: the boot hook refused every restart and the in-flight run stayed in
207
+ * exactly the dead air this feature exists to end.
208
+ */
209
+ export async function findResumableAutoDetailed(cwd, states = RESUMABLE_STATES) {
199
210
  await ensureTasksDir(cwd);
200
211
  const entries = await fsp.readdir(tasksDir(cwd));
201
212
  const candidates = [];
@@ -208,15 +219,19 @@ export async function findResumableAuto(cwd) {
208
219
  const fm = parseFrontMatter(raw);
209
220
  if (!fm)
210
221
  continue;
211
- if (!RESUMABLE_STATES.includes(fm.state))
222
+ if (!states.includes(fm.state))
212
223
  continue;
213
224
  const st = await fsp.stat(path.join(tasksDir(cwd), f));
214
- candidates.push({ id: m[1], mtime: st.mtimeMs });
225
+ candidates.push({ id: m[1], state: fm.state, lastWriteMs: st.mtimeMs });
215
226
  }
216
227
  catch {
217
228
  /* skip unreadable */
218
229
  }
219
230
  }
220
- candidates.sort((a, b) => b.mtime - a.mtime);
221
- return candidates.length > 0 ? candidates[0].id : null;
231
+ candidates.sort((a, b) => b.lastWriteMs - a.lastWriteMs);
232
+ return candidates.length > 0 ? candidates[0] : null;
233
+ }
234
+ /** Id-only form of {@link findResumableAutoDetailed}. */
235
+ export async function findResumableAuto(cwd) {
236
+ return (await findResumableAutoDetailed(cwd))?.id ?? null;
222
237
  }
@@ -14,7 +14,8 @@ import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js'
14
14
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
15
15
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
16
16
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
17
- import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAuto } from './auto-io.js';
17
+ import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
18
+ import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
18
19
  import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
19
20
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
20
21
  import { readTextFile } from '../shared/fs-text.js';
@@ -1593,15 +1594,30 @@ async function handleTaskAuto(args, ctx) {
1593
1594
  disarmCancelListener();
1594
1595
  }
1595
1596
  }
1596
- async function handleTaskAutoResume(_args, ctx) {
1597
+ async function handleTaskAutoResume(args, ctx) {
1597
1598
  await ctx.waitForIdle();
1598
1599
  const cwd = ctx.cwd;
1599
- const id = await findResumableAuto(cwd);
1600
- if (!id) {
1601
- ctx.ui.notify('No resumable /task-auto run.', 'info');
1600
+ // `--unattended` is the boot-hook path: no human decided to continue this
1601
+ // run, so it resumes in-flight states only and refuses the rest by name.
1602
+ const unattended = /(^|\s)--unattended(\s|$)/.test(args);
1603
+ // Unattended asks a narrower question — "is there an IN-FLIGHT run?" — so it
1604
+ // searches those states directly. Picking the newest human-resumable run and
1605
+ // refusing it on state let a failed run shadow an in-flight one behind it.
1606
+ // With nothing in flight, fall back to the newest resumable run so the refusal
1607
+ // still names it instead of claiming there is nothing here.
1608
+ const eligible = unattended ?
1609
+ await findResumableAutoDetailed(cwd, UNATTENDED_STATES)
1610
+ : await findResumableAutoDetailed(cwd);
1611
+ const candidate = eligible ?? (unattended ? await findResumableAutoDetailed(cwd) : null);
1612
+ const decision = decideResume(candidate, Date.now(), unattended);
1613
+ ctx.ui.notify(decision.banner, decision.level);
1614
+ // An unattended refusal happens with nobody watching the terminal — the
1615
+ // remote view is the only surface that will still be there in the morning.
1616
+ if (unattended)
1617
+ publishLifecycleNotice(decision.banner, decision.level);
1618
+ if (!decision.resume || !candidate)
1602
1619
  return;
1603
- }
1604
- ctx.ui.notify(`Resuming ${id}…`, 'info');
1620
+ const id = candidate.id;
1605
1621
  await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
1606
1622
  autoRunning = true;
1607
1623
  armTerminalCancel(ctx);
@@ -1670,7 +1686,8 @@ export function registerTaskAuto(pi) {
1670
1686
  handler: handleTaskAuto
1671
1687
  });
1672
1688
  registerBridgeCommand(pi, 'task-auto-resume', {
1673
- description: 'Resume the active /task-auto run.',
1689
+ description: 'Resume the active /task-auto run. Usage: /task-auto-resume [--unattended] '
1690
+ + '(--unattended is for boot hooks: in-flight runs only, never a failed one).',
1674
1691
  handler: handleTaskAutoResume
1675
1692
  });
1676
1693
  registerBridgeCommand(pi, 'task-auto-cancel', {
@@ -51,12 +51,28 @@ const VOLATILE = [
51
51
  [/\bpids?\s*[:=]?\s*\d+/g, '<pid>'],
52
52
  [/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]):\d+/g, '<addr>'],
53
53
  [/\bport\s*[:=]?\s*\d{2,5}\b/g, '<port>'],
54
- [/:\d{2,5}\b/g, ':<port>'],
55
54
  // Long hex / uuid-ish ids (sha, container id, request id).
56
55
  [/\b0x[0-9a-f]+\b/g, '<hex>'],
57
56
  [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g, '<uuid>'],
58
57
  [/\b[0-9a-f]{7,40}\b/g, '<hex>']
59
58
  ];
59
+ /**
60
+ * A bare `:NNNN` port, which the localhost/`port N` rules above do not reach (a
61
+ * failure that just says "no listener on :3000").
62
+ *
63
+ * NOT applied when the colon follows a SOURCE LOCATION — `src/db.ts:41`,
64
+ * `Cart.tsx:88:12`. A failure detail embeds the failing command's output tail
65
+ * verbatim (final-gate.ts `r.tail`), and tsc/eslint/bun-test tails are mostly
66
+ * file:line. Collapsing those made two DIFFERENT defects in one file — the second
67
+ * uncovered by fixing the first — compare equal, which reads as non-progress and
68
+ * demotes a genuinely fixable check to UNOBSERVED debt. A moved error is progress.
69
+ */
70
+ const BARE_PORT = /(\S*?):(\d{2,5})\b/g;
71
+ /** A path (has a separator) or a filename with an extension ⇒ a source location. */
72
+ const SOURCE_LOCATION = /[/\\]|\.[a-z][a-z0-9]{0,4}$/;
73
+ function collapseBarePorts(s) {
74
+ return s.replace(BARE_PORT, (whole, prefix) => SOURCE_LOCATION.test(prefix) ? whole : `${prefix}:<port>`);
75
+ }
60
76
  /**
61
77
  * Comparison key for a gate failure entry: lowercased, volatile substrings erased,
62
78
  * whitespace collapsed. Two entries with the same key are "the same failure" for
@@ -66,6 +82,7 @@ export function normalizeFailureDetail(detail) {
66
82
  let s = detail.toLowerCase();
67
83
  for (const [re, repl] of VOLATILE)
68
84
  s = s.replace(re, repl);
85
+ s = collapseBarePorts(s);
69
86
  return s.replace(/\s+/g, ' ').trim();
70
87
  }
71
88
  /** The ranked-first failure of a gate outcome (the list is ranked most load-bearing
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Resume gating & the honest resume banner.
3
+ *
4
+ * mx5 run 14 lost ~10 hours to dead air that looked like a stall: the host was
5
+ * powered off overnight, both containers stopped at 20:00Z, and the run picked
6
+ * up cleanly the moment they were restarted at 06:01Z. Nothing was wrong with
7
+ * the run — the only defect was that nobody could tell. Two things follow.
8
+ *
9
+ * (1) A restart-time resume can be automated (`restart: unless-stopped` on the
10
+ * containers plus a boot hook that runs `/task-auto-resume --unattended`), which
11
+ * turns that 10h of nothing into minutes.
12
+ *
13
+ * (2) An automated resume must not resume everything. `RESUMABLE_STATES`
14
+ * deliberately includes `failed` and `cancelled` because a HUMAN typing
15
+ * /task-auto-resume has decided to continue; a boot hook has decided nothing. A
16
+ * failed run stopped for a reason a power cycle does not clear, and re-entering
17
+ * it unattended burns the whole loop against the same wall. Unattended resume
18
+ * therefore covers in-flight states only (see UNATTENDED_STATES), and refuses
19
+ * the rest by name rather than silently doing nothing.
20
+ *
21
+ * The banner follows the honest-restart-hint rule (70a8497): say exactly what
22
+ * was observed and exactly what it does not tell you. All this process knows is
23
+ * when the AUTO file was last written — it cannot distinguish a stopped host
24
+ * from a hung child from a slow task, so it reports the gap and attributes no
25
+ * cause. It is equally careful about the tree: nothing is rolled back between
26
+ * the interruption and the resume.
27
+ */
28
+ import type { TaskState } from './task-types.js';
29
+ /** States an UNATTENDED resume may continue. In-flight only — see file header. */
30
+ export declare const UNATTENDED_STATES: TaskState[];
31
+ /** The most recent resumable AUTO run, with the two facts the banner needs. */
32
+ export interface AutoResumeCandidate {
33
+ id: string;
34
+ state: TaskState;
35
+ /** AUTO-file mtime: the last moment the run demonstrably wrote progress. */
36
+ lastWriteMs: number;
37
+ }
38
+ export interface ResumeDecision {
39
+ /** Whether the caller should proceed with the resume. */
40
+ resume: boolean;
41
+ /** The line to show the user (and publish to the remote view). */
42
+ banner: string;
43
+ level: 'info' | 'warning';
44
+ }
45
+ /** Human gap, coarsest-two-units ("10h 1m", "45s", "2d 4h"). */
46
+ export declare function formatGap(ms: number): string;
47
+ /**
48
+ * Gate an incoming resume and phrase it. Pure: the caller supplies the candidate
49
+ * and the clock. `unattended` is the boot-hook path (`--unattended`), which is
50
+ * the only one that refuses on state.
51
+ */
52
+ export declare function decideResume(candidate: AutoResumeCandidate | null, nowMs: number, unattended: boolean): ResumeDecision;
@@ -0,0 +1,67 @@
1
+ /** States an UNATTENDED resume may continue. In-flight only — see file header. */
2
+ export const UNATTENDED_STATES = ['in_progress'];
3
+ /**
4
+ * Below this the gap is ordinary hand-driven latency (you looked at the failure,
5
+ * then typed the command) and the unaccounted-time paragraph would be noise.
6
+ */
7
+ const GAP_NARRATION_MS = 5 * 60_000;
8
+ /** Human gap, coarsest-two-units ("10h 1m", "45s", "2d 4h"). */
9
+ export function formatGap(ms) {
10
+ const s = Math.floor(ms / 1000);
11
+ if (s < 60)
12
+ return `${s}s`;
13
+ const m = Math.floor(s / 60);
14
+ if (m < 60)
15
+ return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`;
16
+ const h = Math.floor(m / 60);
17
+ if (h < 24)
18
+ return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
19
+ const d = Math.floor(h / 24);
20
+ return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
21
+ }
22
+ /**
23
+ * What the resume says about the time it was away. Attributes no cause: a gap
24
+ * is wall-clock silence, and this process cannot see which of the several very
25
+ * different explanations produced it.
26
+ */
27
+ function gapClause(lastWriteMs, nowMs) {
28
+ const gap = nowMs - lastWriteMs;
29
+ const at = new Date(lastWriteMs).toISOString();
30
+ // A future mtime means the clock moved, not that the run wrote ahead of
31
+ // time; saying "0s ago" there would be the one thing this banner must not do.
32
+ if (gap < -60_000) {
33
+ return `its file is timestamped ${at}, which is in the future — the clock moved under it, so no gap can be measured`;
34
+ }
35
+ const shown = formatGap(Math.max(0, gap));
36
+ if (gap < GAP_NARRATION_MS)
37
+ return `last written ${shown} ago (${at})`;
38
+ return (`last written ${shown} ago (${at}). That gap is unaccounted-for wall time, not work: `
39
+ + `this process cannot tell a stopped host from a hung child from a slow task. `
40
+ + `Nothing was rolled back — the working tree is exactly as the interrupted run left it`);
41
+ }
42
+ /**
43
+ * Gate an incoming resume and phrase it. Pure: the caller supplies the candidate
44
+ * and the clock. `unattended` is the boot-hook path (`--unattended`), which is
45
+ * the only one that refuses on state.
46
+ */
47
+ export function decideResume(candidate, nowMs, unattended) {
48
+ if (!candidate) {
49
+ return { resume: false, banner: 'No resumable /task-auto run.', level: 'info' };
50
+ }
51
+ const { id, state, lastWriteMs } = candidate;
52
+ if (unattended && !UNATTENDED_STATES.includes(state)) {
53
+ return {
54
+ resume: false,
55
+ banner: `Not auto-resuming ${id}: state=${state}. Unattended resume continues `
56
+ + `in-flight runs (${UNATTENDED_STATES.join(', ')}) only — a ${state} run stopped `
57
+ + `for a reason a restart does not clear. Look at it, then resume by hand with `
58
+ + `/task-auto-resume.`,
59
+ level: 'warning'
60
+ };
61
+ }
62
+ return {
63
+ resume: true,
64
+ banner: `Resuming ${id} (state=${state}) — ${gapClause(lastWriteMs, nowMs)}.`,
65
+ level: 'info'
66
+ };
67
+ }
@@ -68,16 +68,22 @@ export function registerStreamWatchdog(pi) {
68
68
  pi.on('message_start', (_e, ctx) => arm(ctx));
69
69
  pi.on('message_update', (_e, ctx) => arm(ctx));
70
70
  pi.on('message_end', (_e, ctx) => arm(ctx));
71
- pi.on('tool_execution_start', (_e, ctx) => {
71
+ // Keyed by toolCallId: a tool BATCH runs in parallel, so the clock must stay
72
+ // paused until the last call settles, not the first (see StreamWatchdog.resume).
73
+ const callId = (e) => {
74
+ const id = e?.toolCallId;
75
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
76
+ };
77
+ pi.on('tool_execution_start', (e, ctx) => {
72
78
  liveCtx = ctx;
73
- watchdog.suspend();
79
+ watchdog.suspend(callId(e));
74
80
  });
75
81
  pi.on('tool_execution_update', (_e, ctx) => {
76
82
  liveCtx = ctx;
77
83
  });
78
- pi.on('tool_execution_end', (_e, ctx) => {
84
+ pi.on('tool_execution_end', (e, ctx) => {
79
85
  liveCtx = ctx;
80
- watchdog.resume();
86
+ watchdog.resume(callId(e));
81
87
  });
82
88
  // Nothing is streaming between agent loops; stop so no timer can fire into an
83
89
  // idle session (which would abort nothing and post a reminder to no one).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.41",
3
+ "version": "0.18.43",
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",