gsd-pi 2.23.0 → 2.24.0

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 (121) hide show
  1. package/dist/cli.js +12 -3
  2. package/dist/headless.d.ts +4 -0
  3. package/dist/headless.js +118 -10
  4. package/dist/help-text.js +22 -7
  5. package/dist/resource-loader.js +64 -9
  6. package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
  8. package/dist/resources/extensions/gsd/auto-recovery.ts +41 -2
  9. package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
  10. package/dist/resources/extensions/gsd/auto.ts +123 -41
  11. package/dist/resources/extensions/gsd/commands.ts +176 -10
  12. package/dist/resources/extensions/gsd/complexity.ts +1 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  14. package/dist/resources/extensions/gsd/doctor.ts +56 -11
  15. package/dist/resources/extensions/gsd/exit-command.ts +2 -2
  16. package/dist/resources/extensions/gsd/gitignore.ts +1 -0
  17. package/dist/resources/extensions/gsd/guided-flow.ts +75 -0
  18. package/dist/resources/extensions/gsd/index.ts +34 -1
  19. package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  20. package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
  21. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  22. package/dist/resources/extensions/gsd/preferences.ts +65 -1
  23. package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  24. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  25. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  26. package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
  27. package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
  28. package/dist/resources/extensions/gsd/state.ts +72 -30
  29. package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  30. package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  31. package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  32. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
  33. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  34. package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  35. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  36. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  37. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  38. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  39. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  40. package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  41. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  42. package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  43. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  44. package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  45. package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  46. package/dist/resources/extensions/gsd/types.ts +15 -1
  47. package/dist/resources/extensions/subagent/index.ts +5 -0
  48. package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
  49. package/dist/update-check.d.ts +9 -0
  50. package/dist/update-check.js +97 -0
  51. package/package.json +6 -1
  52. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  53. package/packages/pi-ai/dist/providers/anthropic.js +16 -7
  54. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  55. package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
  56. package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
  57. package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
  58. package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
  59. package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
  60. package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
  61. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  62. package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
  63. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  64. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  65. package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
  66. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  67. package/packages/pi-ai/src/providers/anthropic.ts +21 -8
  68. package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
  69. package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
  70. package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
  71. package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
  72. package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
  73. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  74. package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
  75. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  76. package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
  77. package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
  78. package/scripts/postinstall.js +7 -109
  79. package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
  80. package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
  81. package/src/resources/extensions/gsd/auto-recovery.ts +41 -2
  82. package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
  83. package/src/resources/extensions/gsd/auto.ts +123 -41
  84. package/src/resources/extensions/gsd/commands.ts +176 -10
  85. package/src/resources/extensions/gsd/complexity.ts +1 -0
  86. package/src/resources/extensions/gsd/dashboard-overlay.ts +38 -0
  87. package/src/resources/extensions/gsd/doctor.ts +56 -11
  88. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  89. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  90. package/src/resources/extensions/gsd/guided-flow.ts +75 -0
  91. package/src/resources/extensions/gsd/index.ts +34 -1
  92. package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
  93. package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
  94. package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
  95. package/src/resources/extensions/gsd/preferences.ts +65 -1
  96. package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
  97. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  98. package/src/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
  99. package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
  100. package/src/resources/extensions/gsd/session-status-io.ts +197 -0
  101. package/src/resources/extensions/gsd/state.ts +72 -30
  102. package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
  103. package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
  104. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
  105. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
  106. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
  107. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
  108. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
  109. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
  110. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
  111. package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
  112. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
  113. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
  114. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
  115. package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
  116. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
  117. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
  118. package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
  119. package/src/resources/extensions/gsd/types.ts +15 -1
  120. package/src/resources/extensions/subagent/index.ts +5 -0
  121. package/src/resources/extensions/subagent/worker-registry.ts +99 -0
@@ -108,6 +108,7 @@ import {
108
108
  autoWorktreeBranch,
109
109
  } from "./auto-worktree.js";
110
110
  import { pruneQueueOrder } from "./queue-order.js";
111
+ import { consumeSignal } from "./session-status-io.js";
111
112
  import { showNextAction } from "../shared/next-action-ui.js";
112
113
  import { debugLog, debugTime, debugCount, debugPeak, enableDebug, isDebugEnabled, writeDebugSummary, getDebugLogPath } from "./debug-logger.js";
113
114
  import {
@@ -195,6 +196,35 @@ function syncStateToProjectRoot(worktreePath: string, projectRoot: string, miles
195
196
  cpSync(srcMilestone, dstMilestone, { recursive: true, force: true });
196
197
  }
197
198
  } catch { /* non-fatal */ }
