gsd-pi 2.26.0 → 2.27.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 (171) hide show
  1. package/README.md +43 -6
  2. package/dist/cli.js +4 -2
  3. package/dist/headless.d.ts +3 -0
  4. package/dist/headless.js +136 -8
  5. package/dist/help-text.js +3 -0
  6. package/dist/loader.js +33 -4
  7. package/dist/resources/extensions/bg-shell/index.ts +19 -2
  8. package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
  9. package/dist/resources/extensions/bg-shell/types.ts +21 -1
  10. package/dist/resources/extensions/gsd/auto/session.ts +224 -0
  11. package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
  12. package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
  13. package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  14. package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
  15. package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
  16. package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
  17. package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
  18. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  19. package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  20. package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  21. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  22. package/dist/resources/extensions/gsd/auto.ts +977 -1551
  23. package/dist/resources/extensions/gsd/commands.ts +3 -3
  24. package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  25. package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
  26. package/dist/resources/extensions/gsd/export-html.ts +1001 -0
  27. package/dist/resources/extensions/gsd/export.ts +49 -1
  28. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  29. package/dist/resources/extensions/gsd/gitignore.ts +4 -1
  30. package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
  31. package/dist/resources/extensions/gsd/index.ts +54 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
  33. package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  35. package/dist/resources/extensions/gsd/preferences.ts +62 -1
  36. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
  37. package/dist/resources/extensions/gsd/prompts/system.md +1 -1
  38. package/dist/resources/extensions/gsd/reports.ts +510 -0
  39. package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
  40. package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  41. package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  42. package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  43. package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  44. package/dist/resources/extensions/gsd/state.ts +30 -0
  45. package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
  46. package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  47. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  48. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  49. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  50. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  51. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  52. package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  53. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  54. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  55. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  56. package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  57. package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  58. package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  59. package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  60. package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  61. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  62. package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  63. package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  64. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  65. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  66. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  67. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  68. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  69. package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  70. package/dist/resources/extensions/gsd/types.ts +38 -0
  71. package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
  72. package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
  73. package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
  74. package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  75. package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
  76. package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
  77. package/dist/resources/extensions/shared/format-utils.ts +85 -0
  78. package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  79. package/dist/resources/extensions/subagent/index.ts +46 -1
  80. package/dist/resources/extensions/subagent/isolation.ts +9 -6
  81. package/package.json +1 -1
  82. package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
  83. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  84. package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
  85. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
  87. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
  90. package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
  91. package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
  92. package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
  93. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  94. package/packages/pi-tui/dist/components/editor.js +1 -1
  95. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  96. package/packages/pi-tui/src/components/editor.ts +3 -1
  97. package/scripts/link-workspace-packages.cjs +22 -6
  98. package/src/resources/extensions/bg-shell/index.ts +19 -2
  99. package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
  100. package/src/resources/extensions/bg-shell/types.ts +21 -1
  101. package/src/resources/extensions/gsd/auto/session.ts +224 -0
  102. package/src/resources/extensions/gsd/auto-budget.ts +32 -0
  103. package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
  104. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  105. package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
  106. package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
  107. package/src/resources/extensions/gsd/auto-observability.ts +74 -0
  108. package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
  109. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  110. package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  111. package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  112. package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  113. package/src/resources/extensions/gsd/auto.ts +977 -1551
  114. package/src/resources/extensions/gsd/commands.ts +3 -3
  115. package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  116. package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
  117. package/src/resources/extensions/gsd/export-html.ts +1001 -0
  118. package/src/resources/extensions/gsd/export.ts +49 -1
  119. package/src/resources/extensions/gsd/git-service.ts +6 -0
  120. package/src/resources/extensions/gsd/gitignore.ts +4 -1
  121. package/src/resources/extensions/gsd/guided-flow.ts +24 -5
  122. package/src/resources/extensions/gsd/index.ts +54 -1
  123. package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
  124. package/src/resources/extensions/gsd/observability-validator.ts +21 -0
  125. package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  126. package/src/resources/extensions/gsd/preferences.ts +62 -1
  127. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
  128. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  129. package/src/resources/extensions/gsd/reports.ts +510 -0
  130. package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
  131. package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  132. package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  133. package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  134. package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  135. package/src/resources/extensions/gsd/state.ts +30 -0
  136. package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
  137. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  138. package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  139. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  140. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  141. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  142. package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  143. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  144. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  145. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  146. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  147. package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  148. package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  149. package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  150. package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  151. package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  152. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  153. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  154. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  155. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  156. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  157. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  158. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  159. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  160. package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  161. package/src/resources/extensions/gsd/types.ts +38 -0
  162. package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
  163. package/src/resources/extensions/gsd/verification-gate.ts +567 -0
  164. package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
  165. package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  166. package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
  167. package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
  168. package/src/resources/extensions/shared/format-utils.ts +85 -0
  169. package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  170. package/src/resources/extensions/subagent/index.ts +46 -1
  171. package/src/resources/extensions/subagent/isolation.ts +9 -6
