pi-extended-teams 2.1.16

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 (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,44 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { withLock } from "./lock";
4
+
5
+ const [lockPath, gateDir, pauseBeforeReap, holdCallback, retriesValue] = process.argv.slice(2);
6
+ if (!lockPath || !gateDir) throw new Error("lockPath and gateDir are required");
7
+
8
+ fs.mkdirSync(gateDir, { recursive: true });
9
+
10
+ const waitState = new Int32Array(new SharedArrayBuffer(4));
11
+ function waitAtGate(name: string): void {
12
+ fs.writeFileSync(path.join(gateDir, `${name}.ready`), "");
13
+ const releaseFile = path.join(gateDir, `${name}.release`);
14
+ while (!fs.existsSync(releaseFile)) {
15
+ Atomics.wait(waitState, 0, 0, 10);
16
+ }
17
+ }
18
+
19
+ const lockFile = `${lockPath}.lock`;
20
+ const originalUnlinkSync = fs.unlinkSync;
21
+ let pausedBeforeReap = false;
22
+ fs.unlinkSync = ((target: fs.PathLike) => {
23
+ if (pauseBeforeReap === "true" && String(target) === lockFile && !pausedBeforeReap) {
24
+ pausedBeforeReap = true;
25
+ waitAtGate("before-reap");
26
+ }
27
+ return originalUnlinkSync(target);
28
+ }) as typeof fs.unlinkSync;
29
+
30
+ function publishResult(result: object): void {
31
+ const resultFile = path.join(gateDir, "result.json");
32
+ const temporary = `${resultFile}.${process.pid}.tmp`;
33
+ fs.writeFileSync(temporary, JSON.stringify(result));
34
+ fs.renameSync(temporary, resultFile);
35
+ }
36
+
37
+ withLock(lockPath, async () => {
38
+ fs.writeFileSync(path.join(gateDir, "callback.ready"), "");
39
+ if (holdCallback === "true") waitAtGate("callback");
40
+ return process.pid;
41
+ }, Number(retriesValue)).then(
42
+ pid => publishResult({ status: "success", pid }),
43
+ error => publishResult({ status: "error", message: error instanceof Error ? error.message : String(error) }),
44
+ );
@@ -0,0 +1,198 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { withLock } from "./lock";
7
+
8
+ interface ReaperChild {
9
+ child: ChildProcess;
10
+ gateDir: string;
11
+ stderr: () => string;
12
+ }
13
+
14
+ function waitForAnyFile(files: string[], timeoutMs = 10_000): Promise<string> {
15
+ const existing = files.find(file => fs.existsSync(file));
16
+ if (existing) return Promise.resolve(existing);
17
+
18
+ return new Promise((resolve, reject) => {
19
+ const watchers: fs.FSWatcher[] = [];
20
+ let settled = false;
21
+ const timeout = setTimeout(() => finish(new Error(`Timed out waiting for ${files.join(" or ")}`)), timeoutMs);
22
+
23
+ const finish = (error?: Error, file?: string) => {
24
+ if (settled) return;
25
+ settled = true;
26
+ clearTimeout(timeout);
27
+ for (const watcher of watchers) watcher.close();
28
+ if (error) reject(error);
29
+ else resolve(file!);
30
+ };
31
+
32
+ const check = () => {
33
+ const file = files.find(candidate => fs.existsSync(candidate));
34
+ if (file) finish(undefined, file);
35
+ };
36
+
37
+ for (const directory of new Set(files.map(file => path.dirname(file)))) {
38
+ watchers.push(fs.watch(directory, check));
39
+ }
40
+ check();
41
+ });
42
+ }
43
+
44
+ function waitForFile(file: string): Promise<string> {
45
+ return waitForAnyFile([file]);
46
+ }
47
+
48
+ function waitForExit(child: ChildProcess): Promise<void> {
49
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
50
+ return new Promise((resolve, reject) => {
51
+ child.once("exit", () => resolve());
52
+ child.once("error", reject);
53
+ });
54
+ }
55
+
56
+ async function getExitedPid(): Promise<number> {
57
+ const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
58
+ const pid = child.pid;
59
+ if (pid === undefined) throw new Error("Child process did not expose a pid");
60
+ await waitForExit(child);
61
+ return pid;
62
+ }
63
+
64
+ function readResult(gateDir: string): { status: string; message?: string; pid?: number } {
65
+ return JSON.parse(fs.readFileSync(path.join(gateDir, "result.json"), "utf-8"));
66
+ }
67
+
68
+ describe("withLock race conditions", () => {
69
+ const testDir = path.join(os.tmpdir(), "pi-lock-race-test-" + Date.now());
70
+ const lockPath = path.join(testDir, "test");
71
+ const lockFile = `${lockPath}.lock`;
72
+ const children: ReaperChild[] = [];
73
+
74
+ function startReaper(name: string, options: { pauseBeforeReap: boolean; holdCallback: boolean; retries: number }): ReaperChild {
75
+ const gateDir = path.join(testDir, name);
76
+ fs.mkdirSync(gateDir, { recursive: true });
77
+ const fixture = path.join(__dirname, "lock.race.child.ts");
78
+ const child = spawn(process.execPath, [
79
+ "-r",
80
+ "ts-node/register",
81
+ fixture,
82
+ lockPath,
83
+ gateDir,
84
+ String(options.pauseBeforeReap),
85
+ String(options.holdCallback),
86
+ String(options.retries),
87
+ ], {
88
+ cwd: path.resolve(__dirname, "../.."),
89
+ stdio: ["ignore", "ignore", "pipe"],
90
+ });
91
+ let stderr = "";
92
+ child.stderr?.on("data", chunk => { stderr += chunk.toString(); });
93
+ const running = { child, gateDir, stderr: () => stderr };
94
+ children.push(running);
95
+ return running;
96
+ }
97
+
98
+ function release(child: ReaperChild, gate: "before-reap" | "callback"): void {
99
+ fs.writeFileSync(path.join(child.gateDir, `${gate}.release`), "");
100
+ }
101
+
102
+ beforeEach(() => {
103
+ if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
104
+ });
105
+
106
+ afterEach(async () => {
107
+ for (const running of children) {
108
+ if (running.child.exitCode === null && running.child.signalCode === null) running.child.kill("SIGKILL");
109
+ }
110
+ await Promise.all(children.map(running => waitForExit(running.child).catch(() => undefined)));
111
+ children.length = 0;
112
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
113
+ });
114
+
115
+ it("should handle multiple concurrent attempts to acquire the lock", async () => {
116
+ let counter = 0;
117
+ const iterations = 20;
118
+ const concurrentCount = 5;
119
+
120
+ const runTask = async () => {
121
+ for (let i = 0; i < iterations; i++) {
122
+ await withLock(lockPath, async () => {
123
+ const current = counter;
124
+ await new Promise(resolve => setTimeout(resolve, Math.random() * 10));
125
+ counter = current + 1;
126
+ });
127
+ }
128
+ };
129
+
130
+ await Promise.all(Array.from({ length: concurrentCount }, runTask));
131
+
132
+ expect(counter).toBe(iterations * concurrentCount);
133
+ });
134
+
135
+ it("does not revoke a live paused reaper or let it delete a successor lock", async () => {
136
+ const deadOwnerPid = await getExitedPid();
137
+ fs.writeFileSync(lockFile, JSON.stringify({ pid: deadOwnerPid, token: "dead-lock", acquiredAt: 1 }));
138
+ const longAgo = new Date(Date.now() - 60_000);
139
+ fs.utimesSync(lockFile, longAgo, longAgo);
140
+
141
+ const first = startReaper("first", { pauseBeforeReap: true, holdCallback: true, retries: 20 });
142
+ await waitForFile(path.join(first.gateDir, "before-reap.ready"));
143
+
144
+ const reapGuard = `${lockFile}.reap`;
145
+ fs.utimesSync(reapGuard, longAgo, longAgo);
146
+
147
+ const second = startReaper("second", { pauseBeforeReap: true, holdCallback: true, retries: 10 });
148
+ const secondBeforeReap = path.join(second.gateDir, "before-reap.ready");
149
+ const secondResult = path.join(second.gateDir, "result.json");
150
+ const secondOutcome = await waitForAnyFile([secondBeforeReap, secondResult]);
151
+
152
+ let callbacksOverlapped = false;
153
+ if (secondOutcome === secondBeforeReap) {
154
+ release(first, "before-reap");
155
+ await waitForFile(path.join(first.gateDir, "callback.ready"));
156
+ release(second, "before-reap");
157
+ await waitForFile(path.join(second.gateDir, "callback.ready"));
158
+ callbacksOverlapped = true;
159
+ release(first, "callback");
160
+ release(second, "callback");
161
+ await Promise.all([
162
+ waitForFile(path.join(first.gateDir, "result.json")),
163
+ waitForFile(path.join(second.gateDir, "result.json")),
164
+ ]);
165
+ } else {
166
+ expect(readResult(second.gateDir)).toMatchObject({ status: "error", message: "Could not acquire lock" });
167
+ release(first, "before-reap");
168
+ await waitForFile(path.join(first.gateDir, "callback.ready"));
169
+ release(first, "callback");
170
+ await waitForFile(path.join(first.gateDir, "result.json"));
171
+ expect(readResult(first.gateDir)).toMatchObject({ status: "success" });
172
+ }
173
+
174
+ expect(callbacksOverlapped).toBe(false);
175
+ expect(first.stderr()).toBe("");
176
+ expect(second.stderr()).toBe("");
177
+ }, 20_000);
178
+
179
+ it("recovers after a reaper genuinely dies while holding the guard", async () => {
180
+ const deadOwnerPid = await getExitedPid();
181
+ fs.writeFileSync(lockFile, JSON.stringify({ pid: deadOwnerPid, token: "dead-lock", acquiredAt: 1 }));
182
+ const longAgo = new Date(Date.now() - 60_000);
183
+ fs.utimesSync(lockFile, longAgo, longAgo);
184
+
185
+ const crashed = startReaper("crashed", { pauseBeforeReap: true, holdCallback: false, retries: 20 });
186
+ await waitForFile(path.join(crashed.gateDir, "before-reap.ready"));
187
+ crashed.child.kill("SIGKILL");
188
+ await waitForExit(crashed.child);
189
+ fs.utimesSync(`${lockFile}.reap`, longAgo, longAgo);
190
+
191
+ const recovery = startReaper("recovery", { pauseBeforeReap: false, holdCallback: false, retries: 200 });
192
+ await waitForFile(path.join(recovery.gateDir, "result.json"));
193
+
194
+ expect(readResult(recovery.gateDir)).toMatchObject({ status: "success" });
195
+ expect(fs.existsSync(lockFile)).toBe(false);
196
+ expect(recovery.stderr()).toBe("");
197
+ }, 20_000);
198
+ });
@@ -0,0 +1,90 @@
1
+ // Project: pi-extended-teams
2
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { withLock } from "./lock";
7
+
8
+ describe("withLock", () => {
9
+ const testDir = path.join(os.tmpdir(), "pi-lock-test-" + Date.now());
10
+ const lockPath = path.join(testDir, "test");
11
+ const lockFile = `${lockPath}.lock`;
12
+
13
+ beforeEach(() => {
14
+ if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
20
+ });
21
+
22
+ it("should successfully acquire and release the lock", async () => {
23
+ const fn = vi.fn().mockResolvedValue("result");
24
+ const result = await withLock(lockPath, fn);
25
+
26
+ expect(result).toBe("result");
27
+ expect(fn).toHaveBeenCalled();
28
+ expect(fs.existsSync(lockFile)).toBe(false);
29
+ });
30
+
31
+ it("should fail to acquire lock if already held", async () => {
32
+ // Manually create lock file
33
+ fs.writeFileSync(lockFile, "9999");
34
+
35
+ const fn = vi.fn().mockResolvedValue("result");
36
+
37
+ // Test with only 2 retries to speed up the failure
38
+ await expect(withLock(lockPath, fn, 2)).rejects.toThrow("Could not acquire lock");
39
+ expect(fn).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it("should release lock even if function fails", async () => {
43
+ const fn = vi.fn().mockRejectedValue(new Error("failure"));
44
+
45
+ await expect(withLock(lockPath, fn)).rejects.toThrow("failure");
46
+ expect(fs.existsSync(lockFile)).toBe(false);
47
+ });
48
+
49
+ it("removes stale lock files before acquiring", async () => {
50
+ fs.writeFileSync(lockFile, JSON.stringify({ pid: 9999, token: "stale", acquiredAt: 1 }));
51
+ const staleTime = new Date(Date.now() - 60_000);
52
+ fs.utimesSync(lockFile, staleTime, staleTime);
53
+
54
+ const fn = vi.fn().mockResolvedValue("result");
55
+ await expect(withLock(lockPath, fn)).resolves.toBe("result");
56
+ expect(fn).toHaveBeenCalled();
57
+ expect(fs.existsSync(lockFile)).toBe(false);
58
+ });
59
+
60
+ it("heartbeats held locks so long critical sections do not become stale", async () => {
61
+ vi.useFakeTimers();
62
+ vi.setSystemTime(new Date("2026-06-19T12:00:00.000Z"));
63
+ let release!: () => void;
64
+
65
+ const running = withLock(lockPath, async () => new Promise<string>((resolve) => {
66
+ release = () => resolve("done");
67
+ }));
68
+ await vi.advanceTimersByTimeAsync(0);
69
+
70
+ const staleTime = new Date(Date.now() - 60_000);
71
+ fs.utimesSync(lockFile, staleTime, staleTime);
72
+ const initialMtime = fs.statSync(lockFile).mtimeMs;
73
+ await vi.advanceTimersByTimeAsync(12_000);
74
+
75
+ expect(fs.statSync(lockFile).mtimeMs).toBeGreaterThan(initialMtime);
76
+
77
+ release();
78
+ await expect(running).resolves.toBe("done");
79
+ expect(fs.existsSync(lockFile)).toBe(false);
80
+ });
81
+
82
+ it("does not remove a lock now owned by another holder", async () => {
83
+ await withLock(lockPath, async () => {
84
+ fs.writeFileSync(lockFile, JSON.stringify({ pid: 1234, token: "other-owner", acquiredAt: Date.now() }));
85
+ });
86
+
87
+ expect(fs.existsSync(lockFile)).toBe(true);
88
+ expect(JSON.parse(fs.readFileSync(lockFile, "utf-8"))).toMatchObject({ token: "other-owner" });
89
+ });
90
+ });
@@ -0,0 +1,186 @@
1
+ // Project: pi-extended-teams
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const LOCK_TIMEOUT = 30000; // 30 seconds of retrying under high fan-out contention
7
+ const LOCK_RETRY_DELAY_MS = 10;
8
+ const DEFAULT_LOCK_RETRIES = Math.ceil(LOCK_TIMEOUT / LOCK_RETRY_DELAY_MS);
9
+ const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
10
+ const LOCK_HEARTBEAT_INTERVAL_MS = Math.max(1000, Math.floor(STALE_LOCK_TIMEOUT / 3));
11
+
12
+ interface LockOwner {
13
+ pid: number;
14
+ token: string;
15
+ acquiredAt: number;
16
+ }
17
+
18
+ function readLockOwner(lockFile: string): LockOwner | null {
19
+ try {
20
+ const raw = fs.readFileSync(lockFile, "utf-8");
21
+ const parsed = JSON.parse(raw);
22
+ if (
23
+ parsed
24
+ && Number.isSafeInteger(parsed.pid)
25
+ && parsed.pid > 0
26
+ && typeof parsed.token === "string"
27
+ && typeof parsed.acquiredAt === "number"
28
+ ) return parsed;
29
+ if (Number.isSafeInteger(parsed) && parsed > 0) {
30
+ // PID reuse can delay reclaiming a legacy lock, but the liveness check
31
+ // below will never revoke it while any process has that PID.
32
+ return { pid: parsed, token: `legacy-pid:${parsed}`, acquiredAt: 0 };
33
+ }
34
+ } catch {
35
+ // Malformed owner records cannot be reclaimed without positive evidence.
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function errorCode(error: unknown): string | undefined {
41
+ return error && typeof error === "object" && "code" in error
42
+ ? String((error as NodeJS.ErrnoException).code)
43
+ : undefined;
44
+ }
45
+
46
+ function processIsDefinitelyDead(pid: number): boolean {
47
+ try {
48
+ process.kill(pid, 0);
49
+ return false;
50
+ } catch (error) {
51
+ // EPERM means the process exists but cannot be signalled. Only ESRCH is
52
+ // positive evidence that a local process no longer exists.
53
+ return errorCode(error) === "ESRCH";
54
+ }
55
+ }
56
+
57
+ function publishReapGuard(guardFile: string, owner: LockOwner): boolean {
58
+ const candidateName = `.candidate-${crypto.createHash("sha256").update(owner.token).digest("hex")}`;
59
+ const candidate = path.join(path.dirname(guardFile), candidateName);
60
+
61
+ try {
62
+ try {
63
+ fs.writeFileSync(candidate, JSON.stringify(owner), { flag: "wx" });
64
+ } catch (error) {
65
+ if (errorCode(error) !== "EEXIST" || readLockOwner(candidate)?.token !== owner.token) return false;
66
+ }
67
+
68
+ // The complete candidate is published with a no-clobber hard link. A
69
+ // process dying before this point leaves no visible epoch; dying after it
70
+ // leaves a complete owner record that a later epoch can recover from.
71
+ fs.linkSync(candidate, guardFile);
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ } finally {
76
+ try {
77
+ if (readLockOwner(candidate)?.token === owner.token) fs.unlinkSync(candidate);
78
+ } catch {
79
+ // The candidate is unique to this owner and may already be gone.
80
+ }
81
+ }
82
+ }
83
+
84
+ function acquireReapGuard(lockFile: string, staleOwner: LockOwner, reaper: LockOwner): boolean {
85
+ const guardDir = `${lockFile}.reap`;
86
+ try {
87
+ fs.mkdirSync(guardDir, { recursive: true });
88
+ } catch {
89
+ return false;
90
+ }
91
+
92
+ const generation = crypto.createHash("sha256").update(staleOwner.token).digest("hex");
93
+ for (let epoch = 0; ; epoch++) {
94
+ const guardFile = path.join(guardDir, `${generation}.${epoch}`);
95
+ if (publishReapGuard(guardFile, reaper)) return true;
96
+
97
+ const guardOwner = readLockOwner(guardFile);
98
+ if (guardOwner?.token === reaper.token) return true;
99
+ if (!guardOwner || !processIsDefinitelyDead(guardOwner.pid)) return false;
100
+ // Guard epochs are immutable and never unlinked. After a proven owner
101
+ // death, contenders race to publish the next epoch instead of replacing
102
+ // state that another live contender may already own.
103
+ }
104
+ }
105
+
106
+ function removeStaleLock(lockFile: string, reaper: LockOwner): void {
107
+ try {
108
+ const staleOwner = readLockOwner(lockFile);
109
+ if (!staleOwner) return;
110
+ if (Date.now() - fs.statSync(lockFile).mtimeMs <= STALE_LOCK_TIMEOUT) return;
111
+ if (!processIsDefinitelyDead(staleOwner.pid)) return;
112
+ if (!acquireReapGuard(lockFile, staleOwner, reaper)) return;
113
+
114
+ // Re-check both generation and liveness while holding the generation's
115
+ // immutable guard. No other live reaper for this generation can reach the
116
+ // unlink, so the path cannot be replaced between this check and removal.
117
+ const currentOwner = readLockOwner(lockFile);
118
+ if (currentOwner?.token !== staleOwner.token) return;
119
+ if (Date.now() - fs.statSync(lockFile).mtimeMs <= STALE_LOCK_TIMEOUT) return;
120
+ if (!processIsDefinitelyDead(currentOwner.pid)) return;
121
+ fs.unlinkSync(lockFile);
122
+ } catch {
123
+ // Ignore: another process may have removed it, or it may not exist yet.
124
+ }
125
+ }
126
+
127
+ function touchOwnedLock(lockFile: string, token: string): void {
128
+ const owner = readLockOwner(lockFile);
129
+ if (owner?.token !== token) return;
130
+ try {
131
+ const now = new Date();
132
+ fs.utimesSync(lockFile, now, now);
133
+ } catch {
134
+ // Ignore: another process may have removed it after the ownership check.
135
+ }
136
+ }
137
+
138
+ function releaseOwnedLock(lockFile: string, token: string): void {
139
+ const owner = readLockOwner(lockFile);
140
+ if (owner?.token !== token) return;
141
+ try {
142
+ fs.unlinkSync(lockFile);
143
+ } catch {
144
+ // Ignore: another process may have removed it after the ownership check.
145
+ }
146
+ }
147
+
148
+ export async function withLock<T>(lockPath: string, fn: () => Promise<T>, retries: number = DEFAULT_LOCK_RETRIES): Promise<T> {
149
+ const lockFile = `${lockPath}.lock`;
150
+ const lockDir = path.dirname(lockFile);
151
+ const owner: LockOwner = {
152
+ pid: process.pid,
153
+ token: `${process.pid}:${Date.now()}:${crypto.randomUUID()}`,
154
+ acquiredAt: Date.now(),
155
+ };
156
+
157
+ if (!fs.existsSync(lockDir)) fs.mkdirSync(lockDir, { recursive: true });
158
+
159
+ let remainingRetries = retries;
160
+ let acquired = false;
161
+ while (remainingRetries > 0) {
162
+ try {
163
+ removeStaleLock(lockFile, owner);
164
+ fs.writeFileSync(lockFile, JSON.stringify(owner), { flag: "wx" });
165
+ acquired = true;
166
+ break;
167
+ } catch {
168
+ remainingRetries--;
169
+ await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
170
+ }
171
+ }
172
+
173
+ if (!acquired) {
174
+ throw new Error("Could not acquire lock");
175
+ }
176
+
177
+ const heartbeat = setInterval(() => touchOwnedLock(lockFile, owner.token), LOCK_HEARTBEAT_INTERVAL_MS);
178
+ heartbeat.unref?.();
179
+
180
+ try {
181
+ return await fn();
182
+ } finally {
183
+ clearInterval(heartbeat);
184
+ releaseOwnedLock(lockFile, owner.token);
185
+ }
186
+ }