@dungle-scrubs/tallow 0.8.26 → 0.8.27

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.
Files changed (48) hide show
  1. package/dist/config.d.ts +1 -1
  2. package/dist/config.js +1 -1
  3. package/dist/interactive-mode-patch.d.ts +1 -0
  4. package/dist/interactive-mode-patch.d.ts.map +1 -1
  5. package/dist/interactive-mode-patch.js +40 -1
  6. package/dist/interactive-mode-patch.js.map +1 -1
  7. package/dist/pid-manager.d.ts +2 -9
  8. package/dist/pid-manager.d.ts.map +1 -1
  9. package/dist/pid-manager.js +1 -58
  10. package/dist/pid-manager.js.map +1 -1
  11. package/dist/pid-schema.d.ts +51 -0
  12. package/dist/pid-schema.d.ts.map +1 -0
  13. package/dist/pid-schema.js +70 -0
  14. package/dist/pid-schema.js.map +1 -0
  15. package/dist/sdk.js +4 -8
  16. package/dist/sdk.js.map +1 -1
  17. package/extensions/__integration__/audit-findings.test.ts +309 -0
  18. package/extensions/__integration__/tasks-runtime.test.ts +63 -12
  19. package/extensions/_shared/lazy-init.ts +88 -3
  20. package/extensions/_shared/pid-registry.ts +8 -82
  21. package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
  22. package/extensions/clear/__tests__/clear.test.ts +38 -0
  23. package/extensions/git-status/__tests__/git-status.test.ts +32 -0
  24. package/extensions/mcp-adapter-tool/index.ts +1 -1
  25. package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
  26. package/extensions/permissions/__tests__/permissions.test.ts +213 -0
  27. package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
  28. package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
  29. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
  30. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +5 -4
  31. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
  32. package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
  33. package/extensions/subagent-tool/formatting.ts +2 -0
  34. package/extensions/subagent-tool/index.ts +156 -95
  35. package/extensions/subagent-tool/process.ts +126 -32
  36. package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
  37. package/extensions/tasks/extension.json +1 -0
  38. package/extensions/tasks/index.ts +2 -12
  39. package/extensions/tasks/state/index.ts +26 -0
  40. package/extensions/teams-tool/dashboard.ts +13 -1
  41. package/extensions/teams-tool/tools/register-extension.ts +10 -2
  42. package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
  43. package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
  44. package/extensions/wezterm-notify/index.ts +5 -3
  45. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
  46. package/package.json +3 -2
  47. package/runtime/pid-schema.ts +13 -0
  48. package/skills/tallow-expert/SKILL.md +1 -1
@@ -50,6 +50,7 @@ import {
50
50
  applyBackgroundResultRetention,
51
51
  mapWithConcurrencyLimit,
52
52
  type OnUpdateCallback,
53
+ resolveRetryPhaseTimeoutMs,
53
54
  runSingleAgent,
54
55
  setPiRef,
55
56
  setTelemetryHandle,
@@ -181,6 +182,25 @@ export default function (pi: ExtensionAPI) {
181
182
  }
182
183
  });
183
184
 
