@mjasnikovs/pi-task 0.13.15 → 0.13.17

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.
@@ -26,6 +26,24 @@ export interface AutoDeps {
26
26
  * is returned verbatim when nothing readable is referenced.
27
27
  */
28
28
  export declare function expandFeatureMentions(cwd: string, feature: string): Promise<string>;
29
+ /**
30
+ * The @file references in the feature that point at a readable file on disk —
31
+ * the bare path tokens, deduped, in first-seen order. Unreadable mentions
32
+ * (typos, non-file @tokens) are dropped so we never advertise a missing file as
33
+ * an authoritative spec.
34
+ */
35
+ export declare function readableMentions(cwd: string, feature: string): Promise<string[]>;
36
+ /**
37
+ * Thread the feature's spec references into every decomposed task title.
38
+ * Decompose emits titles only, and a title is ALL a per-task pipeline ever sees
39
+ * — so without this the design doc the feature pointed at is invisible
40
+ * downstream and each task drifts to a generic reading of its one-line title
41
+ * (this is how an "Implement @design.md" run built a generic `posts` table the
42
+ * spec never mentioned). Appending the @refs makes the per-task refine/research
43
+ * read the real spec and treat it as authoritative over the title. No readable
44
+ * refs → titles unchanged, so a doc-less /task-auto behaves exactly as before.
45
+ */
46
+ export declare function attachSpecRefs(titles: string[], refs: string[]): string[];
29
47
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
30
48
  export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
31
49
  export declare function requestAutoCancel(): void;
@@ -51,6 +51,47 @@ export async function expandFeatureMentions(cwd, feature) {
51
51
  }
52
52
  return blocks.length === 0 ? feature : `${feature.trim()}\n\n${blocks.join('\n\n')}`;
53
53
  }
