pi-claude-supervisor 0.9.0 → 0.9.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.
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
4
4
  import { lstat, open, rename, rm, writeFile, type FileHandle } from "node:fs/promises";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
7
+ import { DEFAULT_MAX_DECISION_RETRIES } from "./config.ts";
7
8
  import { extractJsonObjects } from "./json-extract.ts";
8
9
  import type { PiUsageSample, TaskSpec, WorkerEvent } from "./types.ts";
9
10
  import { redactSensitive } from "./redaction.ts";
@@ -28,6 +29,16 @@ export interface DecisionContext {
28
29
  spec?: TaskSpec;
29
30
  /** Wall-clock budget of the task; absent when no deadline is configured. */
30
31
  deadline?: DecisionDeadlineContext;
32
+ /** Outcome of the most recent acceptance/Review round, if one has run. */
33
+ lastVerification?: DecisionVerificationSummary;
34
+ }
35
+
36
+ /** A compact view of the last verification: what failed and what the Reviewer asked for. */
37
+ export interface DecisionVerificationSummary {
38
+ ok: boolean;
39
+ failedChecks: string[];
40
+ reviewVerdict?: string;
41
+ findings: string[];
31
42
  }
32
43
 
33
44
  export interface DecisionDeadlineContext {
@@ -81,6 +92,9 @@ export interface DecisionWorkerOptions {
81
92
  export type PiModel = NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>;
82
93
 
83
94
  const DEFAULT_COMPACTION_TOKENS = 60_000;
95
+ /** First retry wait; each later wait triples, capped at MAX_DECISION_RETRY_BACKOFF_MS. */
96
+ const DEFAULT_DECISION_RETRY_BACKOFF_MS = 5_000;
97
+ const MAX_DECISION_RETRY_BACKOFF_MS = 60_000;
84
98
 
85
99
  /**
86
100
  * A persistent Pi SDK session used only for supervision decisions.
@@ -106,7 +120,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
106
120
  this.#options = options;
107
121
  this.#context = { ...options.context };
108
122
  this.#timeoutMs = options.timeoutMs ?? 120_000;
109
- this.#retryBackoffMs = options.retryBackoffMs ?? 500;
123
+ this.#retryBackoffMs = options.retryBackoffMs ?? DEFAULT_DECISION_RETRY_BACKOFF_MS;
110
124
  this.#compactionTokens = options.compactionTokens ?? DEFAULT_COMPACTION_TOKENS;
111
125
  }
112
126
 
@@ -188,7 +202,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
188
202
 
189
203
  async #processEvent(event: WorkerEvent): Promise<void> {
190
204
  if (!this.#session || this.#closed) return;
191
- const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? 2;
205
+ const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? DEFAULT_MAX_DECISION_RETRIES;
192
206
  let attempt = 0;
193
207
  // undefined selects the primary decision question; a bounded re-prompt
194
208
  // (below) switches this to a corrective follow-up on the same session.
@@ -216,7 +230,8 @@ export class PiDecisionWorker implements DecisionWorkerLike {
216
230
  if (error instanceof Error && error.name === "AbortError") throw error;
217
231
  if (attempt >= maxRetries) throw error;
218
232
  attempt += 1;
219
- await new Promise<void>((resolveWait) => setTimeout(resolveWait, Math.min(30_000, this.#retryBackoffMs * 2 ** attempt)));
233
+ // 429/529 overloads last minutes, not seconds: 15s, 45s, then 60s.
234
+ await new Promise<void>((resolveWait) => setTimeout(resolveWait, Math.min(MAX_DECISION_RETRY_BACKOFF_MS, this.#retryBackoffMs * 3 ** attempt)));
220
235
  if (!this.#session || this.#closed) return;
221
236
  continue;
222
237
  }
@@ -231,6 +246,13 @@ export class PiDecisionWorker implements DecisionWorkerLike {
231
246
  prompt = `Your previous reply was not a single valid JSON action (${action.reason}). Return exactly one JSON object now and nothing else.`;
232
247
  continue;
233
248
  }
249
+ // `noop` on a completed turn or a pending permission leaves the Worker
250
+ // idle forever; the Supervisor parks it. Ask once for a concrete action.
251
+ if (!repromptAttempted && action.action === "noop" && (event.type === "turn_completed" || event.type === "permission_request")) {
252
+ repromptAttempted = true;
253
+ prompt = `noop is not a valid action for a ${event.type} event: the Worker is waiting on you and nothing will happen. Choose a concrete action (for example continue, redirect or verify for a completed turn, or allow_permission/deny_permission for a permission request) and return exactly one JSON object now.`;
254
+ continue;
255
+ }
234
256
  // An action handler may stop or close the session. Do not replay an
235
257
  // already-decoded action if the handler itself fails.
236
258
  await this.#options.onAction(action, event);
@@ -249,11 +271,15 @@ export class PiDecisionWorker implements DecisionWorkerLike {
249
271
  const tokens = this.#session.getContextUsage?.()?.tokens;
250
272
  if (typeof tokens !== "number" || tokens <= this.#compactionTokens) return;
251
273
  try {
252
- await this.#session.compact(
274
+ // Bounded like any other Decision Worker request: compaction runs on the
275
+ // serialized decision tail, so a stalled summarization would otherwise
276
+ // block every later decision.
277
+ await withTimeout(this.#session.compact(
253
278
  "Preserve: the task specification, the policy rules for permissions and boundaries, the current state, and the last three decisions with their reasons.",
254
- );
279
+ ), this.#timeoutMs, "Decision Worker compaction");
255
280
  this.#instructionsStale = true;
256
281
  } catch {
282
+ try { this.#session?.abortCompaction?.(); } catch { /* best effort */ }
257
283
  // A compaction failure must not fail the decision that already succeeded.
258
284
  }