199
+
200
+ // 3. Merge completed-units.json (set-union of both locations)
201
+ // Prevents already-completed units from being re-dispatched after crash/restart.
202
+ const srcKeysFile = join(wtGsd, "completed-units.json");
203
+ const dstKeysFile = join(prGsd, "completed-units.json");
204
+ if (existsSync(srcKeysFile)) {
205
+ try {
206
+ const srcKeys: string[] = JSON.parse(readFileSync(srcKeysFile, "utf8"));
207
+ let dstKeys: string[] = [];
208
+ if (existsSync(dstKeysFile)) {
209
+ try { dstKeys = JSON.parse(readFileSync(dstKeysFile, "utf8")); } catch { /* ignore corrupt dst */ }
210
+ }
211
+ const merged = [...new Set([...dstKeys, ...srcKeys])];
212
+ writeFileSync(dstKeysFile, JSON.stringify(merged, null, 2));
213
+ } catch { /* non-fatal */ }
214
+ }
215
+
216
+ // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
217
+ // Without this, a crash during a unit leaves the runtime record only in the
218
+ // worktree. If the next session resolves basePath before worktree re-entry,
219
+ // selfHeal can't find or clear the stale record (#769).
220
+ try {
221
+ const srcRuntime = join(wtGsd, "runtime", "units");
222
+ const dstRuntime = join(prGsd, "runtime", "units");
223
+ if (existsSync(srcRuntime)) {
224
+ mkdirSync(dstRuntime, { recursive: true });
225
+ cpSync(srcRuntime, dstRuntime, { recursive: true, force: true });
226
+ }
227
+ } catch { /* non-fatal */ }
198
228
  }
199
229
 
200
230
  // ─── State ────────────────────────────────────────────────────────────────────
@@ -361,11 +391,12 @@ let _sigtermHandler: (() => void) | null = null;
361
391
  */
362
392
  const inFlightTools = new Map<string, number>();
363
393
 
364
- type BudgetAlertLevel = 0 | 75 | 90 | 100;
394
+ type BudgetAlertLevel = 0 | 75 | 80 | 90 | 100;
365
395
 
366
396
  export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
367
397
  if (budgetPct >= 1.0) return 100;
368
398
  if (budgetPct >= 0.90) return 90;
399
+ if (budgetPct >= 0.80) return 80;
369
400
  if (budgetPct >= 0.75) return 75;
370
401
  return 0;
371
402
  }
@@ -552,11 +583,7 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
552
583
  await dispatchNextUnit(ctx, pi);
553
584
  } catch (retryErr) {
554
585
  const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
555
- ctx.ui.notify(
556
- `Dispatch gap recovery failed: ${message}. Stopping auto-mode.`,
557
- "error",
558
- );
559
- await stopAuto(ctx, pi);
586
+ await stopAuto(ctx, pi, `Dispatch gap recovery failed: ${message}`);
560
587
  return;
561
588
  }
562
589
 
@@ -564,17 +591,14 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
564
591
  // (no sendMessage called → no timeout set), auto-mode is permanently
565
592
  // stalled. Stop cleanly instead of leaving it active but idle (#537).
566
593
  if (active && !unitTimeoutHandle && !wrapupWarningHandle) {
567
- ctx.ui.notify(
568
- "Auto-mode stalled — no dispatchable unit found after retry. Stopping. Run /gsd auto to restart.",
569
- "warning",
570
- );
571
- await stopAuto(ctx, pi);
594
+ await stopAuto(ctx, pi, "Stalled — no dispatchable unit after retry");
572
595
  }
573
596
  }, DISPATCH_GAP_TIMEOUT_MS);
574
597
  }
575
598
 