54
+ /**
55
+ * The @file references in the feature that point at a readable file on disk —
56
+ * the bare path tokens, deduped, in first-seen order. Unreadable mentions
57
+ * (typos, non-file @tokens) are dropped so we never advertise a missing file as
58
+ * an authoritative spec.
59
+ */
60
+ export async function readableMentions(cwd, feature) {
61
+ const out = [];
62
+ const seen = new Set();
63
+ for (const m of feature.matchAll(MENTION_RE)) {
64
+ const rel = m[1];
65
+ if (seen.has(rel))
66
+ continue;
67
+ seen.add(rel);
68
+ try {
69
+ await fsp.access(path.resolve(cwd, rel));
70
+ out.push(rel);
71
+ }
72
+ catch {
73
+ // not a readable file — don't thread it into task titles
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * Thread the feature's spec references into every decomposed task title.
80
+ * Decompose emits titles only, and a title is ALL a per-task pipeline ever sees
81
+ * — so without this the design doc the feature pointed at is invisible
82
+ * downstream and each task drifts to a generic reading of its one-line title
83
+ * (this is how an "Implement @design.md" run built a generic `posts` table the
84
+ * spec never mentioned). Appending the @refs makes the per-task refine/research
85
+ * read the real spec and treat it as authoritative over the title. No readable
86
+ * refs → titles unchanged, so a doc-less /task-auto behaves exactly as before.
87
+ */
88
+ export function attachSpecRefs(titles, refs) {
89
+ if (refs.length === 0)
90
+ return titles;
91
+ const list = refs.map(r => '@' + r).join(' ');
92
+ const suffix = ` | spec: ${list} — authoritative; read it and follow it over this title wherever they differ`;
93
+ return titles.map(t => (t.includes('| spec:') ? t : t + suffix));
94
+ }
54
95
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
55
96
  export async function planAuto(ctx, cwd, feature, deps) {
56
97
  // clarify — sequential & adaptive: ask one question at a time, feeding every
@@ -138,7 +179,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
138
179
  const clarifications = answers.join('\n');
139
180
  // decompose
140
181
  const listRaw = await deps.runChild('auto-decompose', 'read', AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications));
141
- const titles = parseDecomposeList(listRaw);
182
+ // Thread the feature's spec doc(s) into every title so each per-task
183
+ // pipeline — which only ever sees its title — reads the real spec instead of
184
+ // a lossy one-line paraphrase of it.
185
+ const refs = await readableMentions(cwd, feature);
186
+ const titles = attachSpecRefs(parseDecomposeList(listRaw), refs);
142
187
  if (titles.length === 0) {
143
188
  announceDone(ctx, '/task-auto: no tasks produced from the feature.', 'warning');
144
189
  return null;
@@ -304,7 +349,11 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
304
349
  }
305
350
  if (!res.ok) {
306
351
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
307
- announceDone(active, `${id} stopped at "${next.title}" fix and run /task-auto-resume.`, 'error');
352
+ // res.reason is set when the implementation turn itself died
353
+ // (e.g. a context-overflow 400) — surface it so the real cause
354
+ // isn't lost behind the generic "stopped" message.
355
+ const why = res.reason ? ` — ${res.reason.slice(0, 160)}` : '';
356
+ announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
308
357
  return;
309
358
  }
310
359
  // res.ok === true means runner.run() completed, so res.taskId is the
@@ -33,6 +33,7 @@ fork the breakdown). Account for the answers so far:
33
33
  time (file/blob storage, client/rendering strategy, auth and session model,
34
34
  real-time vs polling transport, search, deployment).
35
35
  - Skip anything /task will naturally resolve per-task during its own research.
36
+ - Stay grounded in the referenced spec. If a design/spec doc is included above, do NOT propose a new subsystem, dependency, or requirement it does not call for, and do NOT re-ask a choice the spec already settles. Ask only about genuine forks the spec leaves open.
36
37
 
37
38
  YOU MUST propose a default answer for the question — every question you emit
38
39
  carries exactly one SUGGESTED line. Never omit it, never leave it blank, never
@@ -52,6 +52,7 @@ export type { PhaseDeps };
52
52
  * leaking, throw LeakedToolCallError rather than returning the unexecuted call.
53
53
  */
54
54
  export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
55
+ export declare function formatLoopHint(hit: LoopHit): string;
55
56
  export declare function prependHint(hint: string | null, prompt: string): string;
56
57
  /**
57
58
  * Run a phase child with loop detection. On a detected loop, kill and re-spawn
@@ -119,7 +119,7 @@ export async function runPhaseChild(deps, name, tools, prompt) {
119
119
  // Unreachable: the loop returns clean text or throws on the final leak.
120
120
  throw new LeakedToolCallError(name, '(unknown)');
121
121
  }
122
- function formatLoopHint(hit) {
122
+ export function formatLoopHint(hit) {
123
123
  const argsStr = JSON.stringify(hit.call.args);
124
124
  return (`[SYSTEM NOTE: Your prior attempt called ${hit.call.name}(${argsStr}) `
125
125
  + `${hit.count} times in the last ${hit.windowSize} tool calls — you appeared to be `
@@ -108,6 +108,14 @@ export interface RunSingleTaskResult {
108
108
  * uninterrupted.
109
109
  */
110
110
  interrupted?: boolean;
111
+ /**
112
+ * Why the run is not ok, when known. Set when a waitForImplementation turn
113
+ * ended with stopReason "error" (the model/provider died mid-implementation —
114
+ * e.g. a context-overflow 400 — after the task file was already marked
115
+ * `completed` at spec-handoff). The /task-auto loop surfaces this in its
116
+ * "stopped at …" message so the real cause isn't lost. Undefined otherwise.
117
+ */
118
+ reason?: string;
111
119
  }
112
120
  /**
113
121
  * Run one prompt through the full single-task pipeline in a fresh session and
@@ -288,6 +288,29 @@ function wasInterrupted(ctx) {
288
288
  }
289
289
  return false;
290
290
  }
291
+ /**
292
+ * The error cause when the most recent assistant turn ended with stopReason
293
+ * "error" — the model/provider failed mid-implementation (context overflow,
294
+ * disconnect, provider 5xx) after pi exhausted its own retries. Returns the
295
+ * provider's errorMessage, or undefined if the turn ended cleanly ("stop") or
296
+ * was user-aborted ("aborted", handled by wasInterrupted).
297
+ *
298
+ * The /task-auto loop reads this AFTER the implementation wait: a task's file is
299
+ * marked `completed` at spec-handoff (before implementation), so without this
300
+ * check an implementation turn that died — e.g. "400 ... exceeds the available
301
+ * context size" — would still read as ok and get checked off and committed.
302
+ */
303
+ function implementationError(ctx) {
304
+ const entries = ctx.sessionManager.getEntries();
305
+ for (let i = entries.length - 1; i >= 0; i--) {
306
+ const e = entries[i];
307
+ if ('message' in e && 'role' in e.message && e.message.role === 'assistant') {
308
+ const m = e.message;
309
+ return m.stopReason === 'error' ? (m.errorMessage ?? 'model error') : undefined;
310
+ }
311
+ }
312
+ return undefined;
313
+ }
291
314
  /**
292
315
  * After the implementation turn settles, honour a user ESC by letting them steer.
293
316
  *
@@ -326,6 +349,11 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
326
349
  // cancellation path (where no replacement occurs).
327
350
  let freshCtx = ctx;
328
351
  let interrupted = false;
352
+ // The implementation turn's failure cause, when it ended with stopReason
353
+ // "error" (only meaningful in the waitForImplementation path). The task file
354
+ // was already marked `completed` at spec-handoff, so this is the only signal
355
+ // that the implementation itself died and the task must not be checked off.
356
+ let implError;
329
357
  const result = await ctx.newSession({
330
358
  withSession: async (newCtx) => {
331
359
  freshCtx = newCtx;
@@ -335,6 +363,10 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
335
363
  if (opts.waitForImplementation) {
336
364
  await newCtx.waitForIdle();
337
365
  interrupted = await steerUntilDone(newCtx, opts.promptSteer);
366
+ // A user-declined steer (interrupted) is its own paused
367
+ // path; otherwise inspect how the turn actually ended.
368
+ if (!interrupted)
369
+ implError = implementationError(newCtx);
338
370
  }
339
371
  }, opts.spawnFn, opts.onStart);
340
372
  await runner.run();
@@ -360,13 +392,18 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
360
392
  ok = false;
361
393
  }
362
394
  }
395
+ // The file reads `completed` from spec-handoff even when the implementation
396
+ // turn then died. Demote to a failure so /task-auto stops here (leaving the
397
+ // entry unchecked and resumable) instead of committing and advancing.
398
+ if (ok && implError)
399
+ ok = false;
363
400
  if (opts.notifyFinish) {
364
401
  // One push per top-level /task or /task-resume, on any terminal end. The
365
402
  // file state is the source of truth: 'completed' on success, 'failed' /
366
403
  // 'cancelled' otherwise; an unreadable/absent file falls back to 'ended'.
367
404
  void pushNotify('Task finished', `${taskId || 'Task'} ${state ?? 'ended'}.`, 'pi-end').catch(() => { });
368
405
  }
369
- return { taskId, ok, sessionCancelled: false, ctx: freshCtx, interrupted };
406
+ return { taskId, ok, sessionCancelled: false, ctx: freshCtx, interrupted, reason: implError };
370
407
  }
371
408
  // ─── Command handlers ────────────────────────────────────────────────────────
372
409
  async function handleTask(args, ctx) {
@@ -179,6 +179,20 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
179
179
  // mirrors workerSpecs order — the loop above pushes in sequence).
180
180
  const sections = workerSpecs.map((spec, i) => ({ name: spec.section, result: workerResults[i] }));
181
181
  for (const { name, result } of sections) {
182
+ // Loop/timeout exhaustion is checked before the generic exit-code branch:
183
+ // a loop-kill arrives as a SIGTERM (exit 143) AND sometimes as a clean
184
+ // exit 0 with partial text, so keying off exitCode alone would either give
185
+ // a useless "exit 143" message or silently accept truncated output. The
186
+ // worker already burned its MAX_LOOP_RESTARTS restarts before setting these.
187
+ if (result.loopHit) {
188
+ const argsStr = JSON.stringify(result.loopHit.call.args);
189
+ throw new Error(`Research ${name} worker stuck in a loop — called `
190
+ + `${result.loopHit.call.name}(${argsStr}) ×${result.loopHit.count} in the last `
191
+ + `${result.loopHit.windowSize} calls and still looped after restarts`);
192
+ }
193
+ if (result.timedOut) {
194
+ throw new Error(`Research ${name} worker timed out after restarts`);
195
+ }
182
196
  if (result.exitCode !== 0) {
183
197
  throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
184
198
  }
@@ -54,6 +54,7 @@ Rules:
54
54
  - Fix spelling and grammar; output in English regardless of input language.
55
55
  - Preserve every concrete identifier verbatim (paths, function names, ports, env vars, file:line refs).
56
56
  - Do not invent requirements not implied by the input.
57
+ - If the task references a design/spec document (an @-path or a named spec file), READ it and treat it as authoritative. Carry its concrete schema verbatim into GOAL/CONSTRAINTS — table and column names, types, endpoint methods and paths, enum values. The task title is only a pointer into that spec: where the title and the spec disagree, follow the spec, and never introduce a table, column, endpoint, or dependency the spec does not define.
57
58
  - Do not output any preamble, commentary, or markdown headings beyond the four sections above.
58
59
 
59
60
  Task: ${raw}`;
@@ -73,6 +74,8 @@ const RESEARCH_FILES_PROMPT = (refined) => `You are doing targeted research for
73
74
 
74
75
  FILES owns paths. APIS owns symbols. Do not omit a path because it "feels like config" — if the agent will touch or read it, list it here.
75
76
 
77
+ If the task references a design/spec document (an @-path or a named spec file), OPEN it first and list it — it is the contract for this task, and the schema, names, and endpoints it defines are authoritative over any paraphrase in the task title.
78
+
76
79
  When a task operates across a whole directory tree (e.g. lint, typecheck, build, format, test-all), list the root directory entry (\`src/ one-line purpose\`) instead of enumerating every file under it. Enumerate individual files only when they need to be singled out — modified specifically, called out by name in the task, or distinct from their siblings in some material way.
77
80
 
78
81
  RELEVANCE — read carefully: list ONLY paths this specific task touches — the files the agent will read, edit, or must be aware of to complete THIS task. Do NOT inventory the whole repo or list files just because they exist. A file the agent will never open does not belong here. Aim for the smallest sufficient set: include every path the task genuinely reaches and nothing more. There is no fixed limit — a broad task may need many entries, a narrow one only a few. Right-size to the task, not to a number, and collapse directories to their root entry where the task spans a whole tree.
@@ -1,4 +1,5 @@
1
1
  import { type SpawnFn } from '../shared/child-process.js';
2
+ import { type LoopHit } from '../task/loop-detector.js';
2
3
  export interface RunWorkerInput {
3
4
  prompt: string;
4
5
  cwd: string;
@@ -10,6 +11,8 @@ export interface RunWorkerInput {
10
11
  extensions?: string[];
11
12
  /** Called for each tool execution start and text-writing event inside the worker. */
12
13
  onLine?: (line: string) => void;
14
+ /** Per-worker wall-clock timeout in ms. Defaults to RESEARCH_WORKER_TIMEOUT_MS. */
15
+ timeoutMs?: number;
13
16
  }
