@zhuxixi/pi-agent-board 0.4.3 → 0.5.1

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 (39) hide show
  1. package/PROGRESS.md +18 -3
  2. package/README.md +295 -72
  3. package/VERIFY.md +3 -3
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
  5. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
  6. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
  7. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
  8. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
  9. package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
  10. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
  11. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
  12. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
  13. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -0
  14. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +77 -0
  15. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +80 -0
  16. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
  17. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -0
  18. package/package.json +1 -1
  19. package/runner/job-runner.mjs +29 -3
  20. package/runner/pty-runner.mjs +139 -25
  21. package/runner/state-runner.mjs +7 -2
  22. package/runner/title-runner.mjs +1 -1
  23. package/src/core/atomic.mjs +40 -1
  24. package/src/core/code-refs-store.mjs +315 -0
  25. package/src/core/code-refs.mjs +861 -0
  26. package/src/core/host-crash.mjs +39 -0
  27. package/src/core/launch.mjs +6 -0
  28. package/src/core/paths.mjs +24 -1
  29. package/src/core/pty-attach-jiggle-controller.mjs +71 -17
  30. package/src/core/pty-attach-reconnect.mjs +43 -0
  31. package/src/core/pty-scroll.mjs +4 -3
  32. package/src/core/repo.mjs +56 -0
  33. package/src/core/rows.mjs +50 -0
  34. package/src/core/store.mjs +4 -1
  35. package/src/core/types.mjs +12 -0
  36. package/src/core/worktree.mjs +1 -0
  37. package/src/runtime/service.mjs +7 -1
  38. package/src/ui/dashboard.ts +20 -4
  39. package/src/ui/pty-attach.ts +124 -27
@@ -16,11 +16,12 @@ import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
16
16
  import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualCompletion } from "../src/core/auto-state.mjs";
17
17
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
18
18
  import { emptyEvidenceSnapshot, finalizeEvidence, reduceEvidence, summarizeEvidence, writeEvidence, writeRunEvidence } from "../src/core/evidence.mjs";
19
+ import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
19
20
  import { claimNextFollowUp, completeFollowUp, releaseFollowUp } from "../src/core/follow-up-queue.mjs";
20
21
  import { newRunId } from "../src/core/ids.mjs";
21
22
  import { launchRun } from "../src/core/launch.mjs";
22
23
  import * as P from "../src/core/paths.mjs";
23
- import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
24
+ import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
24
25
  import { readSteering, recordPlanReady } from "../src/core/steering.mjs";
25
26
  import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../src/core/steering-prompts.mjs";
26
27
 
