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
@@ -559,7 +559,9 @@ export class Editor implements Component, Focusable {
559
559
  this.state.cursorLine = result.cursorLine;
560
560
  this.setCursorCol(result.cursorCol);
561
561
 
562
- if (this.autocompletePrefix.startsWith("/")) {
562
+ if (this.autocompletePrefix.startsWith("/") || this.isInSlashCommandContext(
563
+ (this.state.lines[this.state.cursorLine] || "").slice(0, this.state.cursorCol),
564
+ )) {
563
565
  this.cancelAutocomplete();
564
566
  // Fall through to submit
565
567
  } else {
@@ -10,8 +10,12 @@
10
10
  * to resolve. This script bridges the gap.
11
11
  *
12
12
  * Runs as part of postinstall (before any ESM code that imports @gsd/*).
13
+ *
14
+ * On Windows without Developer Mode or administrator rights, creating symlinks
15
+ * (even NTFS junctions) can fail with EPERM. In that case we fall back to
16
+ * cpSync (directory copy) which works universally.
13
17
  */
14
- const { existsSync, mkdirSync, symlinkSync, lstatSync, readlinkSync, unlinkSync, readdirSync } = require('fs')
18
+ const { existsSync, mkdirSync, symlinkSync, cpSync, lstatSync, readlinkSync, unlinkSync } = require('fs')
15
19
  const { resolve, join } = require('path')
16
20
 
17
21
  const root = resolve(__dirname, '..')
@@ -33,6 +37,7 @@ if (!existsSync(nodeModulesGsd)) {
33
37
  }
34
38
 
35
39
  let linked = 0
40
+ let copied = 0
36
41
  for (const [dir, name] of Object.entries(packageMap)) {
37
42
  const source = join(packagesDir, dir)
38
43
  const target = join(nodeModulesGsd, name)
@@ -50,21 +55,32 @@ for (const [dir, name] of Object.entries(packageMap)) {
50
55
  }
51
56
  unlinkSync(target) // Wrong target, relink
52
57
  } else {
53
- continue // Real directory (e.g., from bundleDependencies), don't touch
58
+ continue // Real directory (e.g., copied or from bundleDependencies), don't touch
54
59
  }
55
60
  } catch {
56
61
  continue
57
62
  }
58
63
  }
59
64
 
65
+ let symlinkOk = false
60
66
  try {
61
67
  symlinkSync(source, target, 'junction') // junction works on Windows too
68
+ symlinkOk = true
62
69
  linked++
63
70
  } catch {
64
- // Non-fatalmay fail in read-only environments
71
+ // Symlink failed common on Windows without Developer Mode or admin rights.
72
+ // Fall back to a directory copy so the package is still resolvable.
65
73
  }
66
- }
67
74
 
68
- if (linked > 0) {
69
- process.stderr.write(` Linked ${linked} workspace packages\n`)
75
+ if (!symlinkOk) {
76
+ try {
77
+ cpSync(source, target, { recursive: true })
78
+ copied++
79
+ } catch {
80
+ // Non-fatal — loader.ts will emit a clearer error if resolution still fails
81
+ }
82
+ }
70
83
  }
84
+
85
+ if (linked > 0) process.stderr.write(` Linked ${linked} workspace package${linked !== 1 ? 's' : ''}\n`)
86
+ if (copied > 0) process.stderr.write(` Copied ${copied} workspace package${copied !== 1 ? 's' : ''} (symlinks unavailable)\n`)
@@ -52,6 +52,7 @@ import {
52
52
  getGroupStatus,
53
53
  pruneDeadProcesses,
54
54
  cleanupAll,
55
+ cleanupSessionProcesses,
55
56
  persistManifest,
56
57
  loadManifest,
57
58
  pushAlert,
@@ -71,7 +72,7 @@ import { toPosixPath } from "../shared/path-display.js";
71
72
  // ── Re-exports for consumers ───────────────────────────────────────────────
72
73
 
73
74
  export type { ProcessStatus, ProcessType, BgProcess, BgProcessInfo, OutputDigest, OutputLine, ProcessEvent } from "./types.js";
74
- export { processes, startProcess, killProcess, restartProcess, cleanupAll } from "./process-manager.js";
75
+ export { processes, startProcess, killProcess, restartProcess, cleanupAll, cleanupSessionProcesses } from "./process-manager.js";
75
76
  export { generateDigest, getHighlights, getOutput, formatDigestText } from "./output-formatter.js";
76
77
  export { waitForReady, probePort } from "./readiness-detector.js";
77
78
  export { sendAndWait, runOnSession, queryShellEnv } from "./interaction.js";
@@ -136,7 +137,13 @@ export default function (pi: ExtensionAPI) {
136
137
  });
137
138
 
138
139
  // Session switch resets the agent's context.
139
- pi.on("session_switch", async () => {
140
+ pi.on("session_switch", async (event, ctx) => {
141
+ latestCtx = ctx;
142
+ if (event.reason === "new" && event.previousSessionFile) {
143
+ await cleanupSessionProcesses(event.previousSessionFile);
144
+ syncLatestCtxCwd();
145
+ if (latestCtx) persistManifest(latestCtx.cwd);
146
+ }
140
147
  buildProcessStateAlert("Session was switched.");
141
148
  });
142
149
 
@@ -232,6 +239,7 @@ export default function (pi: ExtensionAPI) {
232
239
  "Use 'run' to execute a command on a persistent shell session and block until it completes — returns structured output + exit code. Shell state (env vars, cwd, virtualenvs) persists across runs.",
233
240
  "Use 'send_and_wait' for interactive CLIs: send input and wait for expected output pattern.",
234
241
  "Use 'env' to check the current working directory and active environment variables of a shell session — useful after cd, source, or export commands.",
242
+ "Background processes are session-scoped by default: a new session reaps them unless you set persist_across_sessions:true.",
235
243
  "Use 'restart' to kill and relaunch with the same config — preserves restart count.",
236
244
  "Background processes are auto-classified (server/build/test/watcher) based on the command.",
237
245
  "Process crashes and errors are automatically surfaced as alerts at the start of your next turn — you don't need to poll.",
@@ -300,6 +308,12 @@ export default function (pi: ExtensionAPI) {
300
308
  group: Type.Optional(
301
309
  Type.String({ description: "Group name for related processes (for start, group_status)" }),
302
310
  ),
311
+ persist_across_sessions: Type.Optional(
312
+ Type.Boolean({
313
+ description: "Keep this process running after a new session starts. Default: false.",
314
+ default: false,
315
+ }),
316
+ ),
303
317
  }),
304
318
 
305
319
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -318,6 +332,8 @@ export default function (pi: ExtensionAPI) {
318
332
  const bg = startProcess({
319
333
  command: params.command,
320
334
  cwd: ctx.cwd,
335
+ ownerSessionFile: ctx.sessionManager.getSessionFile() ?? null,
336
+ persistAcrossSessions: params.persist_across_sessions ?? false,
321
337
  label: params.label,
322
338
  type: params.type as ProcessType | undefined,
323
339
  readyPattern: params.ready_pattern,
@@ -341,6 +357,7 @@ export default function (pi: ExtensionAPI) {
341
357
  text += ` cwd: ${toPosixPath(bg.cwd)}`;
342
358
 
343
359
  if (bg.group) text += `\n group: ${bg.group}`;
360
+ if (bg.persistAcrossSessions) text += `\n persist_across_sessions: true`;
344
361
  if (bg.readyPort) text += `\n ready_port: ${bg.readyPort}`;
345
362
  if (bg.readyPattern) text += `\n ready_pattern: ${bg.readyPattern}`;
346
363
  if (bg.ports.length > 0) text += `\n detected ports: ${bg.ports.join(", ")}`;
@@ -67,6 +67,8 @@ export function getInfo(p: BgProcess): BgProcessInfo {
67
67
  label: p.label,
68
68
  command: p.command,
69
69
  cwd: p.cwd,
70
+ ownerSessionFile: p.ownerSessionFile,
71
+ persistAcrossSessions: p.persistAcrossSessions,
70
72
  startedAt: p.startedAt,
71
73
  alive: p.alive,
72
74
  exitCode: p.exitCode,
@@ -138,6 +140,8 @@ export function startProcess(opts: StartOptions): BgProcess {
138
140
  label: opts.label || command.slice(0, 60),
139
141
  command,
140
142
  cwd: opts.cwd,
143
+ ownerSessionFile: opts.ownerSessionFile ?? null,
144
+ persistAcrossSessions: opts.persistAcrossSessions ?? false,
141
145
  startedAt: Date.now(),
142
146
  proc,
143
147
  output: [],
@@ -170,6 +174,8 @@ export function startProcess(opts: StartOptions): BgProcess {
170
174
  cwd: opts.cwd,
171
175
  label: opts.label || command.slice(0, 60),
172
176
  processType,
177
+ ownerSessionFile: opts.ownerSessionFile ?? null,
178
+ persistAcrossSessions: opts.persistAcrossSessions ?? false,
173
179
  readyPattern: opts.readyPattern || null,
174
180
  readyPort: opts.readyPort || null,
175
181
  group: opts.group || null,
@@ -312,6 +318,8 @@ export async function restartProcess(id: string): Promise<BgProcess | null> {
312
318
  cwd: config.cwd,
313
319
  label: config.label,
314
320
  type: config.processType,
321
+ ownerSessionFile: config.ownerSessionFile,
322
+ persistAcrossSessions: config.persistAcrossSessions,
315
323
  readyPattern: config.readyPattern || undefined,
316
324
  readyPort: config.readyPort || undefined,
317
325
  group: config.group || undefined,
@@ -367,6 +375,41 @@ export function cleanupAll(): void {
367
375
  processes.clear();
368
376
  }
369
377
 
378
+ async function waitForProcessExit(bg: BgProcess, timeoutMs: number): Promise<boolean> {
379
+ if (!bg.alive) return true;
380
+ await new Promise<void>((resolve) => {
381
+ const done = () => resolve();
382
+ const timer = setTimeout(done, timeoutMs);
383
+ bg.proc.once("exit", () => {
384
+ clearTimeout(timer);
385
+ resolve();
386
+ });
387
+ });
388
+ return !bg.alive;
389
+ }
390
+
391
+ export async function cleanupSessionProcesses(
392
+ sessionFile: string,
393
+ options?: { graceMs?: number },
394
+ ): Promise<string[]> {
395
+ const graceMs = Math.max(0, options?.graceMs ?? 300);
396
+ const matches = Array.from(processes.values()).filter(
397
+ (bg) => bg.alive && !bg.persistAcrossSessions && bg.ownerSessionFile === sessionFile,
398
+ );
399
+ if (matches.length === 0) return [];
400
+
401
+ for (const bg of matches) {
402
+ killProcess(bg.id, "SIGTERM");
403
+ }
404
+ if (graceMs > 0) {
405
+ await Promise.all(matches.map((bg) => waitForProcessExit(bg, graceMs)));
406
+ }
407
+ for (const bg of matches) {
408
+ if (bg.alive) killProcess(bg.id, "SIGKILL");
409
+ }
410
+ return matches.map((bg) => bg.id);
411
+ }
412
+
370
413
  // ── Persistence ────────────────────────────────────────────────────────────
371
414
 
372
415
  export function getManifestPath(cwd: string): string {
@@ -384,6 +427,8 @@ export function persistManifest(cwd: string): void {
384
427
  label: p.label,
385
428
  command: p.command,
386
429
  cwd: p.cwd,
430
+ ownerSessionFile: p.ownerSessionFile,
431
+ persistAcrossSessions: p.persistAcrossSessions,
387
432
  startedAt: p.startedAt,
388
433
  processType: p.processType,
389
434
  group: p.group,
@@ -53,6 +53,10 @@ export interface BgProcess {
53
53
  label: string;
54
54
  command: string;
55
55
  cwd: string;
56
+ /** Session file that created this process (used for per-session cleanup) */
57
+ ownerSessionFile: string | null;
58
+ /** Whether this process should survive a new-session boundary */
59
+ persistAcrossSessions: boolean;
56
60
  startedAt: number;
57
61
  proc: import("node:child_process").ChildProcess;
58
62
  /** Unified chronologically-interleaved output buffer */
@@ -103,7 +107,17 @@ export interface BgProcess {
103
107
  /** Restart count */
104
108
  restartCount: number;
105
109
  /** Original start config for restart */
106
- startConfig: { command: string; cwd: string; label: string; processType: ProcessType; readyPattern: string | null; readyPort: number | null; group: string | null };
110
+ startConfig: {
111
+ command: string;
112
+ cwd: string;
113
+ label: string;
114
+ processType: ProcessType;
115
+ ownerSessionFile: string | null;
116
+ persistAcrossSessions: boolean;
117
+ readyPattern: string | null;
118
+ readyPort: number | null;
119
+ group: string | null;
120
+ };
107
121
  }
108
122
 
109
123
  export interface BgProcessInfo {
@@ -111,6 +125,8 @@ export interface BgProcessInfo {
111
125
  label: string;
112
126
  command: string;
113
127
  cwd: string;
128
+ ownerSessionFile: string | null;
129
+ persistAcrossSessions: boolean;
114
130
  startedAt: number;
115
131
  alive: boolean;
116
132
  exitCode: number | null;
@@ -133,6 +149,8 @@ export interface BgProcessInfo {
133
149
  export interface StartOptions {
134
150
  command: string;
135
151
  cwd: string;
152
+ ownerSessionFile?: string | null;
153
+ persistAcrossSessions?: boolean;
136
154
  label?: string;
137
155
  type?: ProcessType;
138
156
  readyPattern?: string;
@@ -154,6 +172,8 @@ export interface ProcessManifest {
154
172
  label: string;
155
173
  command: string;
156
174
  cwd: string;
175
+ ownerSessionFile: string | null;
176
+ persistAcrossSessions: boolean;
157
177
  startedAt: number;
158
178
  processType: ProcessType;
159
179
  group: string | null;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * AutoSession — encapsulates all mutable auto-mode state into a single instance.
3
+ *
4
+ * Replaces ~40 module-level variables scattered across auto.ts with typed
5
+ * properties on a class instance. Benefits:
6
+ *
7
+ * - reset() clears everything in one call (was 25+ manual resets in stopAuto)
8
+ * - toJSON() provides diagnostic snapshots
9
+ * - grep `s.` shows every state access
10
+ * - Constructable for testing
11
+ */
12
+
13
+ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
14
+ import type { GitServiceImpl } from "../git-service.js";
15
+ import type { CaptureEntry } from "../captures.js";
16
+ import type { BudgetAlertLevel } from "../auto-budget.js";
17
+
18
+ // ─── Exported Types ──────────────────────────────────────────────────────────
19
+
20
+ export interface CompletedUnit {
21
+ type: string;
22
+ id: string;
23
+ startedAt: number;
24
+ finishedAt: number;
25
+ }
26
+
27
+ export interface CurrentUnit {
28
+ type: string;
29
+ id: string;
30
+ startedAt: number;
31
+ }
32
+
33
+ export interface UnitRouting {
34
+ tier: string;
35
+ modelDowngraded: boolean;
36
+ }
37
+
38
+ export interface StartModel {
39
+ provider: string;
40
+ id: string;
41
+ }
42
+
43
+ export interface PendingVerificationRetry {
44
+ unitId: string;
45
+ failureContext: string;
46
+ attempt: number;
47
+ }
48
+
49
+ // ─── Constants ───────────────────────────────────────────────────────────────
50
+
51
+ export const MAX_UNIT_DISPATCHES = 3;
52
+ export const STUB_RECOVERY_THRESHOLD = 2;
53
+ export const MAX_LIFETIME_DISPATCHES = 6;
54
+ export const MAX_CONSECUTIVE_SKIPS = 3;
55
+ export const DISPATCH_GAP_TIMEOUT_MS = 5_000;
56
+ export const MAX_SKIP_DEPTH = 20;
57
+
58
+ // ─── AutoSession ─────────────────────────────────────────────────────────────
59
+
60
+ export class AutoSession {
61
+ // ── Lifecycle ────────────────────────────────────────────────────────────
62
+ active = false;
63
+ paused = false;
64
+ stepMode = false;
65
+ verbose = false;
66
+ cmdCtx: ExtensionCommandContext | null = null;
67
+
68
+ // ── Paths ────────────────────────────────────────────────────────────────
69
+ basePath = "";
70
+ originalBasePath = "";
71
+ gitService: GitServiceImpl | null = null;
72
+
73
+ // ── Dispatch counters ────────────────────────────────────────────────────
74
+ readonly unitDispatchCount = new Map<string, number>();
75
+ readonly unitLifetimeDispatches = new Map<string, number>();
76
+ readonly unitRecoveryCount = new Map<string, number>();
77
+ readonly unitConsecutiveSkips = new Map<string, number>();
78
+ readonly completedKeySet = new Set<string>();
79
+
80
+ // ── Timers ───────────────────────────────────────────────────────────────
81
+ unitTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
82
+ wrapupWarningHandle: ReturnType<typeof setTimeout> | null = null;
83
+ idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
84
+ continueHereHandle: ReturnType<typeof setInterval> | null = null;
85
+ dispatchGapHandle: ReturnType<typeof setTimeout> | null = null;
86
+
87
+ // ── Current unit ─────────────────────────────────────────────────────────
88
+ currentUnit: CurrentUnit | null = null;
89
+ currentUnitRouting: UnitRouting | null = null;
90
+ completedUnits: CompletedUnit[] = [];
91
+ currentMilestoneId: string | null = null;
92
+
93
+ // ── Model state ──────────────────────────────────────────────────────────
94
+ autoModeStartModel: StartModel | null = null;
95
+ originalModelId: string | null = null;
96
+ originalModelProvider: string | null = null;
97
+ lastBudgetAlertLevel: BudgetAlertLevel = 0;
98
+
99
+ // ── Recovery ─────────────────────────────────────────────────────────────
100
+ pendingCrashRecovery: string | null = null;
101
+ pendingVerificationRetry: PendingVerificationRetry | null = null;
102
+ readonly verificationRetryCount = new Map<string, number>();
103
+ pausedSessionFile: string | null = null;
104
+ resourceVersionOnStart: string | null = null;
105
+ lastStateRebuildAt = 0;
106
+
107
+ // ── Guards ───────────────────────────────────────────────────────────────
108
+ handlingAgentEnd = false;
109
+ dispatching = false;
110
+ skipDepth = 0;
111
+ readonly recentlyEvictedKeys = new Set<string>();
112
+
113
+ // ── Metrics ──────────────────────────────────────────────────────────────
114
+ autoStartTime = 0;
115
+ lastPromptCharCount: number | undefined;
116
+ lastBaselineCharCount: number | undefined;
117
+ pendingQuickTasks: CaptureEntry[] = [];
118
+
119
+ // ── Signal handler ───────────────────────────────────────────────────────
120
+ sigtermHandler: (() => void) | null = null;
121
+
122
+ // ── Methods ──────────────────────────────────────────────────────────────
123
+
124
+ clearTimers(): void {
125
+ if (this.unitTimeoutHandle) { clearTimeout(this.unitTimeoutHandle); this.unitTimeoutHandle = null; }
126
+ if (this.wrapupWarningHandle) { clearTimeout(this.wrapupWarningHandle); this.wrapupWarningHandle = null; }
127
+ if (this.idleWatchdogHandle) { clearInterval(this.idleWatchdogHandle); this.idleWatchdogHandle = null; }
128
+ if (this.continueHereHandle) { clearInterval(this.continueHereHandle); this.continueHereHandle = null; }
129
+ if (this.dispatchGapHandle) { clearTimeout(this.dispatchGapHandle); this.dispatchGapHandle = null; }
130
+ }
131
+
132
+ resetDispatchCounters(): void {
133
+ this.unitDispatchCount.clear();
134
+ this.unitLifetimeDispatches.clear();
135
+ this.unitConsecutiveSkips.clear();
136
+ }
137
+
138
+ get lockBasePath(): string {
139
+ return this.originalBasePath || this.basePath;
140
+ }
141
+
142
+ completeCurrentUnit(): CompletedUnit | null {
143
+ if (!this.currentUnit) return null;
144
+ const done: CompletedUnit = { ...this.currentUnit, finishedAt: Date.now() };
145
+ this.completedUnits.push(done);
146
+ this.currentUnit = null;
147
+ return done;
148
+ }
149
+
150
+ reset(): void {
151
+ this.clearTimers();
152
+
153
+ // Lifecycle
154
+ this.active = false;
155
+ this.paused = false;
156
+ this.stepMode = false;
157
+ this.verbose = false;
158
+ this.cmdCtx = null;
159
+
160
+ // Paths
161
+ this.basePath = "";
162
+ this.originalBasePath = "";
163
+ this.gitService = null;
164
+
165
+ // Dispatch
166
+ this.unitDispatchCount.clear();
167
+ this.unitLifetimeDispatches.clear();
168
+ this.unitRecoveryCount.clear();
169
+ this.unitConsecutiveSkips.clear();
170
+ // Note: completedKeySet is intentionally NOT cleared — it persists
171
+ // across restarts to prevent re-dispatching completed units.
172
+
173
+ // Unit
174
+ this.currentUnit = null;
175
+ this.currentUnitRouting = null;
176
+ this.completedUnits = [];
177
+ this.currentMilestoneId = null;
178
+
179
+ // Model
180
+ this.autoModeStartModel = null;
181
+ this.originalModelId = null;
182
+ this.originalModelProvider = null;
183
+ this.lastBudgetAlertLevel = 0;
184
+
185
+ // Recovery
186
+ this.pendingCrashRecovery = null;
187
+ this.pendingVerificationRetry = null;
188
+ this.verificationRetryCount.clear();
189
+ this.pausedSessionFile = null;
190
+ this.resourceVersionOnStart = null;
191
+ this.lastStateRebuildAt = 0;
192
+
193
+ // Guards
194
+ this.handlingAgentEnd = false;
195
+ this.dispatching = false;
196
+ this.skipDepth = 0;
197
+ this.recentlyEvictedKeys.clear();
198
+
199
+ // Metrics
200
+ this.autoStartTime = 0;
201
+ this.lastPromptCharCount = undefined;
202
+ this.lastBaselineCharCount = undefined;
203
+ this.pendingQuickTasks = [];
204
+
205
+ // Signal handler
206
+ this.sigtermHandler = null;
207
+ }
208
+
209
+ toJSON(): Record<string, unknown> {
210
+ return {
211
+ active: this.active,
212
+ paused: this.paused,
213
+ stepMode: this.stepMode,
214
+ basePath: this.basePath,
215
+ currentMilestoneId: this.currentMilestoneId,
216
+ currentUnit: this.currentUnit,
217
+ completedUnits: this.completedUnits.length,
218
+ completedKeySet: this.completedKeySet.size,
219
+ unitDispatchCount: Object.fromEntries(this.unitDispatchCount),
220
+ dispatching: this.dispatching,
221
+ skipDepth: this.skipDepth,
222
+ };
223
+ }
224
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Budget alert level tracking and enforcement for auto-mode.
3
+ * Pure functions — no module state or side effects.
4
+ */
5
+
6
+ import type { BudgetEnforcementMode } from "./types.js";
7
+
8
+ export type BudgetAlertLevel = 0 | 75 | 80 | 90 | 100;
9
+
10
+ export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
11
+ if (budgetPct >= 1.0) return 100;
12
+ if (budgetPct >= 0.90) return 90;
13
+ if (budgetPct >= 0.80) return 80;
14
+ if (budgetPct >= 0.75) return 75;
15
+ return 0;
16
+ }
17
+
18
+ export function getNewBudgetAlertLevel(previousLevel: BudgetAlertLevel, budgetPct: number): BudgetAlertLevel | null {
19
+ const currentLevel = getBudgetAlertLevel(budgetPct);
20
+ if (currentLevel === 0 || currentLevel <= previousLevel) return null;
21
+ return currentLevel;
22
+ }
23
+
24
+ export function getBudgetEnforcementAction(
25
+ enforcement: BudgetEnforcementMode,
26
+ budgetPct: number,
27
+ ): "none" | "warn" | "pause" | "halt" {
28
+ if (budgetPct < 1.0) return "none";
29
+ if (enforcement === "halt") return "halt";
30
+ if (enforcement === "pause") return "pause";
31
+ return "warn";
32
+ }
@@ -6,7 +6,7 @@
6
6
  * or AutoContext dependency. State accessors are passed as callbacks.
7
7
  */
8
8
 
9
- import type { ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent";
9
+ import type { ExtensionContext, ExtensionCommandContext, SessionMessageEntry } from "@gsd/pi-coding-agent";
10
10
  import type { GSDState } from "./types.js";
11
11
  import { getCurrentBranch } from "./worktree.js";
12
12
  import { getActiveHook } from "./post-unit-hooks.js";
@@ -159,6 +159,49 @@ export function formatWidgetTokens(count: number): string {
159
159
  return `${Math.round(count / 1000000)}M`;
160
160
  }
161
161
 
162
+ // ─── ETA Estimation ──────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Estimate remaining time based on average unit duration from the metrics ledger.
166
+ * Returns a formatted string like "~12m remaining" or null if insufficient data.
167
+ */
168
+ export function estimateTimeRemaining(): string | null {
169
+ const ledger = getLedger();
170
+ if (!ledger || ledger.units.length < 2) return null;
171
+
172
+ const sliceProgress = getRoadmapSlicesSync();
173
+ if (!sliceProgress || sliceProgress.total === 0) return null;
174
+
175
+ const remainingSlices = sliceProgress.total - sliceProgress.done;
176
+ if (remainingSlices <= 0) return null;
177
+
178
+ // Compute average duration per completed slice from the ledger
179
+ const completedSliceUnits = ledger.units.filter(
180
+ u => u.finishedAt > 0 && u.startedAt > 0,
181
+ );
182
+ if (completedSliceUnits.length < 2) return null;
183
+
184
+ const totalDuration = completedSliceUnits.reduce(
185
+ (sum, u) => sum + (u.finishedAt - u.startedAt), 0,
186
+ );
187
+ const avgDuration = totalDuration / completedSliceUnits.length;
188
+
189
+ // Rough estimate: remaining slices × average units per slice × avg duration
190
+ const completedSlices = sliceProgress.done || 1;
191
+ const unitsPerSlice = completedSliceUnits.length / completedSlices;
192
+ const estimatedMs = remainingSlices * unitsPerSlice * avgDuration;
193
+
194
+ if (estimatedMs < 5_000) return null; // Too small to display
195
+
196
+ const s = Math.floor(estimatedMs / 1000);
197
+ if (s < 60) return `~${s}s remaining`;
198
+ const m = Math.floor(s / 60);
199
+ if (m < 60) return `~${m}m remaining`;
200
+ const h = Math.floor(m / 60);
201
+ const rm = m % 60;
202
+ return rm > 0 ? `~${h}h ${rm}m remaining` : `~${h}h remaining`;
203
+ }
204
+
162
205
  // ─── Slice Progress Cache ─────────────────────────────────────────────────────
163
206
 
164
207
  /** Cached slice progress for the widget — avoid async in render */
@@ -277,15 +320,16 @@ export function updateProgressWidget(
277
320
  tui.requestRender();
278
321
  }, 800);
279
322
 
280
- // Refresh progress cache from disk every 5s so the widget reflects
323
+ // Refresh progress cache from disk every 15s so the widget reflects
281
324
  // task/slice completion mid-unit. Without this, the progress bar only
282
325
  // updates at dispatch time, appearing frozen during long-running units.
326
+ // 15s (vs 5s) reduces synchronous file I/O on the hot path.
283
327
  const progressRefreshTimer = mid ? setInterval(() => {
284
328
  try {
285
329
  updateSliceProgressCache(accessors.getBasePath(), mid.id, slice?.id);
286
330
  cachedLines = undefined;
287
331
  } catch { /* non-fatal */ }
288
- }, 5_000) : null;
332
+ }, 15_000) : null;
289
333
 
290
334
  return {
291
335
  render(width: number): string[] {
@@ -346,6 +390,12 @@ export function updateProgressWidget(
346
390
  meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`);
347
391
  }
348
392
 
393
+ // ETA estimate
394
+ const eta = estimateTimeRemaining();
395
+ if (eta) {
396
+ meta += theme.fg("dim", ` · ${eta}`);
397
+ }
398
+
349
399
  lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width));
350
400
  }
351
401
  }
@@ -370,13 +420,16 @@ export function updateProgressWidget(
370
420
  let totalCacheRead = 0, totalCacheWrite = 0;
371
421
  if (cmdCtx) {
372
422
  for (const entry of cmdCtx.sessionManager.getEntries()) {
373
- if (entry.type === "message" && (entry as any).message?.role === "assistant") {
374
- const u = (entry as any).message.usage;
375
- if (u) {
376
- totalInput += u.input || 0;
377
- totalOutput += u.output || 0;
378
- totalCacheRead += u.cacheRead || 0;
379
- totalCacheWrite += u.cacheWrite || 0;
423
+ if (entry.type === "message") {
424
+ const msgEntry = entry as SessionMessageEntry;
425
+ if (msgEntry.message?.role === "assistant") {
426
+ const u = (msgEntry.message as any).usage;
427
+ if (u) {
428
+ totalInput += u.input || 0;
429
+ totalOutput += u.output || 0;
430
+ totalCacheRead += u.cacheRead || 0;
431
+ totalCacheWrite += u.cacheWrite || 0;
432
+ }
380
433
  }
381
434
  }
382
435
  }