pi-orchestrator-config 4.2.0 → 4.3.2

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 (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -9,12 +9,12 @@
9
9
 
10
10
  import { execFileSync } from "node:child_process";
11
11
  import * as fs from "node:fs";
12
+ import * as net from "node:net";
12
13
  import * as path from "node:path";
13
14
  import { createRequire } from "node:module";
14
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
16
  import { hyperlink } from "@earendil-works/pi-tui";
16
17
  import {
17
- createLogger,
18
18
  checkHealth,
19
19
  ensureUiBuilt,
20
20
  spawnDaemon,
@@ -25,16 +25,21 @@ import {
25
25
  readLockfile,
26
26
  removeLockfile,
27
27
  } from "../shared/daemon-manager.js";
28
+ import { createLogger } from "../shared/logger.js";
28
29
  import { setupHeartbeat, setupReconnectPoller } from "../shared/ws-client.js";
29
30
  import { getSetting } from "../orchestrator/project-settings.js";
31
+ import { shouldSkipOneshotRegister } from "../shared/oneshot.js";
32
+ import { evaluateSpawnLock } from "./spawn-lock.js";
33
+
34
+ /** Re-export for callers/tests that import from pidiff. */
35
+ export { evaluateSpawnLock } from "./spawn-lock.js";
30
36
 
31
37
  const RECONNECT_INTERVAL_MS = 5000;
32
38
  const ICON_DIFF = "";
33
39
 
34
- const log = createLogger(
35
- path.join(process.env.HOME || "/tmp", ".pi", "pidiff-debug.log"),
36
- "ext",
37
- );
40
+ const log = createLogger("pidiff");
41
+ /** String logger for daemon-manager / ws-client APIs that take `(msg: string) => void`. */
42
+ const debugLog = (msg: string) => log.debug(msg);
38
43
 
39
44
  function isGitRepo(cwd: string): boolean {
40
45
  try { execFileSync("git", ["rev-parse", "--git-dir"], { cwd, stdio: "ignore" }); return true; } catch { return false; }
@@ -48,8 +53,35 @@ function getBranch(cwd: string): string {
48
53
  } catch { return ""; }
49
54
  }
50
55
 
56
+ /** Return true if port can be bound on 127.0.0.1. */
57
+ function isPortFree(port: number): Promise<boolean> {
58
+ return new Promise((resolve) => {
59
+ const srv = net.createServer();
60
+ srv.once("error", () => { try { srv.close(); } catch {} resolve(false); });
61
+ srv.listen(port, "127.0.0.1", () => { srv.close(() => resolve(true)); });
62
+ });
63
+ }
64
+
65
+ /** Reuse preferred port if free; otherwise allocate a new one. */
66
+ async function resolveSpawnPort(preferred: number | null | undefined): Promise<number> {
67
+ if (!preferred) {
68
+ const port = await findFreePort();
69
+ log.debug(`allocated new port: ${port}`);
70
+ return port;
71
+ }
72
+ if (await isPortFree(preferred)) {
73
+ log.debug(`reusing lockfile port: ${preferred}`);
74
+ return preferred;
75
+ }
76
+ log.debug(`lockfile port ${preferred} in use, allocating new port`);
77
+ const port = await findFreePort();
78
+ log.debug(`allocated new port: ${port}`);
79
+ return port;
80
+ }
81
+
51
82
  export function registerPidiff(pi: ExtensionAPI): void {
52
83
  if (process.env.PI_SUBAGENT_CHILD === "1") return;
84
+ if (shouldSkipOneshotRegister(log)) return;
53
85
 
54
86
  const projectCwd = process.cwd();
55
87
  const pidiffDisabled = !getSetting(projectCwd, "pidiff_enable");
@@ -96,21 +128,21 @@ export function registerPidiff(pi: ExtensionAPI): void {
96
128
  }
97
129
 
98
130
  function doSpawn(port: number, cwd: string): void {
99
- ensureUiBuilt(import.meta.url, "pidiff-ui", log);
131
+ ensureUiBuilt(import.meta.url, "pidiff-ui", debugLog);
100
132
  spawnDaemon({
101
133
  serverScript: "pidiff-server.ts",
102
134
  logFile: path.join(process.env.HOME || "/tmp", ".pi", "pidiff-server.log"),
103
135
  env: { PI_PIDIFF_PORT: String(port), PI_PIDIFF_CWD: cwd },
104
- log,
136
+ log: debugLog,
105
137
  });
106
138
  }
107
139
 
108
140
  async function connect(ctx: any) {
109
- log(`connect() called, connected=${connected}, connecting=${connecting}`);
141
+ log.debug(`connect() called, connected=${connected}, connecting=${connecting}`);
110
142
  if (connected || connecting || shuttingDown) return;
111
143
  connecting = true;
112
144
  lastCtx = ctx;
113
- if (!lockDir) { log("connect: lockDir not set (session_start not fired yet)"); connecting = false; return; }
145
+ if (!lockDir) { log.debug("connect: lockDir not set (session_start not fired yet)"); connecting = false; return; }
114
146
 
115
147
  // Try to discover port from lockfile
116
148
  let port = activePort;
@@ -123,12 +155,12 @@ export function registerPidiff(pi: ExtensionAPI): void {
123
155
  let running = false;
124
156
  if (port) {
125
157
  running = await checkHealth(port);
126
- log(`lockfile port=${port}, healthy=${running}`);
158
+ log.debug(`lockfile port=${port}, healthy=${running}`);
127
159
  }
128
160
 
129
161
  if (!running) {
130
162
  if (spawning) {
131
- log("daemon already spawning, waiting...");
163
+ log.debug("daemon already spawning, waiting...");
132
164
  connecting = false;
133
165
  return;
134
166
  }
@@ -138,8 +170,17 @@ export function registerPidiff(pi: ExtensionAPI): void {
138
170
  try {
139
171
  fs.writeFileSync(spawnLock, String(process.pid), { flag: "wx" });
140
172
  } catch {
141
- // Another session is already spawning — wait for lockfile to appear
142
- log("another session is spawning pidiff, waiting for lockfile...");
173
+ // Another session is already spawning — check if spawner is still alive
174
+ const lockDecision = evaluateSpawnLock(spawnLock, getSetting(ctx.cwd, "pidiff_stale_lock_timeout_ms"));
175
+ if (lockDecision.action === "recover" || lockDecision.action === "recover_pid_reuse") {
176
+ log.info("stale_spawn_lock", lockDecision.reason);
177
+ try { fs.unlinkSync(spawnLock); } catch {}
178
+ connecting = false;
179
+ setTimeout(() => connect(ctx), 500);
180
+ return;
181
+ }
182
+ // Spawner is alive — wait for it
183
+ log.debug("another session is spawning pidiff, waiting for lockfile...");
143
184
  for (let i = 0; i < 30; i++) {
144
185
  await new Promise(r => setTimeout(r, 1000));
145
186
  const lock = readLockfile(lockDir);
@@ -156,12 +197,12 @@ export function registerPidiff(pi: ExtensionAPI): void {
156
197
 
157
198
  spawning = true;
158
199
  try {
159
- log("spawning daemon...");
160
- port = await findFreePort();
161
- log(`allocated free port: ${port}`);
200
+ log.debug("spawning daemon...");
201
+ // Reuse lockfile port if available — keeps URL stable across reload/restart
202
+ port = await resolveSpawnPort(port);
162
203
  doSpawn(port, ctx.cwd);
163
- writeLockfile(lockDir, port, null, log);
164
- const ready = await waitForDaemon(port, 60, log);
204
+ writeLockfile(lockDir, port, null, debugLog);
205
+ const ready = await waitForDaemon(port, getSetting(process.cwd(), "pidiff_daemon_startup_timeout_s"), debugLog);
165
206
  if (!ready) { connecting = false; return; }
166
207
  } finally {
167
208
  spawning = false;
@@ -174,11 +215,11 @@ export function registerPidiff(pi: ExtensionAPI): void {
174
215
  try {
175
216
  const _require = createRequire(import.meta.url);
176
217
  const WebSocket = _require("ws");
177
- log(`connecting WebSocket to port ${activePort}...`);
218
+ log.debug(`connecting WebSocket to port ${activePort}...`);
178
219
  const wsClient = new WebSocket(`ws://127.0.0.1:${activePort}/ws/pi`);
179
220
 
180
221
  wsClient.on("open", () => {
181
- log("WebSocket connected");
222
+ log.debug("WebSocket connected");
182
223
  ws = wsClient;
183
224
  connected = true;
184
225
  connecting = false;
@@ -197,14 +238,14 @@ export function registerPidiff(pi: ExtensionAPI): void {
197
238
  }));
198
239
  } catch {
199
240
  // ctx may be stale if session was replaced during WebSocket connect
200
- log("skipped registration — ctx is stale");
241
+ log.debug("skipped registration — ctx is stale");
201
242
  }
202
243
 
203
244
  // Keepalive + dead connection detection
204
245
  if (cleanupHeartbeat) cleanupHeartbeat();
205
246
  cleanupHeartbeat = setupHeartbeat({
206
247
  ws: wsClient,
207
- log,
248
+ log: debugLog,
208
249
  onDead: () => {
209
250
  connected = false; ws = null;
210
251
  if (!shuttingDown && lastCtx) setTimeout(() => connect(lastCtx), 1000);
@@ -216,7 +257,7 @@ export function registerPidiff(pi: ExtensionAPI): void {
216
257
  try {
217
258
  const parsed = JSON.parse(data.toString());
218
259
  if (parsed.type === "publish-review" && parsed.comments?.length > 0) {
219
- log(`review received: ${parsed.comments.length} comments`);
260
+ log.debug(`review received: ${parsed.comments.length} comments`);
220
261
  const review = {
221
262
  source: "pidiff",
222
263
  type: "code-review",
@@ -235,25 +276,26 @@ export function registerPidiff(pi: ExtensionAPI): void {
235
276
  const message = `## pidiff: Code Review Comments\n\n\`\`\`json\n${JSON.stringify(review, null, 2)}\n\`\`\`\n\nPlease address these review comments.`;
236
277
  pi.sendUserMessage(message, { deliverAs: "followUp" });
237
278
  }
238
- } catch (e: any) { log(`message handler error: ${e.message}`); }
279
+ } catch (e: any) { log.debug(`message handler error: ${e.message}`); }
239
280
  });
240
281
 
241
282
  wsClient.on("close", () => {
242
- log("WebSocket closed");
283
+ log.debug("WebSocket closed");
243
284
  connected = false; ws = null; connecting = false;
244
285
  if (!shuttingDown) {
245
286
  setTimeout(() => { if (lastCtx && !shuttingDown) connect(lastCtx); }, RECONNECT_INTERVAL_MS);
246
287
  }
247
288
  });
248
289
 
249
- wsClient.on("error", (e: Error) => { log(`WebSocket error: ${e.message}`); });
290
+ wsClient.on("error", (e: Error) => { log.debug(`WebSocket error: ${e.message}`); });
250
291
  } catch (e: any) {
251
- log(`connect error: ${e.message}`);
292
+ log.debug(`connect error: ${e.message}`);
252
293
  connecting = false;
253
294
  }
254
295
  }
255
296
 
256
297
  // /pidiff command
298
+ let pidiffCmdHandler: any;
257
299
  pi.registerCommand("pidiff", {
258
300
  description: "Manage pidiff server — /pidiff start|stop|restart|status",
259
301
  getArgumentCompletions: (prefix: string) => {
@@ -265,7 +307,7 @@ export function registerPidiff(pi: ExtensionAPI): void {
265
307
  ];
266
308
  return items.filter(i => i.value.startsWith(prefix.toLowerCase()));
267
309
  },
268
- handler: async (args, ctx) => {
310
+ handler: pidiffCmdHandler = async (args: any, ctx: any) => {
269
311
  lastCtx = ctx;
270
312
  const cmd = (args || "").trim().toLowerCase();
271
313
 
@@ -273,8 +315,8 @@ export function registerPidiff(pi: ExtensionAPI): void {
273
315
  if (ws) { try { ws.close(); } catch {} ws = null; }
274
316
  connected = false;
275
317
  const pidFile = path.join(lockDir, "pidiff.pid");
276
- killDaemonByPid(pidFile, log);
277
- removeLockfile(lockDir, log);
318
+ killDaemonByPid(pidFile, debugLog);
319
+ removeLockfile(lockDir, debugLog);
278
320
  activePort = 0;
279
321
  if (ctx.hasUI) { ctx.ui.setStatus("8-diff", undefined); ctx.ui.notify("pidiff server stopped", "info"); }
280
322
  return;
@@ -288,11 +330,12 @@ export function registerPidiff(pi: ExtensionAPI): void {
288
330
  if (!connected) connect(ctx);
289
331
  return;
290
332
  }
291
- const port = await findFreePort();
333
+ // Reuse lockfile port to keep URL stable — fall back if taken
334
+ const port = await resolveSpawnPort(lock?.port);
292
335
  doSpawn(port, ctx.cwd);
293
- writeLockfile(lockDir, port, null, log);
336
+ writeLockfile(lockDir, port, null, debugLog);
294
337
  if (ctx.hasUI) ctx.ui.notify("Starting pidiff server...", "info");
295
- if (await waitForDaemon(port, 60, log)) {
338
+ if (await waitForDaemon(port, getSetting(process.cwd(), "pidiff_daemon_startup_timeout_s"), debugLog)) {
296
339
  activePort = port;
297
340
  connect(ctx);
298
341
  if (ctx.hasUI) ctx.ui.notify(`pidiff started at http://localhost:${activePort}`, "info");
@@ -306,14 +349,18 @@ export function registerPidiff(pi: ExtensionAPI): void {
306
349
  if (ws) { try { ws.close(); } catch {} ws = null; }
307
350
  connected = false;
308
351
  const pidFile = path.join(lockDir, "pidiff.pid");
309
- killDaemonByPid(pidFile, log);
310
- removeLockfile(lockDir, log);
352
+ // Reuse existing port to keep URL stable (read before removeLockfile)
353
+ const lock = readLockfile(lockDir);
354
+ const preferredPort = activePort || lock?.port || 0;
355
+ killDaemonByPid(pidFile, debugLog);
356
+ removeLockfile(lockDir, debugLog);
311
357
  await new Promise(r => setTimeout(r, 1000));
312
- const port = await findFreePort();
358
+ // Verify preferred port is free after kill — fall back if taken by another process
359
+ const port = await resolveSpawnPort(preferredPort || null);
313
360
  doSpawn(port, ctx.cwd);
314
- writeLockfile(lockDir, port, null, log);
361
+ writeLockfile(lockDir, port, null, debugLog);
315
362
  if (ctx.hasUI) ctx.ui.notify("Restarting pidiff server...", "info");
316
- if (await waitForDaemon(port, 60, log)) {
363
+ if (await waitForDaemon(port, getSetting(process.cwd(), "pidiff_daemon_startup_timeout_s"), debugLog)) {
317
364
  activePort = port;
318
365
  connect(ctx);
319
366
  if (ctx.hasUI) ctx.ui.notify(`pidiff restarted at http://localhost:${activePort}`, "info");
@@ -333,6 +380,17 @@ export function registerPidiff(pi: ExtensionAPI): void {
333
380
  },
334
381
  });
335
382
 
383
+ // Expose handler to pidash for browser command dispatch
384
+ try {
385
+ if ((globalThis as any).__pidiff_pidash_listener) {
386
+ try { pi.events.removeListener("pidash:request-commands", (globalThis as any).__pidiff_pidash_listener); } catch {}
387
+ }
388
+ const registerWithPidash = () => pi.events.emit("pidash:register-command", { name: "pidiff", handler: pidiffCmdHandler });
389
+ (globalThis as any).__pidiff_pidash_listener = registerWithPidash;
390
+ registerWithPidash();
391
+ pi.events.on("pidash:request-commands", registerWithPidash);
392
+ } catch {}
393
+
336
394
  // Session lifecycle
337
395
  pi.on("session_start", async (_event, ctx) => {
338
396
  lastCtx = ctx;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Spawn-lock evaluation for pidiff daemon startup races.
3
+ * No pi-tui / ExtensionAPI deps — unit tests can import directly.
4
+ */
5
+ import * as fs from "node:fs";
6
+ import { createLogger } from "../shared/logger.js";
7
+
8
+ const log = createLogger("pidiff");
9
+
10
+ /**
11
+ * Evaluate a stale spawn lockfile and decide the recovery action.
12
+ * Exported for testing.
13
+ */
14
+ export function evaluateSpawnLock(
15
+ lockPath: string,
16
+ staleTimeoutMs: number,
17
+ ): { action: "wait" | "recover" | "recover_pid_reuse"; reason: string } {
18
+ log.debug("evaluate_spawn_lock", "lockPath", lockPath, "staleTimeoutMs", staleTimeoutMs);
19
+ const effectiveTimeout = Math.max(staleTimeoutMs, 1000); // Floor at 1s to prevent thrashing
20
+ let result: { action: "wait" | "recover" | "recover_pid_reuse"; reason: string };
21
+
22
+ try {
23
+ const content = fs.readFileSync(lockPath, "utf-8").trim();
24
+ const spawnerPid = /^\d+$/.test(content) ? parseInt(content, 10) : NaN;
25
+
26
+ if (!spawnerPid || isNaN(spawnerPid)) {
27
+ // No valid PID — check age
28
+ const lockAge = Date.now() - fs.statSync(lockPath).mtimeMs;
29
+ if (lockAge > effectiveTimeout) {
30
+ result = { action: "recover", reason: `no valid PID, age ${Math.round(lockAge / 1000)}s > timeout` };
31
+ } else {
32
+ result = { action: "wait", reason: "no valid PID but lock is young" };
33
+ }
34
+ } else {
35
+ let isAlive = false;
36
+ try {
37
+ process.kill(spawnerPid, 0);
38
+ isAlive = true;
39
+ } catch (e: any) {
40
+ // EPERM = process exists but we can't signal it — treat as alive
41
+ isAlive = e?.code === "EPERM";
42
+ }
43
+
44
+ if (!isAlive) {
45
+ result = { action: "recover", reason: `PID ${spawnerPid} is dead` };
46
+ } else {
47
+ // PID alive — check for PID reuse (lock age > timeout)
48
+ const lockAge = Date.now() - fs.statSync(lockPath).mtimeMs;
49
+ if (lockAge > effectiveTimeout) {
50
+ result = { action: "recover_pid_reuse", reason: `PID ${spawnerPid} alive but lock age ${Math.round(lockAge / 1000)}s > timeout (likely PID reuse)` };
51
+ } else {
52
+ result = { action: "wait", reason: `PID ${spawnerPid} alive, lock age ${Math.round(lockAge / 1000)}s within threshold` };
53
+ }
54
+ }
55
+ }
56
+ } catch {
57
+ result = { action: "recover", reason: "cannot read/stat lockfile" };
58
+ }
59
+
60
+ log.debug("evaluate_spawn_lock", result.action, result.reason);
61
+ return result;
62
+ }