576
- export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise<void> {
599
+ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string): Promise<void> {
577
600
  if (!active && !paused) return;
601
+ const reasonSuffix = reason ? ` — ${reason}` : "";
578
602
  clearUnitTimeout();
579
603
  if (lockBase()) clearLock(lockBase());
580
604
  clearSkillSnapshot();
@@ -626,11 +650,11 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
626
650
  if (ledger && ledger.units.length > 0) {
627
651
  const totals = getProjectTotals(ledger.units);
628
652
  ctx?.ui.notify(
629
- `Auto-mode stopped. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`,
653
+ `Auto-mode stopped${reasonSuffix}. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`,
630
654
  "info",
631
655
  );
632
656
  } else {
633
- ctx?.ui.notify("Auto-mode stopped.", "info");
657
+ ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}.`, "info");
634
658
  }
635
659
 
636
660
  // Sync disk state so next resume starts from accurate state
@@ -1094,6 +1118,13 @@ export async function startAuto(
1094
1118
  }
1095
1119
  // Re-register SIGTERM handler with the original basePath (lock lives there)
1096
1120
  registerSigtermHandler(originalBasePath);
1121
+
1122
+ // After worktree entry, load completed keys from BOTH locations (project root
1123
+ // + worktree) so the in-memory set is the union. Prevents re-dispatch of units
1124
+ // completed in either location after crash/restart (#769).
1125
+ if (basePath !== originalBasePath) {
1126
+ loadPersistedKeys(basePath, completedKeySet);
1127
+ }
1097
1128
  } catch (err) {
1098
1129
  // Worktree creation is non-fatal — continue in the project root.
1099
1130
  ctx.ui.notify(
@@ -1130,11 +1161,12 @@ export async function startAuto(
1130
1161
  }
1131
1162
  }
1132
1163
 
1133
- // Initialize metrics — loads existing ledger from disk
1134
- initMetrics(base);
1164
+ // Initialize metrics — loads existing ledger from disk.
1165
+ // Use basePath (not base) so worktree-mode reads the worktree ledger (#769).
1166
+ initMetrics(basePath);
1135
1167
 
1136
1168
  // Initialize routing history for adaptive learning
1137
- initRoutingHistory(base);
1169
+ initRoutingHistory(basePath);
1138
1170
 
1139
1171
  // Capture the session's current model at auto-mode start (#650).
1140
1172
  // This prevents model bleed when multiple GSD instances share the
@@ -1185,8 +1217,10 @@ export async function startAuto(
1185
1217
  );
1186
1218
  }
1187
1219
 
1188
- // Self-heal: clear stale runtime records where artifacts already exist
1189
- await selfHealRuntimeRecords(base, ctx, completedKeySet);
1220
+ // Self-heal: clear stale runtime records where artifacts already exist.
1221
+ // Use basePath (not base) — in worktree mode, basePath points to the worktree
1222
+ // where runtime records and artifacts actually live (#769).
1223
+ await selfHealRuntimeRecords(basePath, ctx, completedKeySet);
1190
1224
 
1191
1225
  // Self-heal: remove stale .git/index.lock from prior crash.
1192
1226
  // A stale lock file blocks all git operations (commit, merge, checkout).
@@ -1252,6 +1286,27 @@ export async function handleAgentEnd(
1252
1286
  // Unit completed — clear its timeout
1253
1287
  clearUnitTimeout();
1254
1288
 
1289
+ // ── Parallel worker signal check ─────────────────────────────────────
1290
+ // When running as a parallel worker (GSD_MILESTONE_LOCK set), check for
1291
+ // coordinator signals before dispatching the next unit.
1292
+ const milestoneLock = process.env.GSD_MILESTONE_LOCK;
1293
+ if (milestoneLock) {
1294
+ const signal = consumeSignal(basePath, milestoneLock);
1295
+ if (signal) {
1296
+ if (signal.signal === "stop") {
1297
+ _handlingAgentEnd = false;
1298
+ await stopAuto(ctx, pi);
1299
+ return;
1300
+ }
1301
+ if (signal.signal === "pause") {
1302
+ _handlingAgentEnd = false;
1303
+ await pauseAuto(ctx, pi);
1304
+ return;
1305
+ }
1306
+ // "resume" and "rebase" signals are handled elsewhere or no-op here
1307
+ }
1308
+ }
1309
+
1255
1310
  // Invalidate all caches — the unit just completed and may have
1256
1311
  // written planning files (task summaries, roadmap checkboxes, etc.)
1257
1312
  invalidateAllCaches();
@@ -1485,7 +1540,7 @@ export async function handleAgentEnd(
1485
1540
  const result = await cmdCtx!.newSession();
1486
1541
  if (result.cancelled) {
1487
1542
  resetHookState();
1488
- await stopAuto(ctx, pi);
1543
+ await stopAuto(ctx, pi, "Hook session cancelled");
1489
1544
  return;
1490
1545
  }
1491
1546
  const sessionFile = ctx.sessionManager.getSessionFile();
@@ -1783,7 +1838,15 @@ async function showStepWizard(
1783
1838
 
1784
1839
  // If no active milestone or everything is complete, stop
1785
1840
  if (!mid || state.phase === "complete") {
1786
- await stopAuto(ctx, pi);
1841
+ const incomplete = state.registry.filter(m => m.status !== "complete");
1842
+ if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") {
1843
+ const ids = incomplete.map(m => m.id).join(", ");
1844
+ const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
1845
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
1846
+ await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids})`);
1847
+ } else {
1848
+ await stopAuto(ctx, pi, state.phase === "complete" ? "All work complete" : "No active milestone");
1849
+ }
1787
1850
  return;
1788
1851
  }
1789
1852
 