@@ -15,25 +15,22 @@ import {
15
15
  type ProgressFilter,
16
16
  } from "./visualizer-views.js";
17
17
  import { writeExportFile } from "./export.js";
18
+ import { stripAnsi } from "../shared/format-utils.js";
18
19
 
19
20
  const TAB_COUNT = 10;
20
21
  const TAB_LABELS = [
21
22
  "1 Progress",
22
- "2 Deps",
23
- "3 Metrics",
24
- "4 Timeline",
25
- "5 Agent",
26
- "6 Changes",
27
- "7 Export",
23
+ "2 Timeline",
24
+ "3 Deps",
25
+ "4 Metrics",
26
+ "5 Health",
27
+ "6 Agent",
28
+ "7 Changes",
28
29
  "8 Knowledge",
29
30
  "9 Captures",
30
- "0 Health",
31
+ "0 Export",
31
32
  ];
32
33
 
33
- function stripAnsi(s: string): string {
34
- return s.replace(/\x1b\[[0-9;]*m/g, "");
35
- }
36
-
37
34
  export class GSDVisualizerOverlay {
38
35
  private tui: { requestRender: () => void };
39
36
  private theme: Theme;
@@ -62,6 +59,7 @@ export class GSDVisualizerOverlay {
62
59
  private lastVisibleRows = 20;
63
60
  collapsedMilestones = new Set<string>();
64
61
  showHelp = false;
62
+ private resizeHandler: (() => void) | null = null;
65
63
 
66
64
  constructor(
67
65
  tui: { requestRender: () => void },
@@ -76,6 +74,14 @@ export class GSDVisualizerOverlay {
76
74
  // Enable SGR mouse tracking
77
75
  process.stdout.write("\x1b[?1003h\x1b[?1006h");
78
76
 
77
+ // Invalidate cache on terminal resize
78
+ this.resizeHandler = () => {
79
+ if (this.disposed) return;
80
+ this.invalidate();
81
+ this.tui.requestRender();
82
+ };
83
+ process.stdout.on("resize", this.resizeHandler);
84
+
79
85
  loadVisualizerData(this.basePath).then((d) => {
80
86
  this.data = d;
81
87
  this.loading = false;
@@ -89,7 +95,7 @@ export class GSDVisualizerOverlay {
89
95
  this.invalidate();
90
96
  this.tui.requestRender();
91
97
  });
92
- }, 2000);
98
+ }, 5000);
93
99
  }
94
100
 
95
101
  private parseSGRMouse(data: string): { button: number; x: number; y: number; press: boolean } | null {
@@ -262,7 +268,7 @@ export class GSDVisualizerOverlay {
262
268
  }
263
269
 
264
270
  // Export tab key handling
265
- if (this.activeTab === 6 && this.data) {
271
+ if (this.activeTab === 9 && this.data) {
266
272
  if (data === "m" || data === "j" || data === "s") {
267
273
  this.handleExportKey(data);
268
274
  return;
@@ -372,23 +378,23 @@ export class GSDVisualizerOverlay {
372
378
  return renderProgressView(this.data, th, width, filter, this.collapsedMilestones);
373
379
  }
374
380
  case 1:
375
- return renderDepsView(this.data, th, width);
381
+ return renderTimelineView(this.data, th, width);
376
382
  case 2:
377
- return renderMetricsView(this.data, th, width);
383
+ return renderDepsView(this.data, th, width);
378
384
  case 3:
379
- return renderTimelineView(this.data, th, width);
385
+ return renderMetricsView(this.data, th, width);
380
386
  case 4:
381
- return renderAgentView(this.data, th, width);
387
+ return renderHealthView(this.data, th, width);
382
388
  case 5:
383
- return renderChangelogView(this.data, th, width);
389
+ return renderAgentView(this.data, th, width);
384
390
  case 6:
385
- return renderExportView(this.data, th, width, this.lastExportPath);
391
+ return renderChangelogView(this.data, th, width);
386
392
  case 7:
387
393
  return renderKnowledgeView(this.data, th, width);
388
394
  case 8:
389
395
  return renderCapturesView(this.data, th, width);
390
396
  case 9:
391
- return renderHealthView(this.data, th, width);
397
+ return renderExportView(this.data, th, width, this.lastExportPath);
392
398
  default:
393
399
  return [];
394
400
  }
@@ -470,7 +476,7 @@ export class GSDVisualizerOverlay {
470
476
  let viewLines = this.renderTabContent(this.activeTab, innerWidth);
471
477
 
472
478
  // Show export status message if present
473
- if (this.exportStatus && this.activeTab === 6) {
479
+ if (this.exportStatus && this.activeTab === 9) {
474
480
  content.push(th.fg("success", this.exportStatus));
475
481
  content.push("");
476
482
  this.exportStatus = undefined;
@@ -547,6 +553,10 @@ export class GSDVisualizerOverlay {
547
553
  dispose(): void {
548
554
  this.disposed = true;
549
555
  clearInterval(this.refreshTimer);
556
+ if (this.resizeHandler) {
557
+ process.stdout.removeListener("resize", this.resizeHandler);
558
+ this.resizeHandler = null;
559
+ }
550
560
  // Disable SGR mouse tracking
551
561
  process.stdout.write("\x1b[?1003l\x1b[?1006l");
552
562
  }
@@ -4,41 +4,8 @@ import type { Theme } from "@gsd/pi-coding-agent";
4
4
  import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
5
5
  import type { VisualizerData, VisualizerMilestone, SliceVerification, VisualizerSliceActivity, VisualizerStats, VisualizerSliceRef } from "./visualizer-data.js";
6
6
  import { formatCost, formatTokenCount, classifyUnitPhase } from "./metrics.js";
7
-
8
- // ─── Local Helpers ───────────────────────────────────────────────────────────
9
-
10
- function formatDuration(ms: number): string {
11
- const s = Math.floor(ms / 1000);
12
- if (s < 60) return `${s}s`;
13
- const m = Math.floor(s / 60);
14
- const rs = s % 60;
15
- if (m < 60) return `${m}m ${rs}s`;
16
- const h = Math.floor(m / 60);
17
- const rm = m % 60;
18
- return `${h}h ${rm}m`;
19
- }
20
-
21
- function padRight(content: string, width: number): string {
22
- const vis = visibleWidth(content);
23
- return content + " ".repeat(Math.max(0, width - vis));
24
- }
25
-
26
- function joinColumns(left: string, right: string, width: number): string {
27
- const leftW = visibleWidth(left);
28
- const rightW = visibleWidth(right);
29
- if (leftW + rightW + 2 > width) {
30
- return truncateToWidth(`${left} ${right}`, width);
31
- }
32
- return left + " ".repeat(width - leftW - rightW) + right;
33
- }
34
-
35
- function sparkline(values: number[]): string {
36
- if (values.length === 0) return "";
37
- const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
38
- const max = Math.max(...values);
39
- if (max === 0) return chars[0].repeat(values.length);
40
- return values.map(v => chars[Math.min(7, Math.floor((v / max) * 7))]).join("");
41
- }
7
+ import { formatDuration, padRight, joinColumns, sparkline } from "../shared/format-utils.js";
8
+ import { STATUS_GLYPH, STATUS_COLOR } from "../shared/ui.js";
42
9
 
43
10
  function formatCompletionDate(input: string): string {
44
11
  if (!input) return "unknown";
@@ -168,18 +135,9 @@ export function renderProgressView(
168
135
  }
169
136
 
170
137
  // Milestone header line
171
- const statusGlyph =
172
- ms.status === "complete"
173
- ? th.fg("success", "\u2713")
174
- : ms.status === "active"
175
- ? th.fg("accent", "\u25b8")
176
- : th.fg("dim", "\u25cb");
177
- const statusLabel =
178
- ms.status === "complete"
179
- ? th.fg("success", "complete")
180
- : ms.status === "active"
181
- ? th.fg("accent", "active")
182
- : th.fg("dim", "pending");
138
+ const msStatus = ms.status === "complete" ? "done" : ms.status === "active" ? "active" : "pending";
139
+ const statusGlyph = th.fg(STATUS_COLOR[msStatus], STATUS_GLYPH[msStatus]);
140
+ const statusLabel = th.fg(STATUS_COLOR[msStatus], ms.status);
183
141
 
184
142
  const collapseIndicator = collapsed?.has(ms.id) ? "[+] " : "";
185
143
  const msLeft = `${collapseIndicator}${ms.id}: ${ms.title}`;
@@ -206,11 +164,8 @@ export function renderProgressView(
206
164
  }
207
165
 
208
166
  // Slice line
209
- const slGlyph = sl.done
210
- ? th.fg("success", "\u2713")
211
- : sl.active
212
- ? th.fg("accent", "\u25b8")
213
- : th.fg("dim", "\u25cb");
167
+ const slStatus = sl.done ? "done" : sl.active ? "active" : "pending";
168
+ const slGlyph = th.fg(STATUS_COLOR[slStatus], STATUS_GLYPH[slStatus]);
214
169
  const riskColor =
215
170
  sl.risk === "high"
216
171
  ? "warning"
@@ -241,11 +196,8 @@ export function renderProgressView(
241
196
  // Show tasks for active slice
242
197
  if (sl.active && sl.tasks.length > 0) {
243
198
  for (const task of sl.tasks) {
244
- const tGlyph = task.done
245
- ? th.fg("success", "\u2713")
246
- : task.active
247
- ? th.fg("accent", "\u25b8")
248
- : th.fg("dim", "\u25cb");
199
+ const tStatus = task.done ? "done" : task.active ? "active" : "pending";
200
+ const tGlyph = th.fg(STATUS_COLOR[tStatus], STATUS_GLYPH[tStatus]);
249
201
  const estimateStr = task.estimate ? th.fg("dim", ` (${task.estimate})`) : "";
250
202
  lines.push(` ${tGlyph} ${task.id}: ${task.title}${estimateStr}`);
251
203
  }
@@ -683,10 +635,8 @@ function renderTimelineList(data: VisualizerData, th: Theme, width: number): str
683
635
  const time = `${hh}:${mm}`;
684
636
 
685
637
  const duration = unit.finishedAt - unit.startedAt;
686
- const glyph =
687
- unit.finishedAt > 0
688
- ? th.fg("success", "\u2713")
689
- : th.fg("accent", "\u25b8");
638
+ const unitStatus = unit.finishedAt > 0 ? "done" : "active";
639
+ const glyph = th.fg(STATUS_COLOR[unitStatus], STATUS_GLYPH[unitStatus]);
690
640
 
691
641
  const typeLabel = padRight(unit.type, 16);
692
642
  const idLabel = padRight(unit.id, 14);
@@ -802,9 +752,8 @@ export function renderAgentView(
802
752
  }
803
753
 
804
754
  // Status line
805
- const statusDot = activity.active
806
- ? th.fg("success", "\u25cf")
807
- : th.fg("dim", "\u25cb");
755
+ const agentStatus = activity.active ? "active" : "pending";
756
+ const statusDot = th.fg(STATUS_COLOR[agentStatus], STATUS_GLYPH[agentStatus]);
808
757
  const statusText = activity.active ? "ACTIVE" : "IDLE";
809
758
  const elapsedStr = activity.active ? formatDuration(activity.elapsed) : "\u2014";
810
759
 
@@ -877,7 +826,7 @@ export function renderAgentView(
877
826
  const typeLabel = padRight(u.type, 16);
878
827
  lines.push(
879
828
  truncateToWidth(
880
- ` ${hh}:${mm} ${th.fg("success", "\u2713")} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`,
829
+ ` ${hh}:${mm} ${th.fg(STATUS_COLOR.done, STATUS_GLYPH.done)} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`,
881
830
  width,
882
831
  ),
883
832
  );
@@ -920,7 +869,7 @@ export function renderChangelogView(
920
869
  for (const f of entry.filesModified) {
921
870
  lines.push(
922
871
  truncateToWidth(
923
- ` ${th.fg("success", "\u2713")} ${f.path} \u2014 ${f.description}`,
872
+ ` ${th.fg(STATUS_COLOR.done, STATUS_GLYPH.done)} ${f.path} \u2014 ${f.description}`,
924
873
  width,
925
874
  ),
926
875
  );
@@ -104,6 +104,12 @@ interface SearchDetails {
104
104
  const searchCache = new LRUTTLCache<CachedSearchResult>({ max: 100, ttlMs: 600_000 });
105
105
  searchCache.startPurgeInterval(60_000);
106
106
 
107
+ // Consecutive duplicate search guard (#949)
108
+ // Tracks recent query keys to detect and break search loops.
109
+ const MAX_CONSECUTIVE_DUPES = 3;
110
+ let lastSearchKey = "";
111
+ let consecutiveDupeCount = 0;
112
+
107
113
  // Summarizer responses: max 50 entries, 15-minute TTL
108
114
  const summarizerCache = new LRUTTLCache<string>({ max: 50, ttlMs: 900_000 });
109
115
 
@@ -388,6 +394,26 @@ export function registerSearchTool(pi: ExtensionAPI) {
388
394
  // Cache lookup (provider-prefixed key)
389
395
  // ------------------------------------------------------------------
390
396
  const cacheKey = normalizeQuery(effectiveQuery) + `|f:${freshness || ""}|s:${wantSummary}|p:${provider}`;
397
+
398
+ // ── Consecutive duplicate search guard (#949) ──────────────────────
399
+ // If the LLM keeps calling the same search query, break the loop
400
+ // with an explicit warning instead of returning the same results.
401
+ if (cacheKey === lastSearchKey) {
402
+ consecutiveDupeCount++;
403
+ if (consecutiveDupeCount >= MAX_CONSECUTIVE_DUPES) {
404
+ consecutiveDupeCount = 0;
405
+ lastSearchKey = "";
406
+ return {
407
+ content: [{ type: "text" as const, text: `⚠️ Search loop detected: the query "${params.query}" has been searched ${MAX_CONSECUTIVE_DUPES + 1} times consecutively with identical results. The information you need is already in the previous search results above. Stop searching and use those results to proceed with your task.` }],
408
+ isError: true,
409
+ details: { errorKind: "search_loop", error: "Consecutive duplicate search detected" } satisfies Partial<SearchDetails>,
410
+ };
411
+ }
412
+ } else {
413
+ lastSearchKey = cacheKey;
414
+ consecutiveDupeCount = 0;
415
+ }
416
+
391
417
  const cached = searchCache.get(cacheKey);
392
418
 
393
419
  if (cached) {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared formatting and layout utilities for TUI dashboard components.
3
+ *
4
+ * Consolidates helpers that were previously duplicated across
5
+ * auto-dashboard.ts, dashboard-overlay.ts, and visualizer-views.ts.
6
+ */
7
+
8
+ import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
9
+
10
+ // ─── Duration Formatting ──────────────────────────────────────────────────────
11
+
12
+ /** Format a millisecond duration as a compact human-readable string. */
13
+ export function formatDuration(ms: number): string {
14
+ const s = Math.floor(ms / 1000);
15
+ if (s < 60) return `${s}s`;
16
+ const m = Math.floor(s / 60);
17
+ const rs = s % 60;
18
+ if (m < 60) return `${m}m ${rs}s`;
19
+ const h = Math.floor(m / 60);
20
+ const rm = m % 60;
21
+ return `${h}h ${rm}m`;
22
+ }
23
+
24
+ // ─── Layout Helpers ───────────────────────────────────────────────────────────
25
+
26
+ /** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
27
+ export function padRight(content: string, width: number): string {
28
+ const vis = visibleWidth(content);
29
+ return content + " ".repeat(Math.max(0, width - vis));
30
+ }
31
+
32
+ /** Build a line with left-aligned and right-aligned content. */
33
+ export function joinColumns(left: string, right: string, width: number): string {
34
+ const leftW = visibleWidth(left);
35
+ const rightW = visibleWidth(right);
36
+ if (leftW + rightW + 2 > width) {
37
+ return truncateToWidth(`${left} ${right}`, width);
38
+ }
39
+ return left + " ".repeat(width - leftW - rightW) + right;
40
+ }
41
+
42
+ /** Center content within `width` (ANSI-aware). */
43
+ export function centerLine(content: string, width: number): string {
44
+ const vis = visibleWidth(content);
45
+ if (vis >= width) return truncateToWidth(content, width);
46
+ const leftPad = Math.floor((width - vis) / 2);
47
+ return " ".repeat(leftPad) + content;
48
+ }
49
+
50
+ /** Join as many parts as fit within `width`, separated by `separator`. */
51
+ export function fitColumns(parts: string[], width: number, separator = " "): string {
52
+ const filtered = parts.filter(Boolean);
53
+ if (filtered.length === 0) return "";
54
+ let result = filtered[0];
55
+ for (let i = 1; i < filtered.length; i++) {
56
+ const candidate = `${result}${separator}${filtered[i]}`;
57
+ if (visibleWidth(candidate) > width) break;
58
+ result = candidate;
59
+ }
60
+ return truncateToWidth(result, width);
61
+ }
62
+
63
+ // ─── Data Visualization ───────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Render a sparkline from numeric values using Unicode block characters.
67
+ * Uses loop-based max to avoid stack overflow on large arrays.
68
+ */
69
+ export function sparkline(values: number[]): string {
70
+ if (values.length === 0) return "";
71
+ const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
72
+ let max = 0;
73
+ for (const v of values) {
74
+ if (v > max) max = v;
75
+ }
76
+ if (max === 0) return chars[0].repeat(values.length);
77
+ return values.map(v => chars[Math.min(7, Math.floor((v / max) * 7))]).join("");
78
+ }
79
+
80
+ // ─── ANSI Stripping ───────────────────────────────────────────────────────────
81
+
82
+ /** Strip ANSI escape sequences from a string. */
83
+ export function stripAnsi(s: string): string {
84
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
85
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ formatDuration,
5
+ padRight,
6
+ joinColumns,
7
+ centerLine,
8
+ fitColumns,
9
+ sparkline,
10
+ stripAnsi,
11
+ } from "../format-utils.js";
12
+
13
+ describe("formatDuration", () => {
14
+ it("formats seconds", () => {
15
+ assert.equal(formatDuration(0), "0s");
16
+ assert.equal(formatDuration(5_000), "5s");
17
+ assert.equal(formatDuration(59_000), "59s");
18
+ });
19
+
20
+ it("formats minutes and seconds", () => {
21
+ assert.equal(formatDuration(60_000), "1m 0s");
22
+ assert.equal(formatDuration(90_000), "1m 30s");
23
+ assert.equal(formatDuration(3_540_000), "59m 0s");
24
+ });
25
+
26
+ it("formats hours and minutes", () => {
27
+ assert.equal(formatDuration(3_600_000), "1h 0m");
28
+ assert.equal(formatDuration(5_400_000), "1h 30m");
29
+ assert.equal(formatDuration(7_200_000), "2h 0m");
30
+ });
31
+ });
32
+
33
+ describe("padRight", () => {
34
+ it("pads plain text to width", () => {
35
+ const result = padRight("abc", 6);
36
+ assert.equal(result, "abc ");
37
+ });
38
+
39
+ it("does not pad when text fills width", () => {
40
+ const result = padRight("abcdef", 6);
41
+ assert.equal(result, "abcdef");
42
+ });
43
+
44
+ it("does not pad when text exceeds width", () => {
45
+ const result = padRight("abcdefgh", 6);
46
+ assert.equal(result, "abcdefgh");
47
+ });
48
+ });
49
+
50
+ describe("joinColumns", () => {
51
+ it("joins left and right with spacing", () => {
52
+ const result = joinColumns("left", "right", 20);
53
+ assert.equal(result.length, 20);
54
+ assert.ok(result.startsWith("left"));
55
+ assert.ok(result.endsWith("right"));
56
+ });
57
+
58
+ it("truncates when content overflows", () => {
59
+ const result = joinColumns("a".repeat(20), "b".repeat(20), 30);
60
+ // Should be truncated to 30 chars
61
+ assert.ok(result.length <= 30);
62
+ });
63
+ });
64
+
65
+ describe("centerLine", () => {
66
+ it("centers text within width", () => {
67
+ const result = centerLine("hi", 10);
68
+ assert.equal(result, " hi");
69
+ });
70
+
71
+ it("truncates when content exceeds width", () => {
72
+ const result = centerLine("abcdefgh", 4);
73
+ assert.ok(result.length <= 4);
74
+ });
75
+ });
76
+
77
+ describe("fitColumns", () => {
78
+ it("joins parts that fit", () => {
79
+ const result = fitColumns(["aaa", "bbb", "ccc"], 20);
80
+ assert.ok(result.includes("aaa"));
81
+ assert.ok(result.includes("bbb"));
82
+ assert.ok(result.includes("ccc"));
83
+ });
84
+
85
+ it("drops parts that overflow", () => {
86
+ const result = fitColumns(["aaa", "bbb", "ccc"], 10);
87
+ assert.ok(result.includes("aaa"));
88
+ // May or may not include bbb depending on separator width
89
+ });
90
+
91
+ it("returns empty string for empty array", () => {
92
+ assert.equal(fitColumns([], 80), "");
93
+ });
94
+
95
+ it("filters out empty strings", () => {
96
+ const result = fitColumns(["aaa", "", "bbb"], 80);
97
+ assert.ok(result.includes("aaa"));
98
+ assert.ok(result.includes("bbb"));
99
+ });
100
+ });
101
+
102
+ describe("sparkline", () => {
103
+ it("returns empty string for empty array", () => {
104
+ assert.equal(sparkline([]), "");
105
+ });
106
+
107
+ it("renders all lowest blocks for all-zero values", () => {
108
+ const result = sparkline([0, 0, 0]);
109
+ assert.equal(result.length, 3);
110
+ // All chars should be the same (lowest block)
111
+ assert.equal(result[0], result[1]);
112
+ assert.equal(result[1], result[2]);
113
+ });
114
+
115
+ it("renders highest block for max value", () => {
116
+ const result = sparkline([0, 10, 5]);
117
+ assert.equal(result.length, 3);
118
+ // Middle should be highest block (█)
119
+ assert.equal(result[1], "\u2588");
120
+ });
121
+
122
+ it("handles single value", () => {
123
+ const result = sparkline([42]);
124
+ assert.equal(result.length, 1);
125
+ assert.equal(result, "\u2588");
126
+ });
127
+
128
+ it("handles large arrays without stack overflow", () => {
129
+ const largeArray = new Array(100_000).fill(0).map((_, i) => i);
130
+ const result = sparkline(largeArray);
131
+ assert.equal(result.length, 100_000);
132
+ });
133
+ });
134
+
135
+ describe("stripAnsi", () => {
136
+ it("strips ANSI escape sequences", () => {
137
+ const result = stripAnsi("\x1b[31mred\x1b[0m text");
138
+ assert.equal(result, "red text");
139
+ });
140
+
141
+ it("returns plain text unchanged", () => {
142
+ assert.equal(stripAnsi("plain text"), "plain text");
143
+ });
144
+
145
+ it("strips multiple escape sequences", () => {
146
+ const result = stripAnsi("\x1b[1m\x1b[32mbold green\x1b[0m");
147
+ assert.equal(result, "bold green");
148
+ });
149
+
150
+ it("handles empty string", () => {
151
+ assert.equal(stripAnsi(""), "");
152
+ });
153
+ });
@@ -12,7 +12,7 @@
12
12
  * Uses JSON mode to capture structured output from subagents.
13
13
  */
14
14
 
15
- import { spawn } from "node:child_process";
15
+ import { spawn, type ChildProcess } from "node:child_process";
16
16
  import * as crypto from "node:crypto";
17
17
  import * as fs from "node:fs";
18
18
  import * as os from "node:os";
@@ -38,6 +38,44 @@ import { registerWorker, updateWorker } from "./worker-registry.js";
38
38
  const MAX_PARALLEL_TASKS = 8;
39
39
  const MAX_CONCURRENCY = 4;
40
40
  const COLLAPSED_ITEM_COUNT = 10;
41
+ const liveSubagentProcesses = new Set<ChildProcess>();
42
+
43
+ async function stopLiveSubagents(): Promise<void> {
44
+ const active = Array.from(liveSubagentProcesses);
45
+ if (active.length === 0) return;
46
+
47
+ for (const proc of active) {
48
+ try {
49
+ proc.kill("SIGTERM");
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ }
54
+
55
+ await Promise.all(
56
+ active.map(
57
+ (proc) =>
58
+ new Promise<void>((resolve) => {
59
+ const done = () => resolve();
60
+ const timer = setTimeout(done, 500);
61
+ proc.once("exit", () => {
62
+ clearTimeout(timer);
63
+ resolve();
64
+ });
65
+ }),
66
+ ),
67
+ );
68
+
69
+ for (const proc of active) {
70
+ if (proc.exitCode === null) {
71
+ try {
72
+ proc.kill("SIGKILL");
73
+ } catch {
74
+ /* ignore */
75
+ }
76
+ }
77
+ }
78
+ }
41
79
 
42
80
  function formatTokens(count: number): string {
43
81
  if (count < 1000) return count.toString();
@@ -302,6 +340,7 @@ async function runSingleAgent(
302
340
  [process.env.GSD_BIN_PATH!, ...extensionArgs, ...args],
303
341
  { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] },
304
342
  );
343
+ liveSubagentProcesses.add(proc);
305
344
  let buffer = "";
306
345
 
307
346
  const processLine = (line: string) => {
@@ -353,11 +392,13 @@ async function runSingleAgent(
353
392
  });
354
393
 
355
394
  proc.on("close", (code) => {
395
+ liveSubagentProcesses.delete(proc);
356
396
  if (buffer.trim()) processLine(buffer);
357
397
  resolve(code ?? 0);
358
398
  });
359
399
 
360
400
  proc.on("error", () => {
401
+ liveSubagentProcesses.delete(proc);
361
402
  resolve(1);
362
403
  });
363
404
 
@@ -432,6 +473,10 @@ const SubagentParams = Type.Object({
432
473
  });
433
474
 
434
475
  export default function (pi: ExtensionAPI) {
476
+ pi.on("session_shutdown", async () => {
477
+ await stopLiveSubagents();
478
+ });
479
+
435
480
  // /subagent command - list available agents
436
481
  pi.registerCommand("subagent", {
437
482
  description: "List available subagents",
@@ -273,13 +273,16 @@ export async function createWorktreeIsolation(
273
273
  async cleanup(): Promise<void> {
274
274
  activeIsolations.delete(worktreeDir);
275
275
  try {
276
- await git(
277
- ["worktree", "remove", "--force", worktreeDir],
278
- repoRoot,
279
- );
276
+ await Promise.race([
277
+ git(["worktree", "remove", "--force", worktreeDir], repoRoot),
278
+ new Promise<never>((_, reject) =>
279
+ setTimeout(() => reject(new Error("Worktree cleanup timed out")), 10_000),
280
+ ),
281
+ ]);
280
282
  } catch {
281
- // Force remove directory if git worktree remove fails
282
- fs.rmSync(worktreeDir, { recursive: true, force: true });
283
+ try {
284
+ fs.rmSync(worktreeDir, { recursive: true, force: true });
285
+ } catch { /* best effort */ }
283
286
  }
284
287
  },
285
288
  };