pi-taskflow 0.0.16 → 0.0.18

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.
@@ -21,7 +21,7 @@ export interface TaskflowSettings {
21
21
  maxRunAgeDays: number;
22
22
  }
23
23
 
24
- import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS } from "./store.ts";
24
+ import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, writeFileAtomic } from "./store.ts";
25
25
 
26
26
  export const DEFAULT_TASKFLOW_SETTINGS: TaskflowSettings = {
27
27
  builtInAgents: true,
@@ -63,12 +63,6 @@ export function shouldSyncBuiltinAgentsToProject(settings: TaskflowSettings = DE
63
63
  return settings.builtInAgents && settings.syncBuiltinAgentsToProject;
64
64
  }
65
65
 
66
- export interface AgentOverride {
67
- model?: string;
68
- thinking?: string;
69
- tools?: string[];
70
- }
71
-
72
66
  export interface AgentConfig {
73
67
  name: string;
74
68
  description: string;
@@ -120,16 +114,18 @@ function loadAgentsFromDir(dir: string, source: "user" | "project" | "built-in")
120
114
  if (!frontmatter.name || !frontmatter.description) continue;
121
115
 
122
116
  // frontmatter is YAML-parsed: tools may be a comma-separated string ("a, b")
123
- // OR a YAML sequence ([a, b]). Handle both forms.
117
+ // OR a YAML sequence ([a, b]). Handle both forms; reject other types to
118
+ // prevent garbage output from malformed YAML (e.g. boolean, number).
124
119
  const rawTools = frontmatter.tools;
125
- const tools: string[] | undefined = Array.isArray(rawTools)
126
- ? rawTools.map((t) => String(t).trim()).filter(Boolean)
127
- : rawTools !== undefined && rawTools !== null
128
- ? String(rawTools)
129
- .split(",")
130
- .map((t) => t.trim())
131
- .filter(Boolean)
132
- : undefined;
120
+ let tools: string[] | undefined;
121
+ if (Array.isArray(rawTools)) {
122
+ tools = rawTools.map((t) => String(t).trim()).filter(Boolean);
123
+ } else if (typeof rawTools === "string") {
124
+ tools = rawTools.split(",").map((t) => t.trim()).filter(Boolean);
125
+ } else if (rawTools !== undefined && rawTools !== null) {
126
+ console.warn(`[taskflow] Agent '${String(frontmatter.name)}': 'tools' must be a string or array, got ${typeof rawTools}. Ignoring.`);
127
+ tools = undefined;
128
+ }
133
129
 
134
130
  agents.push({
135
131
  name: String(frontmatter.name),
@@ -173,7 +169,6 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
173
169
  export function discoverAgents(
174
170
  cwd: string,
175
171
  scope: AgentScope,
176
- overrides?: Record<string, AgentOverride>,
177
172
  modelRoles?: Record<string, string>,
178
173
  taskflowSettings: TaskflowSettings = DEFAULT_TASKFLOW_SETTINGS,
179
174
  ): AgentDiscoveryResult {
@@ -202,23 +197,6 @@ export function discoverAgents(
202
197
  for (const a of projectAgents) agentMap.set(a.name, a);
203
198
  }
204
199
 
205
- if (overrides) {
206
- for (const [name, override] of Object.entries(overrides)) {
207
- const agent = agentMap.get(name);
208
- if (agent) {
209
- // Clone before mutating: agentMap owns the original AgentConfig
210
- // (loaded from disk in loadAgentsFromDir). Mutating it in place
211
- // would cause cross-contamination for any caller that retains a
212
- // reference and invokes discoverAgents again with different overrides.
213
- const mutated: AgentConfig = { ...agent };
214
- if (override.model !== undefined) mutated.model = override.model;
215
- if (override.thinking !== undefined) mutated.thinking = override.thinking;
216
- if (override.tools !== undefined) mutated.tools = override.tools;
217
- agentMap.set(name, mutated);
218
- }
219
- }
220
- }
221
-
222
200
  // Resolve {{role}} model references (e.g. {{fast}} → openrouter/deepseek/v4-flash)
223
201
  // Clone before mutating, consistent with the overrides block above.
224
202
  if (modelRoles) {
@@ -236,7 +214,6 @@ export function discoverAgents(
236
214
  }
237
215
 
238
216
  export interface SubagentSettings {
239
- agentOverrides?: Record<string, AgentOverride>;
240
217
  globalThinking?: string;
241
218
  modelRoles?: Record<string, string>;
242
219
  taskflow: TaskflowSettings;
@@ -261,7 +238,6 @@ export function readSubagentSettings(): SubagentSettings {
261
238
  if (!fs.existsSync(settingsPath)) return { taskflow: { ...DEFAULT_TASKFLOW_SETTINGS } };
262
239
  const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
263
240
  return {
264
- agentOverrides: raw.subagents?.agentOverrides,
265
241
  globalThinking: raw.subagents?.globalThinking ?? raw.defaultThinkingLevel,
266
242
  modelRoles: raw.modelRoles,
267
243
  taskflow: normalizeTaskflowSettings(raw.taskflow),
@@ -311,7 +287,7 @@ export function syncBuiltinAgentsToProject(cwd: string): void {
311
287
 
312
288
  try {
313
289
  const content = fs.readFileSync(src, "utf-8");
314
- fs.writeFileSync(dst, content, "utf-8");
290
+ writeFileAtomic(dst, content);
315
291
  } catch {
316
292
  // Best-effort: a locked file must not block the sync.
317
293
  }
@@ -47,9 +47,13 @@ function resolveOne(entry: string, cwd: string): string {
47
47
  cwd,
48
48
  encoding: "utf-8",
49
49
  stdio: ["ignore", "pipe", "ignore"],
50
+ timeout: 30_000,
50
51
  }).trim();
51
52
  return `git:${ref}=${sha}`;
52
- } catch {
53
+ } catch (e: unknown) {
54
+ if ((e as NodeJS.ErrnoException).code === "ETIMEDOUT") {
55
+ return `git:${ref}=<timeout>`;
56
+ }
53
57
  return `git:${ref}=<no-git>`;
54
58
  }
55
59
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * pi-taskflow — lightweight workflow orchestration for the Pi coding agent.
2
+ * pi-taskflow — a declarative, verifiable graph of task nodes for the Pi coding agent.
3
3
  *
4
4
  * Registers:
5
5
  * - tool `taskflow` : run inline / saved flows, save, resume (LLM-callable)
@@ -42,6 +42,7 @@ import {
42
42
  DEFAULT_RUN_AGE_DAYS,
43
43
  } from "./store.ts";
44
44
  import { CacheStore } from "./cache.ts";
45
+ import { safeParse } from "./interpolate.ts";
45
46
 
46
47
  interface TaskflowDetails {
47
48
  state?: RunState;
@@ -195,7 +196,7 @@ async function runFlow(
195
196
  cleanupConfig.maxKeep = settings.taskflow.maxKeptRuns;
196
197
  cleanupConfig.maxAgeDays = settings.taskflow.maxRunAgeDays;
197
198
  const scope: AgentScope = def.agentScope ?? "user";
198
- const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides, settings.modelRoles, settings.taskflow);
199
+ const { agents } = discoverAgents(ctx.cwd, scope, settings.modelRoles, settings.taskflow);
199
200
 
200
201
  // Hint: if any agent still has unresolved {{role}} references, suggest configuring modelRoles
201
202
  const unresolvedRoles = agents
@@ -324,7 +325,7 @@ export default function (pi: ExtensionAPI) {
324
325
  "Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
325
326
  ].join(" "),
326
327
  parameters: TaskflowParams,
327
- promptSnippet: "Orchestrate subagents single, parallel, chain, or DAG — with tracking, resume, and context isolation. Replaces the subagent tool.",
328
+ promptSnippet: "Declare a verifiable graph of subagent tasks (single, parallel, chain, or full DAG)tracked, resumable, context-isolated. The runtime validates the graph before running. Replaces the subagent tool.",
328
329
  promptGuidelines: [
329
330
  "BEFORE FIRST USE: invoke skill_load('taskflow') to read the full skill documentation (DSL syntax, phase types, examples, best practices). This tool description is a condensed reference only — the skill is the authoritative guide.\n\nUse taskflow for ALL delegation — single tasks, parallel, chain, or full DAG orchestration. It fully replaces the subagent tool: every delegation is tracked with a runId, resumable across sessions, context-isolated (only final output returns), and saveable as /tf:<name>. Do NOT call the subagent tool directly; use taskflow shorthand (task/tasks/chain) for simple cases instead.",
330
331
  "For complex multi-phase work (explore / 审计 / analyze the project, auditing endpoints, reviewing or migrating many files/modules, cross-checked research), use the full DSL with phases. For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
@@ -416,7 +417,7 @@ export default function (pi: ExtensionAPI) {
416
417
  if (action === "agents") {
417
418
  const scope = params.scope ?? "both";
418
419
  const settings2 = readSubagentSettings();
419
- const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined, settings2.modelRoles, settings2.taskflow);
420
+ const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, settings2.modelRoles, settings2.taskflow);
420
421
  const text = agents.length
421
422
  ? agents
422
423
  .map(
@@ -441,13 +442,18 @@ export default function (pi: ExtensionAPI) {
441
442
  const { verifyTaskflow } = await import("./verify.ts");
442
443
  // Load definition: inline define takes priority, then saved name
443
444
  let def: Taskflow | undefined;
444
- if (params.define) {
445
- const d = params.define as Record<string, unknown>;
445
+ let resolvedDefine: unknown = params.define;
446
+ if (typeof resolvedDefine === "string") {
447
+ const parsed = safeParse(resolvedDefine);
448
+ if (parsed && typeof parsed === "object") resolvedDefine = parsed;
449
+ }
450
+ if (resolvedDefine) {
451
+ const d = resolvedDefine as Record<string, unknown>;
446
452
  if (typeof d === "object" && d !== null && Array.isArray(d.phases)) {
447
453
  def = d as unknown as Taskflow;
448
- } else if (isShorthand(params.define)) {
449
- const r = validateTaskflow(params.define);
450
- if (r.ok) def = params.define as unknown as Taskflow;
454
+ } else if (isShorthand(resolvedDefine)) {
455
+ const r = validateTaskflow(resolvedDefine);
456
+ if (r.ok) def = resolvedDefine as unknown as Taskflow;
451
457
  }
452
458
  } else if (params.name) {
453
459
  const saved = getFlow(ctx.cwd, params.name);
@@ -505,9 +511,25 @@ export default function (pi: ExtensionAPI) {
505
511
  // resolve the definition: inline `define` / shorthand (single|parallel|chain), else saved `name`.
506
512
  let def: Taskflow | undefined;
507
513
 
514
+ // Auto-parse string `define` — LLMs sometimes pass a JSON string
515
+ // instead of a parsed object. safeParse handles markdown fences too.
516
+ let resolvedDefine: unknown = params.define;
517
+ if (typeof resolvedDefine === "string") {
518
+ const parsed = safeParse(resolvedDefine);
519
+ if (parsed && typeof parsed === "object") {
520
+ resolvedDefine = parsed;
521
+ } else {
522
+ return errorResult(
523
+ action,
524
+ `'define' was passed as a string, not a JSON object. Pass it as a proper object, e.g.:\n` +
525
+ `define: {"name":"my-flow","phases":[{"id":"step1","task":"do something"}]}`,
526
+ );
527
+ }
528
+ }
529
+
508
530
  // A shorthand spec can come from `define` (no phases) or top-level params.
509
531
  const shorthandSpec: unknown =
510
- params.define ??
532
+ resolvedDefine ??
511
533
  (params.chain
512
534
  ? { chain: params.chain, name: params.name }
513
535
  : params.tasks
@@ -530,11 +552,25 @@ export default function (pi: ExtensionAPI) {
530
552
  def = candidate as Taskflow;
531
553
  } else if (params.name) {
532
554
  const saved = getFlow(ctx.cwd, params.name);
533
- if (!saved) return errorResult(action, `Saved flow not found: ${params.name}`);
555
+ if (!saved) {
556
+ const available = listFlows(ctx.cwd);
557
+ const hint = available.length
558
+ ? ` Available flows: ${available.map((f) => f.name).join(", ")}.`
559
+ : " No saved flows found. Use action=save to create one, or pass 'define' for an inline flow.";
560
+ return errorResult(action, `Saved flow '${params.name}' not found.${hint}`);
561
+ }
534
562
  def = saved.def;
535
563
  }
536
564
  if (!def)
537
- return errorResult(action, "Provide 'define' (DSL), shorthand 'task'/'tasks'/'chain', or 'name' (saved).");
565
+ return errorResult(
566
+ action,
567
+ `No taskflow definition provided. Use one of:\n` +
568
+ `- define: {"name":"...","phases":[...]} (inline DSL object)\n` +
569
+ `- task: "..." (shorthand single agent)\n` +
570
+ `- tasks: [{"task":"..."},...] (shorthand parallel)\n` +
571
+ `- chain: [{"task":"..."},...] (shorthand sequential)\n` +
572
+ `- name: "saved-flow-name" (run a previously saved flow)`,
573
+ );
538
574
 
539
575
  // save
540
576
  if (action === "save") {
@@ -562,7 +598,17 @@ export default function (pi: ExtensionAPI) {
562
598
  }
563
599
 
564
600
  // run
565
- const args = resolveArgs(def, params.args);
601
+ // Auto-parse string args LLMs sometimes pass a JSON string.
602
+ let resolvedArgs: Record<string, unknown> | undefined;
603
+ if (typeof params.args === "string") {
604
+ const parsed = safeParse(params.args);
605
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
606
+ resolvedArgs = parsed as Record<string, unknown>;
607
+ }
608
+ } else if (params.args && typeof params.args === "object") {
609
+ resolvedArgs = params.args as Record<string, unknown>;
610
+ }
611
+ const args = resolveArgs(def, resolvedArgs);
566
612
  const v = validateTaskflow(def, { args, cwd: ctx.cwd });
567
613
  if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
568
614
  for (const w of v.warnings) {
@@ -579,7 +625,14 @@ export default function (pi: ExtensionAPI) {
579
625
 
580
626
  renderCall(args, theme) {
581
627
  const action = args.action ?? "run";
582
- let label = args.name || (args.define as { name?: string } | undefined)?.name;
628
+ let label = args.name;
629
+ if (!label) {
630
+ let define = args.define;
631
+ if (typeof define === "string") {
632
+ try { define = JSON.parse(define); } catch { /* not JSON */ }
633
+ }
634
+ label = (define as { name?: string } | undefined)?.name;
635
+ }
583
636
  let suffix = "";
584
637
  const phases = (args.define as Taskflow | undefined)?.phases;
585
638
  if (phases) suffix = ` (${phases.length} phases)`;
@@ -613,7 +666,7 @@ export default function (pi: ExtensionAPI) {
613
666
  pi.registerCommand("tf", {
614
667
  description: "Taskflow: list | run <name> | show <name> | runs | init",
615
668
  getArgumentCompletions: (prefix) => {
616
- const subs = ["list", "run", "show", "runs", "resume", "init"];
669
+ const subs = ["list", "run", "show", "runs", "resume", "init", "save", "verify"];
617
670
  const items = subs.map((s) => ({ value: s, label: s }));
618
671
  const filtered = items.filter((i) => i.value.startsWith(prefix));
619
672
  return filtered.length > 0 ? filtered : null;
@@ -797,13 +850,13 @@ function parseArgsString(input: string, def: Taskflow): Record<string, unknown>
797
850
  }
798
851
  // key=value pairs
799
852
  const out: Record<string, unknown> = {};
800
- const pairs = trimmed.match(/(\w+)=("[^"]*"|\S+)/g);
853
+ const pairs = trimmed.match(/(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g);
801
854
  if (pairs) {
802
855
  for (const p of pairs) {
803
856
  const idx = p.indexOf("=");
804
857
  const k = p.slice(0, idx);
805
858
  let v: string = p.slice(idx + 1);
806
- if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
859
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\"/g, '"');
807
860
  out[k] = v;
808
861
  }
809
862
  return out;
@@ -66,7 +66,13 @@ function resolvePath(path: string, ctx: InterpolationContext): unknown {
66
66
  const step = stepId ? ctx.steps[stepId] : undefined;
67
67
  if (!step) return undefined;
68
68
  const field = parts[2];
69
- if (field === "output") return step.output;
69
+ if (field === "output") {
70
+ // Guard: {steps.X.output.trailing} — trailing segments after output are
71
+ // likely author errors (output is a string, not an object). Return
72
+ // undefined so the placeholder is left intact with a missing warning.
73
+ if (parts.length > 3) return undefined;
74
+ return step.output;
75
+ }
70
76
  if (field === "json") {
71
77
  const json = step.json ?? safeParse(step.output);
72
78
  return dig(json, parts.slice(3));
@@ -82,6 +88,12 @@ function resolvePath(path: string, ctx: InterpolationContext): unknown {
82
88
  return undefined;
83
89
  }
84
90
 
91
+ /**
92
+ * Traverse an object by a sequence of property keys. Returns `undefined`
93
+ * when any segment is missing or the current value is not an object —
94
+ * never throws, so extra path segments like {steps.X.json.a.b} where the
95
+ * data is shallower resolve gracefully to undefined (M-8).
96
+ */
85
97
  function dig(obj: unknown, parts: string[]): unknown {
86
98
  let cur: unknown = obj;
87
99
  for (const part of parts) {
@@ -219,10 +231,25 @@ function tokenize(input: string): Tok[] {
219
231
  }
220
232
  // quoted string
221
233
  if (c === '"' || c === "'") {
222
- const end = input.indexOf(c, i + 1);
223
- if (end === -1) throw new Error("unterminated string");
224
- toks.push({ t: "str", v: input.slice(i + 1, end) });
225
- i = end + 1;
234
+ // Handle escaped quotes. Note: ALL \X sequences are interpreted as literal X
235
+ // (including \n → n, \t → t). This differs from JSON/JS escaping but is
236
+ // correct for condition strings which only need quote escaping.
237
+ let j = i + 1;
238
+ let val = "";
239
+ while (j < n) {
240
+ if (input[j] === "\\" && j + 1 < n) {
241
+ val += input[j + 1];
242
+ j += 2;
243
+ } else if (input[j] === c) {
244
+ break;
245
+ } else {
246
+ val += input[j];
247
+ j++;
248
+ }
249
+ }
250
+ if (j >= n) throw new Error("unterminated string");
251
+ toks.push({ t: "str", v: val });
252
+ i = j + 1;
226
253
  continue;
227
254
  }
228
255
  // multi/single char operators
@@ -104,7 +104,7 @@ export function summarizeRun(state: RunState): string {
104
104
  const done = phases.filter((p) => p.status === "done").length;
105
105
  const failed = phases.filter((p) => p.status === "failed").length;
106
106
  const running = phases.filter((p) => p.status === "running").length;
107
- const total = state.def.phases.length;
107
+ const total = Object.keys(state.phases).length;
108
108
  const bits = [`${done}/${total} done`];
109
109
  if (running) bits.push(`${running} running`);
110
110
  if (failed) bits.push(`${failed} failed`);
@@ -254,7 +254,7 @@ function headerLine(state: RunState, theme: Theme): string {
254
254
  const done = phases.filter((p) => p.status === "done").length;
255
255
  const failed = phases.filter((p) => p.status === "failed").length;
256
256
  const running = phases.filter((p) => p.status === "running").length;
257
- const total = state.def.phases.length;
257
+ const total = Object.keys(state.phases).length;
258
258
 
259
259
  const head =
260
260
  state.status === "completed"
@@ -25,6 +25,8 @@ export interface RunResult {
25
25
  errorMessage?: string;
26
26
  /** Total subagent attempts incl. retries (set by the runtime's retry wrapper). */
27
27
  attempts?: number;
28
+ /** Set when the subagent was killed by the idle watchdog (not a user abort). */
29
+ idleTimeout?: boolean;
28
30
  }
29
31
 
30
32
  export interface LiveUpdate {
@@ -74,6 +76,8 @@ const TRANSIENT_ERROR_RE =
74
76
  /rate[_\s-]?limit|too\s+many\s+requests|overloaded|\b429\b|\b503\b|\b502\b|\b504\b|service\s+unavailable|temporarily\s+unavailable|timeout|timed?\s+out|econnreset|etimedout|socket\s+hang\s*up/i;
75
77
  export function isTransientError(r: RunResult): boolean {
76
78
  if (r.stopReason === "aborted") return false;
79
+ // Idle timeout is a deterministic stall — retrying won't help.
80
+ if (r.stopReason === "error" && r.idleTimeout) return false;
77
81
  const hay = `${r.errorMessage ?? ""} ${r.stderr ?? ""} ${r.output ?? ""}`;
78
82
  return TRANSIENT_ERROR_RE.test(hay);
79
83
  }
@@ -153,6 +157,8 @@ export interface EventAccumulator {
153
157
  stopReason?: string;
154
158
  errorMessage?: string;
155
159
  lastActivity: string;
160
+ /** Set when message cap was hit — output gets a truncation notice. */
161
+ truncated?: boolean;
156
162
  }
157
163
 
158
164
  export function newAccumulator(model?: string): EventAccumulator {
@@ -175,7 +181,15 @@ export function foldEventLine(acc: EventAccumulator, line: string): LiveUpdate |
175
181
  }
176
182
  if (event.type !== "message_end" || !event.message) return null;
177
183
  const msg = event.message as Message;
178
- acc.messages.push(msg);
184
+ // Cap prevents OOM from misconfigured loops. 500 messages is generous for
185
+ // normal subagent tasks (50 turns × 10 messages each). Messages beyond the
186
+ // cap are still parsed for usage/model/stopReason extraction.
187
+ const MAX_MESSAGES = 500;
188
+ if (acc.messages.length < MAX_MESSAGES) {
189
+ acc.messages.push(msg);
190
+ } else {
191
+ acc.truncated = true;
192
+ }
179
193
  if (msg.role !== "assistant") return null;
180
194
  acc.usage.turns++;
181
195
  const u = (msg as any).usage;
@@ -323,6 +337,7 @@ export async function runAgentTask(
323
337
 
324
338
  let wasAborted = false;
325
339
  let idleTimedOut = false;
340
+ let killedBySignal: string | undefined;
326
341
  const exitCode = await new Promise<number>((resolve) => {
327
342
  const invocation = getPiInvocation(args);
328
343
  const proc = spawn(invocation.command, invocation.args, {
@@ -371,12 +386,19 @@ export async function runAgentTask(
371
386
  buffer = lines.pop() || "";
372
387
  for (const line of lines) processLine(line);
373
388
  });
389
+ // Cap prevents OOM from verbose tool output (e.g., npm install). 64 KB is
390
+ // generous for error diagnosis while preventing memory exhaustion.
391
+ const STDERR_MAX_LEN = 64 * 1024;
374
392
  proc.stderr.on("data", (data) => {
375
393
  result.stderr += data.toString();
394
+ if (result.stderr.length >= STDERR_MAX_LEN) {
395
+ result.stderr = result.stderr.slice(0, STDERR_MAX_LEN) + "\n[...stderr truncated at 64KB]";
396
+ }
376
397
  });
377
- proc.on("close", (code) => {
398
+ proc.on("close", (code, signal) => {
378
399
  clearTimers();
379
400
  if (buffer.trim()) processLine(buffer);
401
+ if (code === null && signal) killedBySignal = signal;
380
402
  resolve(code ?? 0);
381
403
  });
382
404
  proc.on("error", (err) => {
@@ -411,11 +433,25 @@ export async function runAgentTask(
411
433
  result.stopReason = acc.stopReason;
412
434
  result.errorMessage = acc.errorMessage;
413
435
  result.output = getFinalOutput(acc.messages);
436
+ // M-6: surface truncation when the message cap was hit so downstream
437
+ // phases and the user know output was cut short.
438
+ if (acc.truncated) {
439
+ result.output += "\n\n[...output truncated after 500 messages]";
440
+ }
441
+ // Signal kill detection: process exited 0 but was killed by a signal
442
+ // (e.g. OOM killer, cgroup limit). Treat as failure so the runtime's
443
+ // retry/fail handling doesn't silently accept a truncated result.
444
+ if (exitCode === 0 && killedBySignal && !idleTimedOut && !wasAborted) {
445
+ result.exitCode = 1;
446
+ result.stopReason = "error";
447
+ result.errorMessage = `Subagent killed by signal ${killedBySignal}`;
448
+ }
414
449
  if (idleTimedOut) {
415
450
  // Distinct, actionable signal: the child was killed for being idle, not
416
451
  // a user abort. stopReason "error" keeps it in the failed bucket so the
417
452
  // runtime's retry/fail handling treats it as a real failure.
418
453
  result.stopReason = "error";
454
+ result.idleTimeout = true;
419
455
  result.errorMessage = `Subagent stalled: no output for ${Math.round((opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS) / 1000)}s (idle timeout) — killed`;
420
456
  } else if (wasAborted) {
421
457
  result.stopReason = "aborted";
@@ -29,7 +29,7 @@ function statusBadge(status: RunState["status"], theme: Theme): string {
29
29
  }
30
30
 
31
31
  function timeAgo(ts: number): string {
32
- const s = Math.floor((Date.now() - ts) / 1000);
32
+ const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
33
33
  if (s < 60) return `${s}s ago`;
34
34
  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
35
35
  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
@@ -37,7 +37,7 @@ function timeAgo(ts: number): string {
37
37
  }
38
38
 
39
39
  function isResumable(r: RunState): boolean {
40
- return r.status === "paused" || r.status === "failed" || r.status === "blocked";
40
+ return r.status === "paused" || r.status === "failed";
41
41
  }
42
42
 
43
43
  export class RunHistoryComponent {