@@ -1906,8 +1969,7 @@ async function dispatchNextUnit(
1906
1969
  // doesn't provide. Stop gracefully instead of crashing.
1907
1970
  const staleMsg = checkResourcesStale();
1908
1971
  if (staleMsg) {
1909
- await stopAuto(ctx, pi);
1910
- ctx.ui.notify(staleMsg, "error");
1972
+ await stopAuto(ctx, pi, staleMsg);
1911
1973
  return;
1912
1974
  }
1913
1975
 
@@ -2054,8 +2116,25 @@ async function dispatchNextUnit(
2054
2116
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2055
2117
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2056
2118
  }
2057
- sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
2058
- await stopAuto(ctx, pi);
2119
+
2120
+ const incomplete = state.registry.filter(m => m.status !== "complete");
2121
+ if (incomplete.length === 0) {
2122
+ // Genuinely all complete
2123
+ sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
2124
+ await stopAuto(ctx, pi, "All milestones complete");
2125
+ } else if (state.phase === "blocked") {
2126
+ // Milestones exist but are dependency-blocked
2127
+ const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
2128
+ await stopAuto(ctx, pi, blockerMsg);
2129
+ ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
2130
+ sendDesktopNotification("GSD", blockerMsg, "error", "attention");
2131
+ } else {
2132
+ // Milestones with remaining work exist but none became active — unexpected
2133
+ const ids = incomplete.map(m => m.id).join(", ");
2134
+ const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
2135
+ ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
2136
+ await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
2137
+ }
2059
2138
  return;
2060
2139
  }
2061
2140
 
@@ -2063,8 +2142,8 @@ async function dispatchNextUnit(
2063
2142
  // The !mid check above returns early if mid is falsy; midTitle comes from
2064
2143
  // the same object so it should always be present when mid is.
2065
2144
  if (!midTitle) {
2066
- await stopAuto(ctx, pi);
2067
- return;
2145
+ midTitle = mid; // Defensive fallback: use milestone ID as title
2146
+ ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning");
2068
2147
  }
2069
2148
 
2070
2149
  // ── Mid-merge safety check: detect leftover merge state from a prior session ──
@@ -2082,7 +2161,10 @@ async function dispatchNextUnit(
2082
2161
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2083
2162
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2084
2163
  }
2085
- await stopAuto(ctx, pi);
2164
+ const noMilestoneReason = !mid
2165
+ ? "No active milestone after merge reconciliation"
2166
+ : `Milestone ${mid} has no title after reconciliation`;
2167
+ await stopAuto(ctx, pi, noMilestoneReason);
2086
2168
  return;
2087
2169
  }
2088
2170
 
@@ -2157,7 +2239,7 @@ async function dispatchNextUnit(
2157
2239
  }
2158
2240
  }
2159
2241
  sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
2160
- await stopAuto(ctx, pi);
2242
+ await stopAuto(ctx, pi, `Milestone ${mid} complete`);
2161
2243
  return;
2162
2244
  }
2163
2245
 
@@ -2167,8 +2249,8 @@ async function dispatchNextUnit(
2167
2249
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2168
2250
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2169
2251
  }
2170
- await stopAuto(ctx, pi);
2171
2252
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
2253
+ await stopAuto(ctx, pi, blockerMsg);
2172
2254
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
2173
2255
  sendDesktopNotification("GSD", blockerMsg, "error", "attention");
2174
2256
  return;