14
17
  export interface RunWorkerResult {
15
18
  text: string;
@@ -34,5 +37,18 @@ export interface RunWorkerResult {
34
37
  * failure rather than trusting the returned text.
35
38
  */
36
39
  leakedToolCall?: string;
40
+ /**
41
+ * Set when the worker was killed for looping (the same tool call repeated
42
+ * past threshold) and still looped after exhausting MAX_LOOP_RESTARTS
43
+ * restarts. The returned text is truncated mid-stream — the caller must treat
44
+ * this as a failure, not trust it.
45
+ */
46
+ loopHit?: LoopHit;
47
+ /**
48
+ * Set when the worker's final attempt hit the per-worker wall-clock timeout
49
+ * after exhausting its restart budget. Like loopHit, the text is partial and
50
+ * the caller must treat it as a failure.
51
+ */
52
+ timedOut?: boolean;
37
53
  }
38
54
  export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
@@ -1,6 +1,7 @@
1
1
  import { getPiInvocation } from '../shared/pi-invocation.js';
2
2
  import { CHILD_BASE_ARGS, runChildDefault } from '../shared/child-process.js';
3
3
  import { LoopDetector } from '../task/loop-detector.js';
4
+ import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
4
5
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
5
6
  // `--mode json` makes pi emit structured events as they happen instead of
6
7
  // buffering the assistant text and flushing on exit. That matters for the
@@ -10,33 +11,118 @@ import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../s
10
11
  // model starts producing — making waitMs the real queue/cold-start cost and
11
12
  // workMs the real generation+tool-call cost.
12
13
  const DEFAULT_TOOLS = 'read,grep,find,ls';
14
+ /**
15
+ * Hard wall-clock bound on a single research worker run (one spawn). The
16
+ * exact-match LoopDetector only catches *identical* repeated tool calls; a model
17
+ * that thrashes with slightly-varied calls (different grep patterns each time)
18
+ * slips past it and would otherwise run unbounded. This is the backstop for that
19
+ * case: after this long with no clean exit, abort and restart with a hint. Sized
20
+ * well above a healthy worker's observed runtime (~25-130s on the local backend)
21
+ * so it never trips a legitimately slow run.
22
+ */
23
+ const RESEARCH_WORKER_TIMEOUT_MS = 240_000;
24
+ /** Restart hint after a wall-clock timeout — distinct from the loop hint. */
25
+ const WORKER_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
26
+ + 'were exploring too long. Be decisive: do the minimum reads/greps needed, '
27
+ + 'then write your answer now. Do not re-explore ground you have already covered.]';
28
+ /**
29
+ * Combine an external abort signal with an internal wall-clock timeout into one
30
+ * signal, while keeping the two causes distinguishable: `timedOut()` is true
31
+ * only when the timer fired (not when the external signal aborted), so the caller
32
+ * can restart on a timeout but not on a user cancel.
33
+ */
34
+ function workerTimeout(external, ms) {
35
+ const ctrl = new AbortController();
36
+ let timedOut = false;
37
+ const timer = setTimeout(() => {
38
+ timedOut = true;
39
+ ctrl.abort();
40
+ }, ms);
41
+ const onExternal = () => ctrl.abort();
42
+ if (external) {
43
+ if (external.aborted)
44
+ ctrl.abort();
45
+ else
46
+ external.addEventListener('abort', onExternal, { once: true });
47
+ }
48
+ return {
49
+ signal: ctrl.signal,
50
+ timedOut: () => timedOut,
51
+ cleanup: () => {
52
+ clearTimeout(timer);
53
+ external?.removeEventListener('abort', onExternal);
54
+ }
55
+ };
56
+ }
13
57
  export async function runWorker(input) {
14
58
  const tools = input.tools ?? DEFAULT_TOOLS;
15
59
  const extensionArgs = (input.extensions ?? []).flatMap(e => ['-e', e]);
16
60
  const baseArgs = [...extensionArgs, ...CHILD_BASE_ARGS, '--mode', 'json', '--tools', tools];
61
+ const timeoutMs = input.timeoutMs ?? RESEARCH_WORKER_TIMEOUT_MS;
17
62
  let hint = null;
18
- for (let attempt = 0;; attempt++) {
63
+ // Loop-kill and timeout share one restart budget, mirroring
64
+ // runPhaseWithLoopGuard: a runaway worker gets re-spawned with a corrective
65
+ // hint up to MAX_LOOP_RESTARTS times before we give up. Leaked tool calls
66
+ // keep their own MAX_LEAK_RETRIES budget below — a different failure mode.
67
+ let restarts = 0;
68
+ let leakRetries = 0;
69
+ for (;;) {
19
70
  const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
20
71
  const invocation = getPiInvocation([...baseArgs, prompt]);
21
72
  const tStart = Date.now();
22
73
  let tFirstByte = null;
23
- const loopDetector = new LoopDetector(20, 5);
24
- const result = await runChildDefault(invocation, input.cwd, input.signal, {
25
- mode: 'json-events',
26
- onFirstByte: () => (tFirstByte = Date.now()),
27
- onToolCall: call => loopDetector.record(call),
28
- onLine: input.onLine
29
- }, input.spawn);
74
+ const loopDetector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
75
+ // Capture the hit the detector reports (it also returns it to the unified
76
+ // runner, which kills the child on a hit). Without capturing it here the
77
+ // SIGTERM that kill produces would surface as a bare non-zero exit the
78
+ // caller couldn't distinguish from a crash.
79
+ let loopHit;
80
+ const timeout = workerTimeout(input.signal, timeoutMs);
81
+ let result;
82
+ try {
83
+ result = await runChildDefault(invocation, input.cwd, timeout.signal, {
84
+ mode: 'json-events',
85
+ onFirstByte: () => (tFirstByte = Date.now()),
86
+ onToolCall: call => {
87
+ const hit = loopDetector.record(call);
88
+ if (hit && !loopHit)
89
+ loopHit = hit;
90
+ return hit;
91
+ },
92
+ onLine: input.onLine
93
+ }, input.spawn);
94
+ }
95
+ finally {
96
+ timeout.cleanup();
97
+ }
30
98
  const tEnd = Date.now();
31
99
  const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
32
100
  const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
33
101
  const text = result.text ?? '';
102
+ const timedOut = timeout.timedOut();
103
+ // A loop-kill gets the same restart-with-hint treatment every other phase
104
+ // already gets (runPhaseWithLoopGuard) — name the offending call so the
105
+ // re-spawn avoids it. Bounded by the shared restart budget.
106
+ if (loopHit && restarts < MAX_LOOP_RESTARTS) {
107
+ hint = formatLoopHint(loopHit);
108
+ restarts++;
109
+ continue;
110
+ }
111
+ // A wall-clock timeout (the backstop for varied thrash the exact-match
112
+ // detector misses) is also restartable, sharing the same budget. Skip when
113
+ // a loop also tripped — the loop hint above is more specific.
114
+ if (timedOut && !loopHit && restarts < MAX_LOOP_RESTARTS) {
115
+ hint = WORKER_TIMEOUT_HINT;
116
+ restarts++;
117
+ continue;
118
+ }
34
119
  // Only treat output as a leak on a clean, complete run — a non-zero exit
35
120
  // or abort yields partial text the caller already handles, and detecting
36
121
  // there would just mislabel the real failure.
37
122
  const leaked = result.exitCode === 0 && !result.aborted ? detectLeakedToolCall(text) : null;
38
- if (leaked && attempt < MAX_LEAK_RETRIES) {
123
+ if (leaked && leakRetries < MAX_LEAK_RETRIES) {
39
124
  hint = leakedToolCallHint(leaked);
125
+ leakRetries++;
40
126
  continue;
41
127
  }
42
128
  return {
@@ -46,7 +132,9 @@ export async function runWorker(input) {
46
132
  aborted: result.aborted,
47
133
  waitMs,
48
134
  workMs,
49
- ...(leaked ? { leakedToolCall: leaked } : {})
135
+ ...(leaked ? { leakedToolCall: leaked } : {}),
136
+ ...(loopHit ? { loopHit } : {}),
137
+ ...(timedOut ? { timedOut: true } : {})
50
138
  };
51
139
  }
52
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.15",
3
+ "version": "0.13.17",
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",