259
285
  }
@@ -311,7 +337,9 @@ as the answer and continues. A turn_completed whose result has subtype "error" o
311
337
  means the Worker's own API/model call failed mid-turn: choose retry (optionally with a short
312
338
  corrective message) or continue to resume it, and park only after repeated failures; a subtype
313
339
  "idle" result means the turn ended without a normal stop signal, so inspect the repository and
314
- decide as for any other turn. Use verify when a turn result indicates the task is complete, even if
340
+ decide as for any other turn. CURRENT CONTEXT.lastVerification, when present, is the last acceptance
341
+ and Review round (failed check ids, Reviewer verdict and findings): after a repair turn, check
342
+ Claude's claim against it before choosing verify. Use verify when a turn result indicates the task is complete, even if
315
343
  Claude says it will stop; choose stop only for an explicit stop or technical containment reason.
316
344
  Use park only when the task cannot safely produce a candidate because required evidence,
317
345
  authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
@@ -390,6 +418,7 @@ async function askDecision(
390
418
  ? { closeOutRemainingMinutes: Math.round(context.deadline.closeOutRemainingMs / 60_000) }
391
419
  : {}),
392
420
  } : {}),
421
+ ...(context.lastVerification ? { lastVerification: context.lastVerification } : {}),
393
422
  };
394
423
  const prompt = `${options.instructionsPrefix ?? ""}UNTRUSTED SUPERVISOR EVENT:\n${boundedEventJson(event)}\n\nCURRENT CONTEXT:\n${boundedJson(currentContext)}\n\nChoose one action now.`;
395
424
  return promptForText(session, prompt, timeoutMs, "Decision Worker request", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: options.onUsage });