185
+ // Kill all running background subagents on session shutdown (SIGTERM during user input).
186
+ // Unlike agent_end, this fires when the entire session is exiting — we don't need to
187
+ // retain results, just ensure orphaned subagent processes are terminated promptly.
188
+ pi.on("session_shutdown", async () => {
189
+ let mutated = false;
190
+ for (const [_id, bg] of backgroundSubagents) {
191
+ if (bg.status === "running" && bg.process && !bg.process.killed) {
192
+ bg.process.kill("SIGTERM");
193
+ bg.completedAt = Date.now();
194
+ bg.status = "failed";
195
+ bg.result.stopReason = "shutdown";
196
+ mutated = true;
197
+ }
198
+ }
199
+ if (mutated) {
200
+ publishSubagentSnapshot(pi.events);
201
+ }
202
+ });
203
+
184
204
  pi.registerTool({
185
205
  name: "subagent",
186
206
  label: "subagent",
@@ -674,6 +694,7 @@ async function executeParallel(
674
694
  const allResults: SingleResult[] = new Array(tasks.length);
675
695
 
676
696
  // Initialize placeholder results
697
+ const parallelStartTime = Date.now();
677
698
  for (let i = 0; i < tasks.length; i++) {
678
699
  allResults[i] = {
679
700
  agent: tasks[i].agent,
@@ -692,6 +713,7 @@ async function executeParallel(
692
713
  turns: 0,
693
714
  denials: 0,
694
715
  },
716
+ startTime: parallelStartTime,
695
717
  };
696
718
  }
697
719
 
@@ -788,12 +810,34 @@ async function executeParallel(
788
810
  const retrySummaryLines: string[] = [];
789
811
  const totalRetries = initialStalledIndexes.length;
790
812
 
813
+ // Cap the entire retry phase with a wall-clock timeout so N sequential
814
+ // retries × per-worker watchdog timeouts don't block the parent for 30+ min.
815
+ const retryPhaseTimeoutMs = resolveRetryPhaseTimeoutMs();
816
+ const retryAbort = new AbortController();
817
+ const retryTimer = setTimeout(() => retryAbort.abort(), retryPhaseTimeoutMs);
818
+ // Propagate parent abort to the retry phase.
819
+ const parentAbortHandler = signal ? () => retryAbort.abort() : undefined;
820
+ if (signal && parentAbortHandler) {
821
+ if (signal.aborted) retryAbort.abort();
822
+ else signal.addEventListener("abort", parentAbortHandler, { once: true });
823
+ }
824
+ const retrySignal = retryAbort.signal;
825
+
791
826
  try {
792
827
  ctx.ui.setWorkingMessage(
793
828
  `Rerunning ${totalRetries} stalled worker${totalRetries === 1 ? "" : "s"} individually`
794
829
  );
795
830
 
796
831
  for (let retryIndex = 0; retryIndex < initialStalledIndexes.length; retryIndex++) {
832
+ // Bail out if the retry phase deadline has elapsed.
833
+ if (retrySignal.aborted) {
834
+ for (let remaining = retryIndex; remaining < initialStalledIndexes.length; remaining++) {
835
+ const idx = initialStalledIndexes[remaining];
836
+ retrySummaryLines.push(`- [${tasks[idx].agent}] skipped (retry phase timeout)`);
837
+ }
838
+ break;
839
+ }
840
+
797
841
  const stalledIndex = initialStalledIndexes[retryIndex];
798
842
  const stalledTask = tasks[stalledIndex];
799
843
  const priorResult = allResults[stalledIndex];
@@ -810,33 +854,49 @@ async function executeParallel(
810
854
  `Retrying stalled worker ${retryIndex + 1}/${totalRetries}: ${stalledTask.agent}`
811
855
  );
812
856
 
813
- const retryResult = await runSingleAgent(
814
- ctx.cwd,
815
- agents,
816
- stalledTask.agent,
817
- retryTask,
818
- stalledTask.cwd,
819
- undefined,
820
- signal,
821
- (partial) => {
822
- if (partial.details?.results[0]) {
823
- const partialResult = {
824
- ...partial.details.results[0],
825
- task: stalledTask.task,
826
- };
827
- allResults[stalledIndex] = partialResult;
828
- emitParallelUpdate();
829
- }
830
- },
831
- makeDetails("parallel"),
832
- pi.events,
833
- undefined,
834
- explicitRetryModel,
835
- parentModelId,
836
- defaults,
837
- retryRoutingHints,
838
- stalledTask.isolation
839
- );
857
+ let retryResult: SingleResult;
858
+ try {
859
+ retryResult = await runSingleAgent(
860
+ ctx.cwd,
861
+ agents,
862
+ stalledTask.agent,
863
+ retryTask,
864
+ stalledTask.cwd,
865
+ undefined,
866
+ retrySignal,
867
+ (partial) => {
868
+ if (partial.details?.results[0]) {
869
+ const partialResult = {
870
+ ...partial.details.results[0],
871
+ task: stalledTask.task,
872
+ };
873
+ allResults[stalledIndex] = partialResult;
874
+ emitParallelUpdate();
875
+ }
876
+ },
877
+ makeDetails("parallel"),
878
+ pi.events,
879
+ undefined,
880
+ explicitRetryModel,
881
+ parentModelId,
882
+ defaults,
883
+ retryRoutingHints,
884
+ stalledTask.isolation
885
+ );
886
+ } catch {
887
+ // Retry-phase timeout aborted this worker. Mark remaining
888
+ // workers as skipped and exit the loop.
889
+ retrySummaryLines.push(`- [${stalledTask.agent}] aborted (retry phase timeout)`);
890
+ for (
891
+ let remaining = retryIndex + 1;
892
+ remaining < initialStalledIndexes.length;
893
+ remaining++
894
+ ) {
895
+ const idx = initialStalledIndexes[remaining];
896
+ retrySummaryLines.push(`- [${tasks[idx].agent}] skipped (retry phase timeout)`);
897
+ }
898
+ break;
899
+ }
840
900
 
841
901
  retryResult.task = stalledTask.task;
842
902
  retryResult.stderr = appendStderrNote(
@@ -848,6 +908,10 @@ async function executeParallel(
848
908
  emitParallelUpdate();
849
909
  }
850
910
  } finally {
911
+ clearTimeout(retryTimer);
912
+ if (signal && parentAbortHandler) {
913
+ signal.removeEventListener("abort", parentAbortHandler);
914
+ }
851
915
  ctx.ui.setWorkingMessage();
852
916
  }
853
917
 
@@ -1066,8 +1130,6 @@ interface DisplayRenderOptions {
1066
1130
  * Shared preview budgets for compact subagent presentation lines.
1067
1131
  */
1068
1132
  const SUBAGENT_PREVIEW_LIMITS = {
1069
- callCentipedeStep: 90,
1070
- callParallelTask: 90,
1071
1133
  collapsedParallelResult: 88,
1072
1134
  } as const;
1073
1135
 
@@ -1333,65 +1395,27 @@ function renderSubagentCall(args: Record<string, unknown>, theme: Theme) {
1333
1395
  args.tasks as { agent: string; model?: string; task: string }[] | string | undefined
1334
1396
  );
1335
1397
  const lines: string[] = [];
1398
+ // Only show scope when non-default (user is the default)
1399
+ const scopeEntry = scope !== "user" ? `scope:${scope}` : undefined;
1336
1400
 
1337
1401
  if (centipedeArr && centipedeArr.length > 0) {
1338
1402
  appendSection(lines, [formatSubagentHeader(theme, `centipede (${centipedeArr.length} steps)`)]);
1339
- const metaLine = formatMetaLine(theme, [
1340
- `scope:${scope}`,
1341
- model ? `model:${model}` : undefined,
1342
- ]);
1403
+ const metaLine = formatMetaLine(theme, [scopeEntry, model ? `model:${model}` : undefined]);
1343
1404
  if (metaLine) appendSection(lines, [metaLine]);
1344
-
1345
- const previewLines = centipedeArr.slice(0, 3).map((step, index) => {
1346
- const task = step.task.replace(/\{previous\}/g, "").trim();
1347
- const preview = toCompactPreview(
1348
- task || "(uses previous output)",
1349
- SUBAGENT_PREVIEW_LIMITS.callCentipedeStep
1350
- );
1351
- const modelTag = formatModelTag(theme, step.model);
1352
- const identity = modelTag
1353
- ? `${formatSubagentIdentity(step.agent)} ${modelTag}`
1354
- : formatSubagentIdentity(step.agent);
1355
- return `${formatPresentationText(theme, "meta", `${index + 1}.`)} ${identity} ${formatPresentationText(theme, "process_output", preview)}`;
1356
- });
1357
- if (previewLines.length > 0) appendSection(lines, previewLines, { blankBefore: true });
1358
- if (centipedeArr.length > 3) {
1359
- appendSection(lines, [
1360
- formatPresentationText(theme, "hint", `… +${centipedeArr.length - 3} more steps`),
1361
- ]);
1362
- }
1363
1405
  return new Text(lines.join("\n"), 0, 0);
1364
1406
  }
1365
1407
 
1366
1408
  if (tasksArr && tasksArr.length > 0) {
1367
1409
  appendSection(lines, [formatSubagentHeader(theme, `parallel (${tasksArr.length} tasks)`)]);
1368
- const metaLine = formatMetaLine(theme, [
1369
- `scope:${scope}`,
1370
- model ? `model:${model}` : undefined,
1371
- ]);
1410
+ const metaLine = formatMetaLine(theme, [scopeEntry, model ? `model:${model}` : undefined]);
1372
1411
  if (metaLine) appendSection(lines, [metaLine]);
1373
-
1374
- const previewLines = tasksArr.slice(0, 2).map((task, index) => {
1375
- const taskPreview = toCompactPreview(task.task, SUBAGENT_PREVIEW_LIMITS.callParallelTask);
1376
- const modelTag = formatModelTag(theme, task.model);
1377
- const identity = modelTag
1378
- ? `${formatSubagentIdentity(task.agent)} ${modelTag}`
1379
- : formatSubagentIdentity(task.agent);
1380
- return `${formatPresentationText(theme, "meta", `${index + 1}.`)} ${identity} ${formatPresentationText(theme, "process_output", taskPreview)}`;
1381
- });
1382
- if (previewLines.length > 0) appendSection(lines, previewLines, { blankBefore: true });
1383
- if (tasksArr.length > 2) {
1384
- appendSection(lines, [
1385
- formatPresentationText(theme, "hint", `… +${tasksArr.length - 2} more tasks`),
1386
- ]);
1387
- }
1388
1412
  return new Text(lines.join("\n"), 0, 0);
1389
1413
  }
1390
1414
 
1391
1415
  const agentName = (args.agent as string) || "...";
1392
1416
  const task = typeof args.task === "string" ? args.task : "...";
1393
1417
  appendSection(lines, [formatSubagentHeader(theme, "single", agentName)]);
1394
- const metaLine = formatMetaLine(theme, [`scope:${scope}`, model ? `model:${model}` : undefined]);
1418
+ const metaLine = formatMetaLine(theme, [scopeEntry, model ? `model:${model}` : undefined]);
1395
1419
  if (metaLine) appendSection(lines, [metaLine]);
1396
1420
  appendSection(
1397
1421
  lines,
@@ -1523,7 +1547,14 @@ function renderSingleResult(
1523
1547
  : isError
1524
1548
  ? theme.fg("error", getIcon("error"))
1525
1549
  : theme.fg("success", getIcon("success"));
1526
- const statusLabel = isRunning ? "running" : isError ? "failed" : "completed";
1550
+ const statusLabel =
1551
+ isRunning && r.startTime
1552
+ ? `running ${formatDuration(Date.now() - r.startTime)}`
1553
+ : isRunning
1554
+ ? "running"
1555
+ : isError
1556
+ ? "failed"
1557
+ : "completed";
1527
1558
  const headerLine = formatSubagentHeader(theme, statusLabel, r.agent, icon);
1528
1559
  const metaLine = formatMetaLine(theme, [
1529
1560
  `source:${r.agentSource}`,
@@ -1727,17 +1758,25 @@ function renderCentipedeResult(
1727
1758
  : failCount > 0
1728
1759
  ? theme.fg("error", getIcon("error"))
1729
1760
  : theme.fg("success", getIcon("success"));
1761
+ const earliestStart = details.results.reduce(
1762
+ (min, r) => (r.startTime && r.startTime < min ? r.startTime : min),
1763
+ Number.POSITIVE_INFINITY
1764
+ );
1765
+ const elapsed = Number.isFinite(earliestStart)
1766
+ ? formatDuration(Date.now() - earliestStart)
1767
+ : undefined;
1730
1768
  const summaryLine = formatMetaLine(theme, [
1731
1769
  `${successCount + failCount}/${totalSteps} done`,
1732
1770
  runningCount > 0 ? `${runningCount} running` : undefined,
1733
1771
  failCount > 0 ? `${failCount} failed` : undefined,
1772
+ elapsed,
1734
1773
  ]);
1735
1774
 
1736
1775
  if (expanded) {
1737
1776
  const container = new Container();
1738
- const headerLines: string[] = [formatSubagentHeader(theme, "centipede", undefined, icon)];
1739
- if (summaryLine) appendSection(headerLines, [summaryLine]);
1740
- container.addChild(new Text(headerLines.join("\n"), 0, 0));
1777
+ const headerLines: string[] = [];
1778
+ if (summaryLine) headerLines.push(`${icon} ${summaryLine}`);
1779
+ if (headerLines.length > 0) container.addChild(new Text(headerLines.join("\n"), 0, 0));
1741
1780
 
1742
1781
  for (let si = 0; si < totalSteps; si++) {
1743
1782
  const stepNum = si + 1;
@@ -1746,11 +1785,13 @@ function renderCentipedeResult(
1746
1785
  stepResult?.agent ?? details.centipedeSteps?.[si]?.agent ?? `step ${stepNum}`;
1747
1786
  const stepStatus = !stepResult
1748
1787
  ? "pending"
1749
- : stepResult.exitCode === -1
1750
- ? "running"
1751
- : isResultError(stepResult)
1752
- ? "failed"
1753
- : "completed";
1788
+ : stepResult.exitCode === -1 && stepResult.startTime
1789
+ ? `running ${formatDuration(Date.now() - stepResult.startTime)}`
1790
+ : stepResult.exitCode === -1
1791
+ ? "running"
1792
+ : isResultError(stepResult)
1793
+ ? "failed"
1794
+ : "completed";
1754
1795
  const stepStatusRole = !stepResult
1755
1796
  ? "meta"
1756
1797
  : stepResult.exitCode === -1
@@ -1839,8 +1880,8 @@ function renderCentipedeResult(
1839
1880
  return container;
1840
1881
  }
1841
1882
 
1842
- const lines: string[] = [formatSubagentHeader(theme, "centipede", undefined, icon)];
1843
- if (summaryLine) appendSection(lines, [summaryLine]);
1883
+ const lines: string[] = [];
1884
+ if (summaryLine) lines.push(`${icon} ${summaryLine}`);
1844
1885
 
1845
1886
  for (let si = 0; si < totalSteps; si++) {
1846
1887
  const stepNum = si + 1;
@@ -1851,11 +1892,13 @@ function renderCentipedeResult(
1851
1892
  const stem = isLast ? " " : `${formatPresentationText(theme, "meta", "│")} `;
1852
1893
  const stepStatus = !stepResult
1853
1894
  ? "pending"
1854
- : stepResult.exitCode === -1
1855
- ? "running"
1856
- : isResultError(stepResult)
1857
- ? "failed"
1858
- : "done";
1895
+ : stepResult.exitCode === -1 && stepResult.startTime
1896
+ ? `running ${formatDuration(Date.now() - stepResult.startTime)}`
1897
+ : stepResult.exitCode === -1
1898
+ ? "running"
1899
+ : isResultError(stepResult)
1900
+ ? "failed"
1901
+ : "done";
1859
1902
  const statusRole = !stepResult
1860
1903
  ? "meta"
1861
1904
  : stepResult.exitCode === -1
@@ -1922,23 +1965,36 @@ function renderParallelResult(
1922
1965
  : counts.stalled > 0
1923
1966
  ? theme.fg("warning", getIcon("blocked"))
1924
1967
  : theme.fg("success", getIcon("success"));
1968
+ const earliestStart = details.results.reduce(
1969
+ (min, r) => (r.startTime && r.startTime < min ? r.startTime : min),
1970
+ Number.POSITIVE_INFINITY
1971
+ );
1972
+ const elapsed = Number.isFinite(earliestStart)
1973
+ ? formatDuration(Date.now() - earliestStart)
1974
+ : undefined;
1925
1975
  const summaryLine = formatMetaLine(theme, [
1926
1976
  `${counts.finished}/${details.results.length} done`,
1927
1977
  `${counts.completed} completed`,
1928
1978
  counts.failed > 0 ? `${counts.failed} failed` : undefined,
1929
1979
  `${counts.stalled} stalled`,
1930
1980
  counts.running > 0 ? `${counts.running} running` : undefined,
1981
+ elapsed,
1931
1982
  ]);
1932
1983
 
1933
1984
  if (expanded && !isRunning) {
1934
1985
  const container = new Container();
1935
- const headerLines = [formatSubagentHeader(theme, "parallel", undefined, icon)];
1936
- if (summaryLine) appendSection(headerLines, [summaryLine]);
1937
- container.addChild(new Text(headerLines.join("\n"), 0, 0));
1986
+ const headerLines: string[] = [];
1987
+ if (summaryLine) headerLines.push(`${icon} ${summaryLine}`);
1988
+ if (headerLines.length > 0) container.addChild(new Text(headerLines.join("\n"), 0, 0));
1938
1989
 
1939
1990
  for (const result of details.results) {
1940
1991
  const resultState = getParallelResultState(result);
1941
- const resultStatus = resultState === "completed" ? "completed" : resultState;
1992
+ const resultStatus =
1993
+ resultState === "completed"
1994
+ ? "completed"
1995
+ : resultState === "running" && result.startTime
1996
+ ? `running ${formatDuration(Date.now() - result.startTime)}`
1997
+ : resultState;
1942
1998
  const resultStatusRole =
1943
1999
  resultState === "failed"
1944
2000
  ? "status_error"
@@ -2028,8 +2084,8 @@ function renderParallelResult(
2028
2084
  return container;
2029
2085
  }
2030
2086
 
2031
- const lines: string[] = [formatSubagentHeader(theme, "parallel", undefined, icon)];
2032
- if (summaryLine) appendSection(lines, [summaryLine]);
2087
+ const lines: string[] = [];
2088
+ if (summaryLine) lines.push(`${icon} ${summaryLine}`);
2033
2089
 
2034
2090
  for (let index = 0; index < details.results.length; index++) {
2035
2091
  const result = details.results[index];
@@ -2037,7 +2093,12 @@ function renderParallelResult(
2037
2093
  const branch = formatPresentationText(theme, "meta", isLast ? "└─" : "├─");
2038
2094
  const stem = isLast ? " " : `${formatPresentationText(theme, "meta", "│")} `;
2039
2095
  const resultState = getParallelResultState(result);
2040
- const resultStatus = resultState === "completed" ? "done" : resultState;
2096
+ const resultStatus =
2097
+ resultState === "completed"
2098
+ ? "done"
2099
+ : resultState === "running" && result.startTime
2100
+ ? `running ${formatDuration(Date.now() - result.startTime)}`
2101
+ : resultState;
2041
2102
  const statusRole =
2042
2103
  resultState === "failed"
2043
2104
  ? "status_error"
@@ -449,6 +449,7 @@ export interface ForegroundWatchdogThresholds {
449
449
  readonly killGraceMs: number;
450
450
  readonly startupTimeoutMs: number;
451
451
  readonly toolExecutionTimeoutMs: number;
452
+ readonly wallClockTimeoutMs: number;
452
453
  }
453
454
 
454
455
  /** Heartbeat state tracked by the foreground subagent liveness watchdog. */
@@ -464,7 +465,7 @@ export type WatchdogStatus =
464
465
  | {
465
466
  readonly elapsedMs: number;
466
467
  readonly kind: "stalled";
467
- readonly phase: "inactivity" | "startup" | "tool_execution";
468
+ readonly phase: "inactivity" | "startup" | "tool_execution" | "wall_clock";
468
469
  readonly timeoutMs: number;
469
470
  };
470
471
 
@@ -477,17 +478,33 @@ export const SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_INACTIVITY_TI
477
478
  /** Env var overriding the foreground timeout while a tool call is still running. */
478
479
  export const SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS";
479
480
 
481
+ /** Env var overriding the total wall-clock timeout per foreground subagent. */
482
+ export const SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_WALL_CLOCK_TIMEOUT_MS";
483
+
480
484
  /** Env var overriding the SIGTERM → SIGKILL grace window for stalled workers. */
481
485
  export const SUBAGENT_WATCHDOG_KILL_GRACE_MS_ENV = "TALLOW_SUBAGENT_WATCHDOG_KILL_GRACE_MS";
482
486
 
487
+ /** Env var overriding the total wall-clock timeout for the stalled-worker retry phase. */
488
+ export const SUBAGENT_RETRY_PHASE_TIMEOUT_MS_ENV = "TALLOW_SUBAGENT_RETRY_PHASE_TIMEOUT_MS";
489
+
483
490
  /** Default watchdog thresholds used by foreground subagents in runSingleAgent. */
484
491
  export const FOREGROUND_WATCHDOG_THRESHOLDS: ForegroundWatchdogThresholds = {
485
- inactivityTimeoutMs: 180_000,
492
+ inactivityTimeoutMs: 120_000, // 2 min without any heartbeat event
486
493
  killGraceMs: 5_000,
487
- startupTimeoutMs: 60_000,
488
- toolExecutionTimeoutMs: 600_000,
494
+ startupTimeoutMs: 30_000, // 30s to emit first event
495
+ toolExecutionTimeoutMs: 300_000, // 5 min per tool call (was 10 min)
496
+ wallClockTimeoutMs: 480_000, // 8 min total (was 15 min)
489
497
  };
490
498
 
499
+ /**
500
+ * Default total wall-clock timeout for the entire stalled-worker retry phase.
501
+ *
502
+ * Without a cap, N stalled retries × per-worker watchdog timeout can block
503
+ * the parent for 30+ minutes. This limits the total retry phase so the
504
+ * parent agent can recover sooner.
505
+ */
506
+ export const DEFAULT_RETRY_PHASE_TIMEOUT_MS = 180_000; // 3 minutes
507
+
491
508
  /** How often the foreground watchdog checks for stalled subagents. */
492
509
  const FOREGROUND_WATCHDOG_CHECK_INTERVAL_MS = 500;
493
510
 
@@ -532,9 +549,25 @@ export function resolveForegroundWatchdogThresholds(
532
549
  toolExecutionTimeoutMs:
533
550
  parseTimeoutOverrideMs(env[SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV]) ??
534
551
  FOREGROUND_WATCHDOG_THRESHOLDS.toolExecutionTimeoutMs,
552
+ wallClockTimeoutMs:
553
+ parseTimeoutOverrideMs(env[SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV]) ??
554
+ FOREGROUND_WATCHDOG_THRESHOLDS.wallClockTimeoutMs,
535
555
  };
536
556
  }
537
557
 
558
+ /**
559
+ * Resolve the effective retry-phase wall-clock timeout from env overrides.
560
+ *
561
+ * @param env - Environment lookup map
562
+ * @returns Timeout in milliseconds for the entire stalled-worker retry phase
563
+ */
564
+ export function resolveRetryPhaseTimeoutMs(env: EnvLookup = process.env): number {
565
+ return (
566
+ parseTimeoutOverrideMs(env[SUBAGENT_RETRY_PHASE_TIMEOUT_MS_ENV]) ??
567
+ DEFAULT_RETRY_PHASE_TIMEOUT_MS
568
+ );
569
+ }
570
+
538
571
  /**
539
572
  * Return whether an event type counts as watchdog progress.
540
573
  * @param eventType - Raw child-process event type
@@ -619,6 +652,18 @@ export function evaluateWatchdogStatus(
619
652
  nowMs: number,
620
653
  thresholds: ForegroundWatchdogThresholds
621
654
  ): WatchdogStatus {
655
+ // Wall-clock timeout: hard cap on total execution time, regardless of activity.
656
+ // Catches "slow but active" agents that keep making tool calls without finishing.
657
+ const totalElapsedMs = nowMs - state.startedAtMs;
658
+ if (totalElapsedMs >= thresholds.wallClockTimeoutMs) {
659
+ return {
660
+ elapsedMs: totalElapsedMs,
661
+ kind: "stalled",
662
+ phase: "wall_clock",
663
+ timeoutMs: thresholds.wallClockTimeoutMs,
664
+ };
665
+ }
666
+
622
667
  if (state.lastHeartbeatAtMs === null) {
623
668
  const startupElapsedMs = nowMs - state.startedAtMs;
624
669
  if (startupElapsedMs >= thresholds.startupTimeoutMs) {
@@ -655,6 +700,15 @@ export function createStalledSubagentErrorMessage(
655
700
  stalledStatus: Extract<WatchdogStatus, { kind: "stalled" }>
656
701
  ): string {
657
702
  const timeoutSeconds = Math.max(1, Math.round(stalledStatus.timeoutMs / 1000));
703
+ const timeoutMinutes = Math.round(timeoutSeconds / 60);
704
+ if (stalledStatus.phase === "wall_clock") {
705
+ return (
706
+ `Subagent terminated after ${timeoutMinutes}m wall-clock timeout. ` +
707
+ "The agent was still active but exceeded the maximum allowed execution time. " +
708
+ "Action: break the task into smaller pieces, or increase " +
709
+ "TALLOW_SUBAGENT_WALL_CLOCK_TIMEOUT_MS for legitimately long work."
710
+ );
711
+ }
658
712
  const phaseDescription =
659
713
  stalledStatus.phase === "startup"
660
714
  ? "no startup activity was received"
@@ -743,6 +797,54 @@ export function terminateProcessWithGrace(
743
797
  /** Callback for streaming partial results during subagent execution. */
744
798
  export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
745
799
 
800
+ // ── Subprocess Arg Construction ──────────────────────────────────────────────
801
+
802
+ /** Options for building subprocess CLI arguments. */
803
+ export interface SubprocessArgsOptions {
804
+ /** Session file path for persistent teammates (omit for --no-session). */
805
+ session?: string;
806
+ /** Provider-qualified model display name (e.g. "anthropic/claude-sonnet-4-6"). */
807
+ modelDisplayName?: string;
808
+ /** Effective tool allowlist (already filtered by denylist). */
809
+ tools?: string[];
810
+ /** Skill names to pass via --skill flags. */
811
+ skills?: string[];
812
+ /** Path to temp file containing the system prompt. */
813
+ systemPromptPath?: string;
814
+ /** Task text (will be prefixed with "Task: "). */
815
+ task: string;
816
+ }
817
+
818
+ /**
819
+ * Build the CLI argument array for a subagent child process.
820
+ *
821
+ * **Invariant:** `-p <task>` is always the last pair in the array.
822
+ * Commander consumes the next token after `-p` as its required `<prompt>`
823
+ * value. Placing `-p` before other flags (e.g. `--no-session`) causes
824
+ * Commander to swallow the flag as the prompt text, leaving the real
825
+ * task as a stray positional argument.
826
+ *
827
+ * @param opts - Subprocess argument options
828
+ * @returns CLI argument array safe for child_process.spawn
829
+ */
830
+ export function buildSubprocessArgs(opts: SubprocessArgsOptions): string[] {
831
+ const args: string[] = opts.session
832
+ ? ["--mode", "json", "--session", opts.session]
833
+ : ["--mode", "json", "--no-session"];
834
+
835
+ if (opts.modelDisplayName) args.push("--model", opts.modelDisplayName);
836
+ if (opts.tools && opts.tools.length > 0) args.push("--tools", opts.tools.join(","));
837
+ if (opts.skills && opts.skills.length > 0) {
838
+ for (const skill of opts.skills) args.push("--skill", skill);
839
+ }
840
+ if (opts.systemPromptPath) args.push("--append-system-prompt", opts.systemPromptPath);
841
+
842
+ // CRITICAL: -p must be last — see JSDoc above.
843
+ args.push("-p", `Task: ${opts.task}`);
844
+
845
+ return args;
846
+ }
847
+
746
848
  // ── Background Spawning ──────────────────────────────────────────────────────
747
849
 
748
850
  /**
@@ -813,18 +915,6 @@ export async function spawnBackgroundSubagent(
813
915
  const agent = { ...resolved.agent, model: routing.model.id };
814
916
  const agentSource = resolved.resolution === "ephemeral" ? ("ephemeral" as const) : agent.source;
815
917
 
816
- const args: string[] = session
817
- ? ["--mode", "json", "-p", "--session", session]
818
- : ["--mode", "json", "-p", "--no-session"];
819
- // Use provider-qualified name (e.g. "openai-codex/gpt-5.1") so the child process
820
- // resolves to the exact provider the router selected, not just the first match.
821
- if (agent.model) args.push("--model", routing.model.displayName);
822
- const effectiveTools = computeEffectiveTools(agent.tools, agent.disallowedTools);
823
- if (effectiveTools && effectiveTools.length > 0) args.push("--tools", effectiveTools.join(","));
824
- if (agent.skills && agent.skills.length > 0) {
825
- for (const skill of agent.skills) args.push("--skill", skill);
826
- }
827
-
828
918
  let tmpPromptDir: string | undefined;
829
919
  let tmpPromptPath: string | undefined;
830
920
 
@@ -839,7 +929,6 @@ export async function spawnBackgroundSubagent(
839
929
  const tmp = writePromptToTempFile(agent.name, systemPrompt);
840
930
  tmpPromptDir = tmp.dir;
841
931
  tmpPromptPath = tmp.filePath;
842
- args.push("--append-system-prompt", tmpPromptPath);
843
932
  }
844
933
 
845
934
  let expandedTask: string;
@@ -850,7 +939,16 @@ export async function spawnBackgroundSubagent(
850
939
  const reason = error instanceof Error ? error.message : String(error);
851
940
  return `Failed to expand task references for ${agentName}: ${reason}`;
852
941
  }
853
- args.push(`Task: ${expandedTask}`);
942
+
943
+ const effectiveTools = computeEffectiveTools(agent.tools, agent.disallowedTools);
944
+ const args = buildSubprocessArgs({
945
+ session,
946
+ modelDisplayName: agent.model ? routing.model.displayName : undefined,
947
+ tools: effectiveTools && effectiveTools.length > 0 ? effectiveTools : undefined,
948
+ skills: agent.skills && agent.skills.length > 0 ? agent.skills : undefined,
949
+ systemPromptPath: tmpPromptPath,
950
+ task: expandedTask,
951
+ });
854
952
 
855
953
  const childEnv: Record<string, string> = { ...process.env, PI_IS_SUBAGENT: "1" } as Record<
856
954
  string,
@@ -1236,18 +1334,6 @@ export async function runSingleAgent(
1236
1334
  background: false,
1237
1335
  } satisfies SubagentStartEvent);
1238
1336
 
1239
- const args: string[] = session
1240
- ? ["--mode", "json", "-p", "--session", session]
1241
- : ["--mode", "json", "-p", "--no-session"];
1242
- // Use provider-qualified name so the child process resolves to the exact provider.
1243
- if (agent.model) args.push("--model", routing.model.displayName);
1244
- const fgEffectiveTools = computeEffectiveTools(agent.tools, agent.disallowedTools);
1245
- if (fgEffectiveTools && fgEffectiveTools.length > 0)
1246
- args.push("--tools", fgEffectiveTools.join(","));
1247
- if (agent.skills && agent.skills.length > 0) {
1248
- for (const skill of agent.skills) args.push("--skill", skill);
1249
- }
1250
-
1251
1337
  let tmpPromptDir: string | null = null;
1252
1338
  let tmpPromptPath: string | null = null;
1253
1339
 
@@ -1290,6 +1376,7 @@ export async function runSingleAgent(
1290
1376
  },
1291
1377
  model: agent.model,
1292
1378
  step,
1379
+ startTime: Date.now(),
1293
1380
  };
1294
1381
 
1295
1382
  /** Timestamp of the last emitted update, used for throttling. */
@@ -1327,11 +1414,18 @@ export async function runSingleAgent(
1327
1414
  const tmp = writePromptToTempFile(agent.name, fgSystemPrompt);
1328
1415
  tmpPromptDir = tmp.dir;
1329
1416
  tmpPromptPath = tmp.filePath;
1330
- args.push("--append-system-prompt", tmpPromptPath);
1331
1417
  }
1332
1418
 
1333
1419
  const expandedTask = await expandFileReferences(task, effectiveCwd);
1334
- args.push(`Task: ${expandedTask}`);
1420
+ const fgEffectiveTools = computeEffectiveTools(agent.tools, agent.disallowedTools);
1421
+ const args = buildSubprocessArgs({
1422
+ session,
1423
+ modelDisplayName: agent.model ? routing.model.displayName : undefined,
1424
+ tools: fgEffectiveTools && fgEffectiveTools.length > 0 ? fgEffectiveTools : undefined,
1425
+ skills: agent.skills && agent.skills.length > 0 ? agent.skills : undefined,
1426
+ systemPromptPath: tmpPromptPath ?? undefined,
1427
+ task: expandedTask,
1428
+ });
1335
1429
  let wasAborted = false;
1336
1430
 
1337
1431
  const fgChildEnv: Record<string, string> = {