@@ -54,12 +55,16 @@ function main() {
54
55
  const stderrLog = P.stderrPath(root, viewId, runId);
55
56
  const eventsLog = P.eventsPath(root, viewId, runId);
56
57
 
58
+ // Read the view meta once so code-ref extraction reuses it across every evidence write.
59
+ const meta = readMeta(root, viewId);
60
+
57
61
  let status = createRunStatus(config, null, Date.now());
58
62
  let evidence = emptyEvidenceSnapshot({ viewId, runId, source: "json-runner" });
59
63
  appendDiagnostic(root, viewId, { source: "runner", runId, code: "runner_start", message: "Runner started", details: { kind: config.kind, cwd: config.cwd, model: config.model } });
60
64
  writeStatus(root, status);
61
65
  writeRunEvidence(root, evidence);
62
66
  writeEvidence(root, evidence);
67
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
63
68
  writeState(root, projectViewState(status, Date.now(), readState(root, viewId)));
64
69
 
65
70
  // Build worker args: pi --mode json -p --session <file> [--model m] [--thinking l] [--tools t] <prompt>
@@ -80,6 +85,7 @@ function main() {
80
85
  const worker = spawn(config.piCommand, args, {
81
86
  cwd: config.cwd,
82
87
  stdio: ["ignore", "pipe", "pipe"],
88
+ windowsHide: true,
83
89
  env: process.env,
84
90
  });
85
91
 
@@ -99,6 +105,10 @@ function main() {
99
105
  writeRunEvidence(root, evidence);
100
106
  writeEvidence(root, evidence);
101
107
  writeState(root, projectViewState(status, now, readState(root, viewId)));
108
+ // Best-effort code-refs extraction shells out to git and can take hundreds of
109
+ // ms; run it after the state write so endedAt-visible state converges first.
110
+ // The extraction only depends on evidence + git, never on state.json.
111
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
102
112
  dirty = false;
103
113
  };
104
114
 
@@ -212,7 +222,7 @@ function main() {
212
222
  if (applyHeuristicAutoState(config, status, evidence)) {
213
223
  finalizeEvidence(evidence, status, Date.now());
214
224
  status.evidenceSummary = summarizeEvidence(evidence);
215
- persist(true);
225
+ persistUnlessManual(true);
216
226
  }
217
227
  maybeModelAutoState(config, status, evidence)
218
228
  .then((changed) => {
@@ -249,6 +259,10 @@ function main() {
249
259
  function finalizeSteeringIfNeeded(config, status, evidence) {
250
260
  if (config.kind !== "plan" && config.kind !== "plan_change") return;
251
261
  if (status.semanticState === "failed" || status.semanticState === "stopped") return;
262
+ // A manual completion racing the exit chain must not be resurrected for
263
+ // approval: the user already closed this row. Same signal as the other
264
+ // post-exit guards (completeView writes completed+autoState null to state.json).
265
+ if (isManualCompletion(readState(config.root, config.viewId))) return;
252
266
  recordPlanReady(config.root, config.viewId, {
253
267
  runId: config.runId,
254
268
  planText: latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "Plan ready",
@@ -269,6 +283,11 @@ function finalizeSteeringIfNeeded(config, status, evidence) {
269
283
  /** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status */
270
284
  function drainQueuedFollowUp(config, status) {
271
285
  if (status.semanticState !== "idle" && status.semanticState !== "completed") return;
286
+ // A manual completion racing the exit chain must never be followed up: the
287
+ // user just finished this row, so don't launch a new run over it. The
288
+ // in-memory status may be stale (fresh-read guards skip classification), so
289
+ // check the authoritative state.json signal.
290
+ if (isManualCompletion(readState(config.root, config.viewId))) return;
272
291
  if (config.kind === "plan" || config.kind === "plan_change") return;
273
292
  const claimed = claimNextFollowUp(config.root, config.viewId);
274
293
  if (!claimed.ok || !claimed.item) return;
@@ -352,6 +371,13 @@ function canAutoState(config, status, evidence) {
352
371
 
353
372
  function applyHeuristicAutoState(config, status, evidence) {
354
373
  if (!canAutoState(config, status, evidence)) return false;
374
+ // Fresh read of state.json (not status.json): completeView writes the manual
375
+ // completion signal (semanticState "completed" + autoState null) to state.json
376
+ // and only clears autoState in status.json, so status.json can never carry
377
+ // the completed+null pair. If the user marked the row done while the worker
378
+ // was exiting, skip classification so the persist path can't clobber it.
379
+ const latestState = readState(config.root, config.viewId);
380
+ if (isManualCompletion(latestState)) return false;
355
381
  const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
356
382
  const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
357
383
  const changed = applyAutoStateToStatus(status, classification, Date.now());
@@ -434,7 +460,7 @@ async function maybeModelSummary(config, status) {
434
460
  function runOneShot(command, args, timeoutMs = 20000) {
435
461
  return new Promise((resolve) => {
436
462
  let out = "";
437
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
463
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
438
464
  let buf = "";
439
465
  child.stdout.on("data", (c) => {
440
466
  buf += c.toString();
@@ -11,7 +11,11 @@ import { spawn } from "node:child_process";
11
11
  import { createRequire } from "node:module";
12
12
  import { createServer } from "node:net";
13
13
  import { existsSync, unlinkSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
14
16
  import { appendLine, readJson } from "../src/core/atomic.mjs";
17
+ import { appendDiagnostic } from "../src/core/diagnostics.mjs";
18
+ import { finalizeHostCrash } from "../src/core/host-crash.mjs";
15
19
  import * as P from "../src/core/paths.mjs";
16
20
  import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
17
21
  import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
@@ -54,8 +58,20 @@ function main() {
54
58
  /** @type {Set<import("node:net").Socket>} */
55
59
  const clients = new Set();
56
60
  let childPid = null;
61
+ let child = null;
57
62
  let exitCode = null;
58
- let stopping = false;
63
+ let shutdownStarted = false;
64
+ let shutdownExitCode = null;
65
+ let childExited = false;
66
+ let resolveChildExit;
67
+ const childExitPromise = new Promise((resolve) => {
68
+ resolveChildExit = resolve;
69
+ });
70
+ // Set when the uncaughtException crash handler finalizes the host. Guards
71
+ // child.onExit against clobbering the persisted "failed" state with an
72
+ // "exited" update (the handler kills the child, so its exit callback fires
73
+ // inside the 50ms flush window — CR round-1, issue #48).
74
+ let crashed = false;
59
75
  /** @type {import("../src/core/types.mjs").HostStatus} */
60
76
  let host = {
61
77
  version: 1,
@@ -75,7 +91,24 @@ function main() {
75
91
  attachedClients: 0,
76
92
  attachedEver: false,
77
93
  };
78
- const persist = () => writeHost(config.root, host);
94
+ /** Persist host.json. A transient failure (e.g. Windows rename EPERM racing a
95
+ * reader) must degrade, not kill the host: record a diagnostic and let the
96
+ * next heartbeat tick retry. Socket protocol is the attach main channel, so
97
+ * host.json being briefly stale is acceptable. */
98
+ const persist = () => {
99
+ try {
100
+ writeHost(config.root, host);
101
+ } catch (err) {
102
+ try {
103
+ appendDiagnostic(config.root, config.viewId, {
104
+ source: "runner",
105
+ level: "error",
106
+ code: "persist_error",
107
+ message: err instanceof Error ? err.message : String(err),
108
+ });
109
+ } catch { /* diagnostics must never kill the host either */ }
110
+ }
111
+ };
79
112
  const broadcast = (msg) => {
80
113
  const line = JSON.stringify(msg) + "\n";
81
114
  for (const c of clients) c.write(line);
@@ -87,6 +120,32 @@ function main() {
87
120
  };
88
121
  persist();
89
122
 
123
+ // Last-resort crash path (registered early, before spawnInteractive, so any
124
+ // early synchronous failure is also covered): the runner is launched detached
125
+ // with stdio ignored, so an uncaught exception is otherwise completely silent —
126
+ // no host.json finalize, no exit message, and the attach view reconnects
127
+ // forever. Record diagnostics, finalize the host as failed, and broadcast exit
128
+ // so attached clients can leave the view instead of looping.
129
+ process.on("uncaughtException", (err) => {
130
+ process.removeAllListeners("uncaughtException");
131
+ try {
132
+ appendDiagnostic(config.root, config.viewId, {
133
+ source: "runner",
134
+ level: "error",
135
+ code: "runner_crash",
136
+ message: err instanceof Error ? err.message : String(err),
137
+ details: { stack: err instanceof Error ? err.stack : undefined },
138
+ });
139
+ } catch { /* best effort */ }
140
+ crashed = true;
141
+ host = finalizeHostCrash(config.root, config.viewId, host, err);
142
+ try {
143
+ broadcast({ type: "exit", exitCode: 1 });
144
+ } catch { /* best effort */ }
145
+ try { if (child) killChild(child, childPid, "SIGTERM"); } catch { /* best effort */ }
146
+ setTimeout(() => process.exit(1), 50).unref?.();
147
+ });
148
+
90
149
  const args = [...config.piArgsPrefix, "--session", config.sessionFile];
91
150
  if (config.model) args.push("--model", config.model);
92
151
  if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
@@ -107,7 +166,6 @@ function main() {
107
166
  AGENT_VIEW_HOSTED: "pty",
108
167
  };
109
168
 
110
- let child;
111
169
  try {
112
170
  child = spawnInteractive(config.piCommand, args, {
113
171
  cwd: config.cwd,
@@ -123,25 +181,32 @@ function main() {
123
181
  process.exit(1);
124
182
  }
125
183
  childPid = child.pid ?? null;
126
- update({ childPid, state: "alive" });
184
+ update({ childPid });
127
185
 
128
186
  child.onData((data) => {
129
187
  screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
130
188
  broadcast({ type: "output", data });
131
189
  });
132
190
  child.onExit((code) => {
191
+ childExited = true;
192
+ resolveChildExit?.();
133
193
  exitCode = code ?? 0;
134
- update({ state: stopping ? "exited" : "exited", endedAt: Date.now(), exitCode, childPid: null });
135
- broadcast({ type: "exit", exitCode });
136
- setTimeout(() => process.exit(exitCode ?? 0), 50).unref?.();
194
+ // After a crash the handler already persisted "failed" and broadcast
195
+ // exit; this callback must not overwrite that state.
196
+ if (!crashed) {
197
+ update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null });
198
+ broadcast({ type: "exit", exitCode });
199
+ }
200
+ if (!shutdownStarted) setTimeout(() => process.exit(exitCode ?? 0), 50).unref?.();
137
201
  });
138
202
  child.onError((err) => {
139
203
  update({ state: "failed", endedAt: Date.now(), exitCode: 1, error: err instanceof Error ? err.message : String(err) });
140
204
  broadcast({ type: "error", message: host.error || "child error" });
141
- setTimeout(() => process.exit(1), 50).unref?.();
205
+ void shutdown(1);
142
206
  });
143
207
 
144
- const server = createServer((socket) => {
208
+ let server;
209
+ server = createServer((socket) => {
145
210
  clients.add(socket);
146
211
  update({ attachedEver: true });
147
212
  socket.write(JSON.stringify({ type: "hello", status: host }) + "\n");
@@ -163,8 +228,7 @@ function main() {
163
228
  });
164
229
  server.on("error", (err) => {
165
230
  update({ state: "failed", endedAt: Date.now(), error: err instanceof Error ? err.message : String(err), exitCode: 1 });
166
- try { child.kill("SIGTERM"); } catch {}
167
- process.exit(1);
231
+ void shutdown(1);
168
232
  });
169
233
  server.listen(socketPath, () => update({ socketPath, state: "alive" }));
170
234
 
@@ -189,11 +253,11 @@ function main() {
189
253
  case "interrupt":
190
254
  child.write("\x1b");
191
255
  break;
192
- case "terminate":
193
- stopping = true;
194
- child.kill("SIGTERM");
195
- setTimeout(() => child.kill("SIGKILL"), 4000).unref?.();
256
+ case "terminate": {
257
+ killChild(child, childPid, "SIGTERM");
258
+ setTimeout(() => killChild(child, childPid, "SIGKILL"), 4000).unref?.();
196
259
  break;
260
+ }
197
261
  case "detach":
198
262
  socket.end();
199
263
  break;
@@ -208,15 +272,45 @@ function main() {
208
272
  }, HEARTBEAT_MS);
209
273
  heartbeat.unref?.();
210
274
 
211
- const shutdown = () => {
212
- stopping = true;
213
- try { server.close(); } catch {}
275
+ function waitForChildExit(timeoutMs) {
276
+ if (childExited) return Promise.resolve(true);
277
+ return new Promise((resolve) => {
278
+ let settled = false;
279
+ let timer;
280
+ const finish = (exited) => {
281
+ if (settled) return;
282
+ settled = true;
283
+ clearTimeout(timer);
284
+ resolve(exited);
285
+ };
286
+ timer = setTimeout(() => finish(false), timeoutMs);
287
+ childExitPromise.then(() => finish(true));
288
+ });
289
+ }
290
+
291
+ async function shutdown(requestedExitCode = null) {
292
+ if (requestedExitCode !== null) shutdownExitCode = requestedExitCode;
293
+ if (shutdownStarted) return;
294
+ shutdownStarted = true;
295
+ try { server?.close(); } catch {}
214
296
  try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
215
- try { child.kill("SIGTERM"); } catch {}
216
- setTimeout(() => process.exit(0), 100).unref?.();
217
- };
218
- process.on("SIGTERM", shutdown);
219
- process.on("SIGINT", shutdown);
297
+ for (const client of clients) {
298
+ try { client.end(); } catch {}
299
+ }
300
+ killChild(child, childPid, "SIGTERM");
301
+ if (!(await waitForChildExit(4000)) && !childExited) {
302
+ killChild(child, childPid, "SIGKILL");
303
+ if (!(await waitForChildExit(1000)) && !childExited) {
304
+ // The child abstraction has no portable liveness probe. Exit only
305
+ // after the escalation window so normal children are always awaited;
306
+ // an unkillable platform child is left to the OS.
307
+ process.exit(1);
308
+ }
309
+ }
310
+ process.exit(shutdownExitCode ?? exitCode ?? 0);
311
+ }
312
+ process.on("SIGTERM", () => { void shutdown(); });
313
+ process.on("SIGINT", () => { void shutdown(); });
220
314
  }
221
315
 
222
316
  function spawnInteractive(command, args, opts) {
@@ -245,7 +339,7 @@ function spawnInteractive(command, args, opts) {
245
339
  }
246
340
  if (!opts.allowPipeFallback) throw new Error("node-pty is unavailable");
247
341
 
248
- const proc = spawn(command, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"] });
342
+ const proc = spawn(command, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
249
343
  return {
250
344
  pid: proc.pid ?? null,
251
345
  write: (s) => proc.stdin.write(s),
@@ -264,6 +358,26 @@ function send(socket, msg) {
264
358
  socket.write(JSON.stringify(msg) + "\n");
265
359
  }
266
360
 
361
+ /**
362
+ * Terminate the hosted child on all platforms. node-pty's kill() throws
363
+ * "Signals not supported on windows", so on win32 we TerminateProcess via
364
+ * process.kill; on unix keep the graceful SIGTERM/SIGKILL path through the pty.
365
+ * @param {{ kill: (signal: string) => void }} child
366
+ * @param {number|null} pid
367
+ * @param {"SIGTERM"|"SIGKILL"} [signal]
368
+ */
369
+ function killChild(child, pid, signal = "SIGTERM") {
370
+ if (process.platform === "win32") {
371
+ if (pid) {
372
+ try {
373
+ process.kill(pid, "SIGKILL");
374
+ return;
375
+ } catch {}
376
+ }
377
+ }
378
+ try { child.kill(signal); } catch {}
379
+ }
380
+
267
381
  function clampInt(value, min, max, fallback) {
268
382
  const n = Number(value);
269
383
  if (!Number.isFinite(n)) return fallback;
@@ -302,7 +416,7 @@ function markRowFailed(root, viewId, message) {
302
416
  }
303
417
 
304
418
  function failEarly(message) {
305
- try { appendLine("/tmp/pi-agent-board-pty-runner.err", message); } catch {}
419
+ try { appendLine(join(tmpdir(), "pi-agent-board-pty-runner.err"), message); } catch {}
306
420
  process.stderr.write(`${message}\n`);
307
421
  process.exit(2);
308
422
  }
@@ -11,7 +11,8 @@ import { readJson } from "../src/core/atomic.mjs";
11
11
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
12
12
  import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState } from "../src/core/auto-state.mjs";
13
13
  import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from "../src/core/evidence.mjs";
14
- import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
14
+ import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
15
+ import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
15
16
 
16
17
  async function main() {
17
18
  const configPath = process.argv[2];
@@ -20,6 +21,9 @@ async function main() {
20
21
  const config = readJson(configPath, null);
21
22
  if (!config || !autoStateEnabled()) process.exit(0);
22
23
 
24
+ // Read the view meta once so code-ref extraction reuses it on the evidence write.
25
+ const meta = readMeta(config.root, config.viewId);
26
+
23
27
  const state = readState(config.root, config.viewId);
24
28
  if (!state || state.processState === "alive" || state.semanticState === "failed" || state.semanticState === "stopped") process.exit(0);
25
29
 
@@ -55,6 +59,7 @@ async function main() {
55
59
  finalizeEvidence(evidence, { semanticState: latestState.semanticState, usage: null }, Date.now());
56
60
  latestState.review = summarizeEvidence(evidence);
57
61
  writeEvidence(config.root, evidence);
62
+ updateCodeRefsFromEvidence(config.root, config.viewId, evidence, meta);
58
63
  writeState(config.root, latestState);
59
64
  if (changed) {
60
65
  appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
@@ -86,7 +91,7 @@ function runOneShot(command, args, opts = {}) {
86
91
  return new Promise((resolve) => {
87
92
  let out = "";
88
93
  let settled = false;
89
- const child = spawn(command, args, { cwd: opts.cwd, env: opts.env ?? process.env, stdio: ["ignore", "pipe", "ignore"] });
94
+ const child = spawn(command, args, { cwd: opts.cwd, env: opts.env ?? process.env, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
90
95
  let buf = "";
91
96
  const finish = () => {
92
97
  if (settled) return;
@@ -45,7 +45,7 @@ async function maybeGenerateTitle(config) {
45
45
  function runOneShot(command, args, timeoutMs = 20000) {
46
46
  return new Promise((resolve) => {
47
47
  let out = "";
48
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
48
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
49
49
  let buf = "";
50
50
  child.stdout.on("data", (c) => {
51
51
  buf += c.toString();
@@ -16,11 +16,50 @@ import {
16
16
  } from "node:fs";
17
17
  import * as path from "node:path";
18
18
 
19
+ /** Retry backoff (ms) for rename retries; index i is the delay after attempt i. */
20
+ export const RENAME_RETRY_BACKOFF_MS = [10, 50, 250];
21
+ /** Error codes that mean "transient sharing violation" — retryable on Windows. */
22
+ export const RENAME_RETRY_ERROR_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
23
+
19
24
  /** @param {string} dir */
20
25
  export function ensureDir(dir) {
21
26
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
27
  }
23
28
 
29
+ /**
30
+ * rename() with retries for Windows sharing-violation races.
31
+ *
32
+ * On Windows, libuv opens files without FILE_SHARE_DELETE, so replacing an
33
+ * existing target while any other process holds it open for reading throws
34
+ * EPERM/EBUSY/EACCES. The reader window is microseconds, so a few retries
35
+ * with a short backoff succeed almost always. Non-whitelisted errors
36
+ * (e.g. EISDIR) are configuration errors and throw immediately.
37
+ * @param {string} tmp
38
+ * @param {string} file
39
+ * @param {{ rename?: (a: string, b: string) => void, delays?: number[], errorCodes?: Set<string> }} [opts]
40
+ */
41
+ export function renameWithRetry(tmp, file, opts = {}) {
42
+ const rename = opts.rename ?? renameSync;
43
+ const delays = opts.delays ?? RENAME_RETRY_BACKOFF_MS;
44
+ const errorCodes = opts.errorCodes ?? RENAME_RETRY_ERROR_CODES;
45
+ for (let attempt = 0; ; attempt++) {
46
+ try {
47
+ rename(tmp, file);
48
+ return;
49
+ } catch (err) {
50
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
51
+ if (!errorCodes.has(code) || attempt >= delays.length) {
52
+ // Exhausted or non-transient: leave no .tmp litter behind, then
53
+ // surface the original error so callers can degrade deliberately.
54
+ try { unlinkSync(tmp); } catch { /* best effort */ }
55
+ throw err;
56
+ }
57
+ }
58
+ // Synchronous sleep (Atomics.wait) — atomicWrite is a sync API.
59
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delays[attempt]);
60
+ }
61
+ }
62
+
24
63
  /**
25
64
  * Atomically write a string to `file` (creates parent dirs).
26
65
  * @param {string} file
@@ -30,7 +69,7 @@ export function atomicWrite(file, data) {
30
69
  ensureDir(path.dirname(file));
31
70
  const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
32
71
  writeFileSync(tmp, data, "utf8");
33
- renameSync(tmp, file);
72
+ renameWithRetry(tmp, file);
34
73
  }
35
74
 
36
75
  /**