@@ -2194,9 +2276,8 @@ async function dispatchNextUnit(
2194
2276
  const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`;
2195
2277
  lastBudgetAlertLevel = newBudgetAlertLevel;
2196
2278
  if (budgetEnforcementAction === "halt") {
2197
- ctx.ui.notify(`${msg} Stopping auto-mode.`, "error");
2198
2279
  sendDesktopNotification("GSD", msg, "error", "budget");
2199
- await stopAuto(ctx, pi);
2280
+ await stopAuto(ctx, pi, "Budget ceiling reached");
2200
2281
  return;
2201
2282
  }
2202
2283
  if (budgetEnforcementAction === "pause") {
@@ -2211,6 +2292,10 @@ async function dispatchNextUnit(
2211
2292
  lastBudgetAlertLevel = newBudgetAlertLevel;
2212
2293
  ctx.ui.notify(`Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
2213
2294
  sendDesktopNotification("GSD", `Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
2295
+ } else if (newBudgetAlertLevel === 80) {
2296
+ lastBudgetAlertLevel = newBudgetAlertLevel;
2297
+ ctx.ui.notify(`Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
2298
+ sendDesktopNotification("GSD", `Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
2214
2299
  } else if (newBudgetAlertLevel === 75) {
2215
2300
  lastBudgetAlertLevel = newBudgetAlertLevel;
2216
2301
  ctx.ui.notify(`Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info");
@@ -2275,8 +2360,7 @@ async function dispatchNextUnit(
2275
2360
  snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
2276
2361
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
2277
2362
  }
2278
- await stopAuto(ctx, pi);
2279
- ctx.ui.notify(dispatchResult.reason, dispatchResult.level);
2363
+ await stopAuto(ctx, pi, dispatchResult.reason);
2280
2364
  return;
2281
2365
  }
2282
2366
 
@@ -2316,8 +2400,7 @@ async function dispatchNextUnit(
2316
2400
 
2317
2401
  const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId);
2318
2402
  if (priorSliceBlocker) {
2319
- await stopAuto(ctx, pi);
2320
- ctx.ui.notify(priorSliceBlocker, "error");
2403
+ await stopAuto(ctx, pi, priorSliceBlocker);
2321
2404
  return;
2322
2405
  }
2323
2406
 
@@ -2434,9 +2517,9 @@ async function dispatchNextUnit(
2434
2517
  }
2435
2518
  saveActivityLog(ctx, basePath, unitType, unitId);
2436
2519
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2437
- await stopAuto(ctx, pi);
2520
+ await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId}`);
2438
2521
  ctx.ui.notify(
2439
- `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles). Stopping.${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`,
2522
+ `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`,
2440
2523
  "error",
2441
2524
  );
2442
2525
  return;
@@ -2529,7 +2612,7 @@ async function dispatchNextUnit(
2529
2612
 
2530
2613
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
2531
2614
  const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
2532
- await stopAuto(ctx, pi);
2615
+ await stopAuto(ctx, pi, `Loop: ${unitType} ${unitId}`);
2533
2616
  sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error");
2534
2617
  ctx.ui.notify(
2535
2618
  `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`,
@@ -2670,8 +2753,7 @@ async function dispatchNextUnit(
2670
2753
  // Fresh session
2671
2754
  const result = await cmdCtx!.newSession();
2672
2755
  if (result.cancelled) {
2673
- await stopAuto(ctx, pi);
2674
- ctx.ui.notify("Auto-mode stopped.", "info");
2756
+ await stopAuto(ctx, pi, "Session cancelled");
2675
2757
  return;
2676
2758
  }
2677
2759
 
@@ -6,14 +6,14 @@
6
6
 
7
7
  import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
8
8
  import { AuthStorage } from "@gsd/pi-coding-agent";
9
- import { existsSync, readFileSync, mkdirSync } from "node:fs";
9
+ import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
10
10
  import { join, dirname } from "node:path";
11
11
  import { enableDebug, isDebugEnabled } from "./debug-logger.js";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { deriveState } from "./state.js";
14
14
  import { GSDDashboardOverlay } from "./dashboard-overlay.js";
15
15
  import { GSDVisualizerOverlay } from "./visualizer-overlay.js";
16
- import { showQueue, showDiscuss } from "./guided-flow.js";
16
+ import { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.js";
17
17
  import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, dispatchDirectPhase } from "./auto.js";
18
18
  import { resolveProjectRoot } from "./worktree.js";
19
19
  import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js";
@@ -42,6 +42,14 @@ import { handleQuick } from "./quick.js";
42
42
  import { handleHistory } from "./history.js";
43
43
  import { handleUndo } from "./undo.js";
44
44
  import { handleExport } from "./export.js";
45
+ import {
46
+ isParallelActive, getOrchestratorState, getWorkerStatuses,
47
+ prepareParallelStart, startParallel, stopParallel,
48
+ pauseWorker, resumeWorker,
49
+ } from "./parallel-orchestrator.js";
50
+ import { formatEligibilityReport } from "./parallel-eligibility.js";
51
+ import { mergeAllCompleted, mergeCompletedMilestone, formatMergeResults } from "./parallel-merge.js";
52
+ import { resolveParallelConfig } from "./preferences.js";
45
53
  import { nativeBranchList, nativeDetectMainBranch, nativeBranchListMerged, nativeBranchDelete, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js";
46
54
 
47
55
  export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
@@ -69,20 +77,53 @@ function projectRoot(): string {
69
77
 
70
78
  export function registerGSDCommand(pi: ExtensionAPI): void {
71
79
  pi.registerCommand("gsd", {
72
- description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge",
80
+ description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|new-milestone|parallel",
73
81
  getArgumentCompletions: (prefix: string) => {
74
82
  const subcommands = [
75
- "help", "next", "auto", "stop", "pause", "status", "visualize", "queue", "quick", "discuss",
76
- "capture", "triage", "dispatch",
77
- "history", "undo", "skip", "export", "cleanup", "mode", "prefs",
78
- "config", "hooks", "run-hook", "skill-health", "doctor", "forensics", "migrate", "remote", "steer", "inspect", "knowledge",
83
+ { cmd: "help", desc: "Categorized command reference with descriptions" },
84
+ { cmd: "next", desc: "Explicit step mode (same as /gsd)" },
85
+ { cmd: "auto", desc: "Autonomous mode — research, plan, execute, commit, repeat" },
86
+ { cmd: "stop", desc: "Stop auto mode gracefully" },
87
+ { cmd: "pause", desc: "Pause auto-mode (preserves state, /gsd auto to resume)" },
88
+ { cmd: "status", desc: "Progress dashboard" },
89
+ { cmd: "visualize", desc: "Open workflow visualizer (progress, deps, metrics, timeline)" },
90
+ { cmd: "queue", desc: "Queue and reorder future milestones" },
91
+ { cmd: "quick", desc: "Execute a quick task without full planning overhead" },
92
+ { cmd: "discuss", desc: "Discuss architecture and decisions" },
93
+ { cmd: "capture", desc: "Fire-and-forget thought capture" },
94
+ { cmd: "triage", desc: "Manually trigger triage of pending captures" },
95
+ { cmd: "dispatch", desc: "Dispatch a specific phase directly" },
96
+ { cmd: "history", desc: "View execution history" },
97
+ { cmd: "undo", desc: "Revert last completed unit" },
98
+ { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" },
99
+ { cmd: "export", desc: "Export milestone/slice results" },
100
+ { cmd: "cleanup", desc: "Remove merged branches or snapshots" },
101
+ { cmd: "mode", desc: "Switch workflow mode (solo/team)" },
102
+ { cmd: "prefs", desc: "Manage preferences (model selection, timeouts, etc.)" },
103
+ { cmd: "config", desc: "Set API keys for external tools" },
104
+ { cmd: "hooks", desc: "Show configured post-unit and pre-dispatch hooks" },
105
+ { cmd: "run-hook", desc: "Manually trigger a specific hook" },
106
+ { cmd: "skill-health", desc: "Skill lifecycle dashboard" },
107
+ { cmd: "doctor", desc: "Runtime health checks with auto-fix" },
108
+ { cmd: "forensics", desc: "Examine execution logs" },
109
+ { cmd: "migrate", desc: "Migrate a v1 .planning directory to .gsd format" },
110
+ { cmd: "remote", desc: "Control remote auto-mode" },
111
+ { cmd: "steer", desc: "Hard-steer plan documents during execution" },
112
+ { cmd: "inspect", desc: "Show SQLite DB diagnostics" },
113
+ { cmd: "knowledge", desc: "Add persistent project knowledge (rule, pattern, or lesson)" },
114
+ { cmd: "new-milestone", desc: "Create a milestone from a specification document (headless)" },
115
+ { cmd: "parallel", desc: "Parallel milestone orchestration (start, status, stop, merge)" },
79
116
  ];
80
117
  const parts = prefix.trim().split(/\s+/);
81
118
 
82
119
  if (parts.length <= 1) {
83
120
  return subcommands
84
- .filter((cmd) => cmd.startsWith(parts[0] ?? ""))
85
- .map((cmd) => ({ value: cmd, label: cmd }));
121
+ .filter((item) => item.cmd.startsWith(parts[0] ?? ""))
122
+ .map((item) => ({
123
+ value: item.cmd,
124
+ label: item.cmd,
125
+ description: item.desc
126
+ }));
86
127
  }
87
128
 
88
129
  if (parts[0] === "auto" && parts.length <= 2) {
@@ -99,6 +140,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
99
140
  .map((cmd) => ({ value: `mode ${cmd}`, label: cmd }));
100
141
  }
101
142
 
143
+ if (parts[0] === "parallel" && parts.length <= 2) {
144
+ const subPrefix = parts[1] ?? "";
145
+ return ["start", "status", "stop", "pause", "resume", "merge"]
146
+ .filter((cmd) => cmd.startsWith(subPrefix))
147
+ .map((cmd) => ({ value: `parallel ${cmd}`, label: cmd }));
148
+ }
149
+
102
150
  if (parts[0] === "prefs" && parts.length <= 2) {
103
151
  const subPrefix = parts[1] ?? "";
104
152
  return ["global", "project", "status", "wizard", "setup", "import-claude"]
@@ -251,7 +299,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
251
299
  }
252
300
  return;
253
301
  }
254
- await stopAuto(ctx, pi);
302
+ await stopAuto(ctx, pi, "User requested stop");
255
303
  return;
256
304
  }
257
305
 
@@ -288,6 +336,108 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
288
336
  return;
289
337
  }
290
338
 
339
+ // ─── Parallel Orchestration ────────────────────────────────────────
340
+ if (trimmed.startsWith("parallel")) {
341
+ const parallelArgs = trimmed.slice("parallel".length).trim();
342
+ const [subCmd = "", ...restParts] = parallelArgs.split(/\s+/);
343
+ const rest = restParts.join(" ");
344
+
345
+ if (subCmd === "start" || subCmd === "") {
346
+ const loaded = loadEffectiveGSDPreferences();
347
+ const config = resolveParallelConfig(loaded?.preferences);
348
+ if (!config.enabled) {
349
+ pi.sendMessage({
350
+ customType: "gsd-parallel",
351
+ content: "Parallel mode is not enabled. Set `parallel.enabled: true` in your preferences.",
352
+ display: false,
353
+ });
354
+ return;
355
+ }
356
+ const candidates = await prepareParallelStart(projectRoot(), loaded?.preferences);
357
+ const report = formatEligibilityReport(candidates);
358
+ if (candidates.eligible.length === 0) {
359
+ pi.sendMessage({ customType: "gsd-parallel", content: report + "\n\nNo milestones are eligible for parallel execution.", display: false });
360
+ return;
361
+ }
362
+ const result = await startParallel(
363
+ projectRoot(),
364
+ candidates.eligible.map(e => e.milestoneId),
365
+ loaded?.preferences,
366
+ );
367
+ const lines = [`Parallel orchestration started.`, `Workers: ${result.started.join(", ")}`];
368
+ if (result.errors.length > 0) {
369
+ lines.push(`Errors: ${result.errors.map(e => `${e.mid}: ${e.error}`).join("; ")}`);
370
+ }
371
+ pi.sendMessage({ customType: "gsd-parallel", content: report + "\n\n" + lines.join("\n"), display: false });
372
+ return;
373
+ }
374
+
375
+ if (subCmd === "status") {
376
+ if (!isParallelActive()) {
377
+ pi.sendMessage({ customType: "gsd-parallel", content: "No parallel orchestration is currently active.", display: false });
378
+ return;
379
+ }
380
+ const workers = getWorkerStatuses();
381
+ const lines = ["# Parallel Workers\n"];
382
+ for (const w of workers) {
383
+ lines.push(`- **${w.milestoneId}** (${w.title}) — ${w.state} — ${w.completedUnits} units — $${w.cost.toFixed(2)}`);
384
+ }
385
+ const orchState = getOrchestratorState();
386
+ if (orchState) {
387
+ lines.push(`\nTotal cost: $${orchState.totalCost.toFixed(2)}`);
388
+ }
389
+ pi.sendMessage({ customType: "gsd-parallel", content: lines.join("\n"), display: false });
390
+ return;
391
+ }
392
+
393
+ if (subCmd === "stop") {
394
+ const mid = rest.trim() || undefined;
395
+ await stopParallel(projectRoot(), mid);
396
+ pi.sendMessage({ customType: "gsd-parallel", content: mid ? `Stopped worker for ${mid}.` : "All parallel workers stopped.", display: false });
397
+ return;
398
+ }
399
+
400
+ if (subCmd === "pause") {
401
+ const mid = rest.trim() || undefined;
402
+ pauseWorker(projectRoot(), mid);
403
+ pi.sendMessage({ customType: "gsd-parallel", content: mid ? `Paused worker for ${mid}.` : "All parallel workers paused.", display: false });
404
+ return;
405
+ }
406
+
407
+ if (subCmd === "resume") {
408
+ const mid = rest.trim() || undefined;
409
+ resumeWorker(projectRoot(), mid);
410
+ pi.sendMessage({ customType: "gsd-parallel", content: mid ? `Resumed worker for ${mid}.` : "All parallel workers resumed.", display: false });
411
+ return;
412
+ }
413
+
414
+ if (subCmd === "merge") {
415
+ const mid = rest.trim() || undefined;
416
+ if (mid) {
417
+ // Merge a specific milestone
418
+ const result = await mergeCompletedMilestone(projectRoot(), mid);
419
+ pi.sendMessage({ customType: "gsd-parallel", content: formatMergeResults([result]), display: false });
420
+ return;
421
+ }
422
+ // Merge all completed milestones
423
+ const workers = getWorkerStatuses();
424
+ if (workers.length === 0) {
425
+ pi.sendMessage({ customType: "gsd-parallel", content: "No parallel workers to merge.", display: false });
426
+ return;
427
+ }
428
+ const results = await mergeAllCompleted(projectRoot(), workers);
429
+ pi.sendMessage({ customType: "gsd-parallel", content: formatMergeResults(results), display: false });
430
+ return;
431
+ }
432
+
433
+ pi.sendMessage({
434
+ customType: "gsd-parallel",
435
+ content: `Unknown parallel subcommand "${subCmd}". Usage: /gsd parallel [start|status|stop|pause|resume|merge]`,
436
+ display: false,
437
+ });
438
+ return;
439
+ }
440
+
291
441
  if (trimmed === "cleanup") {
292
442
  await handleCleanupBranches(ctx, projectRoot());
293
443
  await handleCleanupSnapshots(ctx, projectRoot());
@@ -314,6 +464,21 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
314
464
  return;
315
465
  }
316
466
 
467
+ if (trimmed === "new-milestone") {
468
+ const basePath = projectRoot();
469
+ const headlessContextPath = join(basePath, ".gsd", "runtime", "headless-context.md");
470
+ if (existsSync(headlessContextPath)) {
471
+ const seedContext = readFileSync(headlessContextPath, "utf-8");
472
+ try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ }
473
+ await showHeadlessMilestoneCreation(ctx, pi, basePath, seedContext);
474
+ } else {
475
+ // No headless context — fall back to interactive smart entry
476
+ const { showSmartEntry } = await import("./guided-flow.js");
477
+ await showSmartEntry(ctx, pi, basePath);
478
+ }
479
+ return;
480
+ }
481
+
317
482
  if (trimmed.startsWith("capture ") || trimmed === "capture") {
318
483
  await handleCapture(trimmed.replace(/^capture\s*/, "").trim(), ctx);
319
484
  return;
@@ -434,6 +599,7 @@ function showHelp(ctx: ExtensionCommandContext): void {
434
599
  " /gsd stop Stop auto-mode gracefully",
435
600
  " /gsd pause Pause auto-mode (preserves state, /gsd auto to resume)",
436
601
  " /gsd discuss Start guided milestone/slice discussion",
602
+ " /gsd new-milestone Create milestone from headless context (used by gsd headless)",
437
603
  "",
438
604
  "VISIBILITY",
439
605
  " /gsd status Show progress dashboard (Ctrl+Alt+G)",
@@ -87,6 +87,7 @@ const UNIT_TYPE_TIERS: Record<string, ComplexityTier> = {
87
87
  "execute-task": "standard",
88
88
  "replan-slice": "heavy",
89
89
  "reassess-roadmap": "heavy",
90
+ "validate-milestone": "heavy",
90
91
  "complete-milestone": "standard",
91
92
  };
92
93
 
@@ -19,6 +19,7 @@ import {
19
19
  } from "./metrics.js";
20
20
  import { loadEffectiveGSDPreferences } from "./preferences.js";
21
21
  import { getActiveWorktreeName } from "./worktree-command.js";
22
+ import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js";
22
23
 
23
24
  function formatDuration(ms: number): string {
24
25
  const s = Math.floor(ms / 1000);
@@ -363,6 +364,43 @@ export class GSDDashboardOverlay {
363
364
  lines.push(blank());
364
365
  }
365
366
 
367
+ // Parallel workers section — shows active subagent sessions
368
+ if (hasActiveWorkers()) {
369
+ lines.push(hr());
370
+ lines.push(row(th.fg("text", th.bold("Parallel Workers"))));
371
+ lines.push(blank());
372
+
373
+ const batches = getWorkerBatches();
374
+ for (const [batchId, workers] of batches) {
375
+ const running = workers.filter(w => w.status === "running").length;
376
+ const done = workers.filter(w => w.status === "completed").length;
377
+ const failed = workers.filter(w => w.status === "failed").length;
378
+ const total = workers[0]?.batchSize ?? workers.length;
379
+
380
+ lines.push(row(joinColumns(
381
+ ` ${th.fg("accent", "⟐")} ${th.fg("text", `Batch ${batchId.slice(0, 8)}`)}`,
382
+ th.fg("dim", `${done + failed}/${total} done`),
383
+ contentWidth,
384
+ )));
385
+
386
+ for (const w of workers) {
387
+ const icon = w.status === "running"
388
+ ? th.fg("accent", "▸")
389
+ : w.status === "completed"
390
+ ? th.fg("success", "✓")
391
+ : th.fg("error", "✗");
392
+ const elapsed = th.fg("dim", formatDuration(Date.now() - w.startedAt));
393
+ const taskPreview = truncateToWidth(w.task, Math.max(20, contentWidth - 30));
394
+ lines.push(row(joinColumns(
395
+ ` ${icon} ${th.fg("text", w.agent)} ${th.fg("dim", taskPreview)}`,
396
+ elapsed,
397
+ contentWidth,
398
+ )));
399
+ }
400
+ }
401
+ lines.push(blank());
402
+ }
403
+
366
404
  // Pending captures badge — only shown when captures are waiting for triage
367
405
  if (this.dashData.pendingCaptureCount > 0) {
368
406
  const count = this.dashData.pendingCaptureCount;