package/src/events.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { appendFile, chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
+ import { currentLockOwner, lockOwnerAlive } from "./lock-owner.ts";
4
5
  import { redactSensitive } from "./redaction.ts";
5
6
 
6
7
  export interface SupervisorEvent {
@@ -71,7 +72,7 @@ export class EventLog {
71
72
  while (true) {
72
73
  try {
73
74
  await mkdir(lockPath);
74
- await writeFile(`${lockPath}/owner.json`, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
75
+ await writeFile(`${lockPath}/owner.json`, JSON.stringify(await currentLockOwner()));
75
76
  break;
76
77
  } catch (error) {
77
78
  if (!(error instanceof Error) || !/EEXIST/u.test(error.message)) throw error;
@@ -91,21 +92,14 @@ export class EventLog {
91
92
  try {
92
93
  const info = await stat(`${lockPath}/owner.json`);
93
94
  if (Date.now() - info.mtimeMs < STALE_LOCK_MS) return false;
94
- let owner: { pid?: unknown };
95
+ let owner: unknown;
95
96
  try {
96
- owner = JSON.parse(await readFile(`${lockPath}/owner.json`, "utf8")) as { pid?: unknown };
97
+ owner = JSON.parse(await readFile(`${lockPath}/owner.json`, "utf8"));
97
98
  } catch {
98
99
  await rm(lockPath, { recursive: true, force: true });
99
100
  return true;
100
101
  }
101
- if (typeof owner.pid === "number") {
102
- try {
103
- process.kill(owner.pid, 0);
104
- return false;
105
- } catch (error) {
106
- if (error instanceof Error && /EPERM/u.test(error.message)) return false;
107
- }
108
- }
102
+ if (await lockOwnerAlive(owner)) return false;
109
103
  await rm(lockPath, { recursive: true, force: true });
110
104
  return true;
111
105
  } catch (error) {
@@ -2,20 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { chmod, mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import { HOOK_TIMEOUT_SECONDS, type ClaudeHookEventName } from "./types.ts";
5
+ import { CLAUDE_HOOK_EVENT_NAMES, HOOK_TIMEOUT_SECONDS, type ClaudeHookEventName } from "./types.ts";
6
6
  import { HOOK_RELAY_SCRIPT, hookRelayCommand } from "./relay.ts";
7
7
  import { hookSocketDirectory } from "./server.ts";
8
8
 
9
- const HOOK_EVENT_NAMES: readonly ClaudeHookEventName[] = [
10
- "SessionStart",
11
- "SessionEnd",
12
- "UserPromptSubmit",
13
- "PreToolUse",
14
- "PermissionRequest",
15
- "Stop",
16
- "StopFailure",
17
- "Notification",
18
- ];
9
+ const HOOK_EVENT_NAMES: readonly ClaudeHookEventName[] = CLAUDE_HOOK_EVENT_NAMES;
19
10
 
20
11
  /** Substring that marks a hook command entry as ours, regardless of which stateDir produced it. */
21
12
  const RELAY_MARKER = "/hooks/relay.js";
@@ -3,19 +3,11 @@ import { createServer, type Server, type Socket } from "node:net";
3
3
  import { chmod, lstat, mkdir, readdir, readlink, realpath, rename, rm, symlink, unlink } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
- import type { ClaudeHookEvent, ClaudeHookEventName, HookEventSource, HookRelayReply, HookRelayRequest } from "./types.ts";
6
+ import { CLAUDE_HOOK_EVENT_NAMES, type ClaudeHookEvent, type ClaudeHookEventName, type HookEventSource, type HookRelayReply, type HookRelayRequest } from "./types.ts";
7
7
 
8
8
  const MAX_LINE_BYTES = 1024 * 1024;
9
9
 
10
- const VALID_EVENT_NAMES: ReadonlySet<string> = new Set<ClaudeHookEventName>([
11
- "SessionStart",
12
- "SessionEnd",
13
- "UserPromptSubmit",
14
- "PreToolUse",
15
- "PermissionRequest",
16
- "Stop",
17
- "Notification",
18
- ]);
10
+ const VALID_EVENT_NAMES: ReadonlySet<string> = new Set<ClaudeHookEventName>(CLAUDE_HOOK_EVENT_NAMES);
19
11
 
20
12
  type HookHandler = (request: HookRelayRequest) => Promise<HookRelayReply | undefined>;
21
13
 
@@ -1,18 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
- import { HOOK_TIMEOUT_SECONDS, type ClaudeHookEventName } from "./types.ts";
4
+ import { CLAUDE_HOOK_EVENT_NAMES, HOOK_TIMEOUT_SECONDS, type ClaudeHookEventName } from "./types.ts";
5
5
 
6
- const HOOK_EVENT_NAMES: readonly ClaudeHookEventName[] = [
7
- "SessionStart",
8
- "SessionEnd",
9
- "UserPromptSubmit",
10
- "PreToolUse",
11
- "PermissionRequest",
12
- "Stop",
13
- "StopFailure",
14
- "Notification",
15
- ];
6
+ const HOOK_EVENT_NAMES: readonly ClaudeHookEventName[] = CLAUDE_HOOK_EVENT_NAMES;
16
7
 
17
8
  interface HookCommandEntry {
18
9
  type: "command";
@@ -5,15 +5,23 @@
5
5
  * event over a unix socket and prints the reply as Claude's hook output.
6
6
  */
7
7
 
8
- export type ClaudeHookEventName =
9
- | "SessionStart"
10
- | "SessionEnd"
11
- | "UserPromptSubmit"
12
- | "PreToolUse"
13
- | "PermissionRequest"
14
- | "Stop"
15
- | "StopFailure"
16
- | "Notification";
8
+ /**
9
+ * Every hook event the Supervisor consumes. The one list that the installed
10
+ * hook settings, the per-session settings and the server's request parser all
11
+ * derive from, so an event cannot be registered and then dropped on arrival.
12
+ */
13
+ export const CLAUDE_HOOK_EVENT_NAMES = [
14
+ "SessionStart",
15
+ "SessionEnd",
16
+ "UserPromptSubmit",
17
+ "PreToolUse",
18
+ "PermissionRequest",
19
+ "Stop",
20
+ "StopFailure",
21
+ "Notification",
22
+ ] as const;
23
+
24
+ export type ClaudeHookEventName = typeof CLAUDE_HOOK_EVENT_NAMES[number];
17
25
 
18
26
  /** The subset of Claude Code hook input the Supervisor consumes (all fields untrusted). */
19
27
  export interface ClaudeHookEvent {
package/src/index.ts CHANGED
@@ -73,6 +73,11 @@ async function userHooksInstalled(settingsPath: string): Promise<boolean> {
73
73
  * sessions may run concurrently, but active sessions must use different
74
74
  * working directories so workers cannot silently overwrite one another.
75
75
  */
76
+ /** The first message a recovered Worker gets: it is a fresh process that never saw the task. */
77
+ function recoveryContinuation(goal: string): string {
78
+ return `The Supervisor restarted this task after an interruption; you are a fresh session and earlier work may already be in the repository. First inspect git status, git log and the uncommitted diff to see what was done. Then continue the original task to completion, run the relevant checks and commit locally. Original task:\n${goal}`;
79
+ }
80
+
76
81
  export default function piClaudeSupervisor(pi: ExtensionAPI): void {
77
82
  loadSupervisorEnvironment();
78
83
  const automation = process.env.PI_CLAUDE_SUPERVISOR_MODE === "auto" || process.env.PI_CLAUDE_SUPERVISOR_AUTOMATION === "1";
@@ -185,6 +190,8 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
185
190
  return new PiReadOnlyReviewer({ timeoutMs: reviewTimeoutMs(), model: await reviewerPiModelPromise });
186
191
  };
187
192
  const sessions = new Map<string, Supervisor>();
193
+ /** The last few tasks that finished and were released, newest last, for `status`/`sessions`. */
194
+ const finishedSessions = new Map<string, { finishedAt: number; detail: string }>();
188
195
  const cwdLeases = new Map<string, CwdLeaseHandle>();
189
196
  const cleanupRequiredTasks = new Set<string>();
190
197
  const reservedCwds = new Map<string, string>();
@@ -230,6 +237,14 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
230
237
  }
231
238
  };
232
239
  const forgetSession = (taskId: string): void => {
240
+ // Keep what a finished task ended as: after an overnight run, `status`
241
+ // is exactly where the operator looks, and the live session is gone.
242
+ const finished = sessions.get(taskId);
243
+ if (finished) {
244
+ finishedSessions.delete(taskId);
245
+ finishedSessions.set(taskId, { finishedAt: Date.now(), detail: formatSessionDetail(taskId, finished, tmuxModeLabel()) });
246
+ while (finishedSessions.size > MAX_FINISHED_SESSIONS) finishedSessions.delete(finishedSessions.keys().next().value!);
247
+ }
233
248
  sessions.delete(taskId);
234
249
  reservedCwds.delete(taskId);
235
250
  cleanupRequiredTasks.delete(taskId);
@@ -885,6 +900,34 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
885
900
  throw new Error("Pi session shut down during recovery");
886
901
  }
887
902
  message = `Worker recovered idle: task=${record.taskId} worker=${handle.id}; original task was not replayed; send an explicit continuation, then use resume-auto`;
903
+ // `--extend` is an explicit request to carry on unattended: hand
904
+ // the task back to automation instead of leaving it under
905
+ // takeover. `--extend 0` needs no message — the close-out
906
+ // verifies the idle Worker on the next watchdog tick; a real
907
+ // extension sends a continuation, since the fresh Worker does
908
+ // not remember the task. A failure here leaves the recovered
909
+ // Worker idle under takeover, exactly as a plain recover would.
910
+ if (automaticRecovery && extendMs !== undefined) {
911
+ const turnBefore = session.turn;
912
+ try {
913
+ await session.resumeAutomation();
914
+ if (extendMs > 0) await session.send(recoveryContinuation(record.spec?.goal ?? record.task));
915
+ message = extendMs > 0
916
+ ? `Worker recovered: task=${record.taskId} worker=${handle.id}; automation resumed with a continuation of the original task; ${formatDurationMs(Math.max(0, recoveryDeadlineMs - elapsedMs))} of budget from now`
917
+ : `Worker recovered: task=${record.taskId} worker=${handle.id}; automation resumed; the close-out verifies and reviews the repository as it stands`;
918
+ } catch (error) {
919
+ const detail = redactText(error instanceof Error ? error.message : String(error));
920
+ if (session.turn > turnBefore) {
921
+ // The continuation reached the Worker (only its audit
922
+ // record failed): it is working under automation now,
923
+ // and taking it over would leave that turn undecided.
924
+ message = `Worker recovered: task=${record.taskId} worker=${handle.id}; automation resumed with a continuation of the original task (warning: ${detail})`;
925
+ } else {
926
+ await session.takeover().catch(() => {});
927
+ message = `${message} (automatic resume failed: ${detail})`;
928
+ }
929
+ }
930
+ }
888
931
  } catch (error) {
889
932
  const handle = session.handle ?? startedHandle;
890
933
  let cleanupConfirmed = !handle;
@@ -960,15 +1003,22 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
960
1003
  } else if (operation === "sessions") {
961
1004
  const recoverable = await decisionStore.list({ activeOnly: true });
962
1005
  message = formatSessions(sessions, recoverable, tmuxModeLabel());
1006
+ if (finishedSessions.size > 0) message += `\nRecently finished:\n${formatFinishedSessions(finishedSessions)}`;
963
1007
  const quarantined = await cwdLeaseStore.quarantined();
964
1008
  if (quarantined.length > 0) {
965
1009
  message += `\nQuarantined cwd lease records (${quarantined.length}) in ${leaseDir}/quarantine: ${redactText(quarantined.join(", "))}`;
966
1010
  }
967
1011
  } else if (operation === "status") {
968
- const { session, sessionId } = resolveSession(sessions, activeTaskId, rest, true);
969
- message = session
970
- ? `task=${sessionId} state=${session.state} worker=${session.handle?.id ?? "none"}${tmuxModeLabel() ? ` mode=${tmuxModeLabel()}` : ""}${formatDeadline(session.deadline)} ${formatUsageDetail(session.usage)}`
971
- : formatSessions(sessions, await decisionStore.list({ activeOnly: true }), tmuxModeLabel());
1012
+ const finished = rest[0] && !sessions.has(rest[0]) ? finishedSessions.get(rest[0]) : undefined;
1013
+ if (finished) {
1014
+ message = `finished ${formatDurationMs(Date.now() - finished.finishedAt)} ago: ${finished.detail}`;
1015
+ } else {
1016
+ const { session, sessionId } = resolveSession(sessions, activeTaskId, rest, true);
1017
+ message = session && sessionId
1018
+ ? formatSessionDetail(sessionId, session, tmuxModeLabel())
1019
+ : formatSessions(sessions, await decisionStore.list({ activeOnly: true }), tmuxModeLabel())
1020
+ + (finishedSessions.size > 0 ? `\nRecently finished:\n${formatFinishedSessions(finishedSessions)}` : "");
1021
+ }
972
1022
  } else if (operation === "capabilities") {
973
1023
  message = JSON.stringify(adapter.capabilities(), null, 2);
974
1024
  } else if (operation === "install-hooks" || operation === "uninstall-hooks") {
@@ -1149,6 +1199,35 @@ function formatDeadline(deadline: Supervisor["deadline"]): string {
1149
1199
  return ` deadline=close-out (${formatDurationMs(deadline.closeOutRemainingMs ?? 0)} left)`;
1150
1200
  }
1151
1201
 
1202
+ const MAX_FINISHED_SESSIONS = 20;
1203
+
1204
+ /**
1205
+ * One task's state in a line: what it is doing, how far it got, and — the
1206
+ * question an operator of an unattended run actually has — whether it is
1207
+ * waiting on them.
1208
+ */
1209
+ function formatSessionDetail(taskId: string, session: Supervisor, tmuxModeLabel?: string): string {
1210
+ const task = session.task;
1211
+ const parts = [`task=${taskId}`, `state=${session.state}`, `worker=${session.handle?.id ?? "none"}`];
1212
+ if (tmuxModeLabel) parts.push(`mode=${tmuxModeLabel}`);
1213
+ if (session.humanRequired) parts.push("automation=paused(resume-auto)");
1214
+ if (session.candidateParked) parts.push("candidate=parked");
1215
+ if (task) {
1216
+ parts.push(`turn=${session.turn}/${task.maxTurns}`);
1217
+ if (session.repairRound > 0) parts.push(`repair=${session.repairRound}`);
1218
+ const startedAt = Date.parse(task.startedAt);
1219
+ if (Number.isFinite(startedAt)) parts.push(`elapsed=${formatDurationMs(Math.max(0, Date.now() - startedAt))}`);
1220
+ }
1221
+ const verification = session.lastVerification;
1222
+ if (verification) parts.push(`lastVerify=${verification.ok ? "pass" : "fail"}${verification.review ? `(review=${verification.review.verdict})` : ""}`);
1223
+ const terminal = ["completed", "blocked", "stopped", "failed"].includes(session.state);
1224
+ return `${parts.join(" ")}${terminal ? "" : formatDeadline(session.deadline)} ${formatUsageDetail(session.usage)}`;
1225
+ }
1226
+
1227
+ function formatFinishedSessions(finished: Map<string, { finishedAt: number; detail: string }>): string {
1228
+ return [...finished.values()].reverse().map((entry) => ` ${formatDurationMs(Date.now() - entry.finishedAt)} ago: ${entry.detail}`).join("\n");
1229
+ }
1230
+
1152
1231
  function formatSessions(sessions: Map<string, Supervisor>, recoverable: DecisionSessionRecord[] = [], tmuxModeLabel?: string): string {
1153
1232
  const modeSuffix = tmuxModeLabel ? ` mode=${tmuxModeLabel}` : "";
1154
1233
  const active = [...sessions.entries()]
@@ -4,7 +4,12 @@
4
4
  * objects: prose is harmless, but ambiguity must remain non-publishable.
5
5
  */
6
6
  export function extractJsonObjects(text: string): unknown[] {
7
- const objects: unknown[] = [];
7
+ return extractJsonObjectSpans(text).map((span) => span.value);
8
+ }
9
+
10
+ /** The same objects, each with the exact source text it was parsed from. */
11
+ export function extractJsonObjectSpans(text: string): Array<{ value: unknown; source: string }> {
12
+ const objects: Array<{ value: unknown; source: string }> = [];
8
13
  let offset = 0;
9
14
  while (offset < text.length) {
10
15
  const start = text.indexOf("{", offset);
@@ -17,7 +22,7 @@ export function extractJsonObjects(text: string): unknown[] {
17
22
  const candidate = text.slice(start, end + 1);
18
23
  try {
19
24
  const value = JSON.parse(candidate) as unknown;
20
- if (value && typeof value === "object" && !Array.isArray(value)) objects.push(value);
25
+ if (value && typeof value === "object" && !Array.isArray(value)) objects.push({ value, source: candidate });
21
26
  offset = end + 1;
22
27
  } catch {
23
28
  offset = start + 1;
@@ -47,3 +52,37 @@ export function balancedObjectEnd(text: string, start: number): number {
47
52
  }
48
53
  return -1;
49
54
  }
55
+
56
+ /**
57
+ * True when any object in this (already valid) JSON text names a key twice.
58
+ * `JSON.parse` silently keeps the last value, so a duplicate is how text
59
+ * spliced into a string can override a field it was never meant to touch.
60
+ * Keys are compared decoded, so `"\u0076erdict"` duplicates `"verdict"`.
61
+ */
62
+ export function jsonHasDuplicateKeys(json: string): boolean {
63
+ const scopes: Array<Set<string> | undefined> = [];
64
+ let index = 0;
65
+ while (index < json.length) {
66
+ const character = json[index];
67
+ if (character === '"') {
68
+ let end = index + 1;
69
+ while (end < json.length && json[end] !== '"') end += json[end] === "\\" ? 2 : 1;
70
+ const token = json.slice(index, end + 1);
71
+ index = end + 1;
72
+ let next = index;
73
+ while (next < json.length && /\s/u.test(json[next]!)) next += 1;
74
+ const scope = scopes.at(-1);
75
+ if (json[next] === ":" && scope) {
76
+ const key = JSON.parse(token) as string;
77
+ if (scope.has(key)) return true;
78
+ scope.add(key);
79
+ }
80
+ continue;
81
+ }
82
+ if (character === "{") scopes.push(new Set());
83
+ else if (character === "[") scopes.push(undefined);
84
+ else if (character === "}" || character === "]") scopes.pop();
85
+ index += 1;
86
+ }
87
+ return false;
88
+ }
@@ -0,0 +1,55 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ /**
4
+ * Identity written into a short-lived lock directory's `owner.json`. The pid
5
+ * alone is not an identity: after a crash and a container or VM restart the
6
+ * next Pi often gets the same pid, and a lock judged only by `kill(pid, 0)`
7
+ * then looks held forever. The pid's start time (Linux) makes it one.
8
+ */
9
+ export interface LockOwner {
10
+ pid: number;
11
+ startTime?: string;
12
+ at: string;
13
+ }
14
+
15
+ let ownStartTime: Promise<string | undefined> | undefined;
16
+
17
+ /** The owner record for a lock taken by this process. */
18
+ export async function currentLockOwner(): Promise<LockOwner> {
19
+ ownStartTime ??= processStartTime(process.pid);
20
+ const startTime = await ownStartTime;
21
+ return { pid: process.pid, ...(startTime ? { startTime } : {}), at: new Date().toISOString() };
22
+ }
23
+
24
+ /**
25
+ * Whether the process that wrote `owner` still exists. A missing or malformed
26
+ * record, a dead pid, or a live pid with a different start time (a reused
27
+ * pid) all mean the lock is abandoned; only a live pid whose start time still
28
+ * matches (or cannot be compared) keeps it.
29
+ */
30
+ export async function lockOwnerAlive(owner: unknown): Promise<boolean> {
31
+ if (!owner || typeof owner !== "object") return false;
32
+ const { pid, startTime } = owner as { pid?: unknown; startTime?: unknown };
33
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return false;
34
+ try {
35
+ process.kill(pid, 0);
36
+ } catch (error) {
37
+ if (!(error instanceof Error && /EPERM/u.test(error.message))) return false;
38
+ }
39
+ if (typeof startTime !== "string") return true;
40
+ const current = await processStartTime(pid);
41
+ return current === undefined || current === startTime;
42
+ }
43
+
44
+ /** Linux process start time in clock ticks since boot (field 22 of /proc/<pid>/stat). */
45
+ export async function processStartTime(pid: number): Promise<string | undefined> {
46
+ try {
47
+ const statText = await readFile(`/proc/${pid}/stat`, "utf8");
48
+ const closeParen = statText.lastIndexOf(")");
49
+ const fields = closeParen >= 0 ? statText.slice(closeParen + 2).trim().split(/\s+/u) : [];
50
+ const startTime = fields[19];
51
+ return startTime && /^\d+$/u.test(startTime) ? startTime : undefined;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }