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,169 @@
1
+ /**
2
+ * File write-claim registry.
3
+ *
4
+ * Coordinates write intent across agents. Claims are advisory protocol state,
5
+ * not OS-level or tool-level sandbox enforcement. Cooperative write agents must
6
+ * claim before editing; the registry prevents two granted claims for the same
7
+ * path. Claims are held in a single lock-guarded `claims.json` per team so multi-file claims are
8
+ * atomic and all-or-nothing: if any requested path is held by another agent,
9
+ * none are granted.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { writeJsonAtomic } from "./atomic-json";
15
+ import { withLock } from "./lock";
16
+ import { claimsPath } from "./paths";
17
+
18
+ export interface FileClaim {
19
+ agent: string;
20
+ path: string;
21
+ since: number;
22
+ }
23
+
24
+ /** Map of normalized file path -> claim. */
25
+ type ClaimMap = Record<string, FileClaim>;
26
+
27
+ export interface ClaimResult {
28
+ granted: string[];
29
+ conflicts: Array<{ path: string; heldBy: string }>;
30
+ }
31
+
32
+ /**
33
+ * Normalize a repository-relative path so "./a", "a", and "a/" map to the
34
+ * same claim key. Blank, absolute, and parent-traversal paths are rejected so a
35
+ * bad claim cannot accidentally reserve the repository root or escape it.
36
+ */
37
+ export function normalizeClaimPath(p: string): string {
38
+ const raw = p.trim().replace(/\\/g, "/");
39
+ if (!raw) {
40
+ throw new Error("File claim path must not be empty.");
41
+ }
42
+ if (raw.startsWith("/") || /^[A-Za-z]:\//.test(raw)) {
43
+ throw new Error(`File claim path must be repository-relative: ${p}`);
44
+ }
45
+
46
+ const normalized = path.posix.normalize(raw).replace(/\/+$/, "");
47
+ if (!normalized || normalized === ".") {
48
+ throw new Error("File claim path must not refer to the repository root.");
49
+ }
50
+ if (normalized.split("/").includes("..")) {
51
+ throw new Error(`File claim path must not traverse outside the repository: ${p}`);
52
+ }
53
+
54
+ return normalized;
55
+ }
56
+
57
+ function readClaims(p: string): ClaimMap {
58
+ if (!fs.existsSync(p)) return {};
59
+ try {
60
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
61
+ return parsed && typeof parsed === "object" ? (parsed as ClaimMap) : {};
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ function writeClaims(p: string, claims: ClaimMap): void {
68
+ writeJsonAtomic(p, claims);
69
+ }
70
+
71
+ /**
72
+ * Attempt to claim a set of paths for an agent.
73
+ *
74
+ * All-or-nothing: if any path is held by a *different* agent, nothing is
75
+ * granted and the conflicts are returned. Re-claiming a path the agent already
76
+ * holds is idempotent (refreshes `since`).
77
+ */
78
+ export async function claimFiles(
79
+ teamName: string,
80
+ agent: string,
81
+ paths: string[],
82
+ now: number = Date.now()
83
+ ): Promise<ClaimResult> {
84
+ const p = claimsPath(teamName);
85
+ const normalized = Array.from(new Set(paths.map(normalizeClaimPath).filter(Boolean)));
86
+
87
+ return await withLock(p, async () => {
88
+ const claims = readClaims(p);
89
+ const conflicts: ClaimResult["conflicts"] = [];
90
+
91
+ for (const target of normalized) {
92
+ const existing = claims[target];
93
+ if (existing && existing.agent !== agent) {
94
+ conflicts.push({ path: target, heldBy: existing.agent });
95
+ }
96
+ }
97
+
98
+ if (conflicts.length > 0) {
99
+ return { granted: [], conflicts };
100
+ }
101
+
102
+ for (const target of normalized) {
103
+ claims[target] = { agent, path: target, since: claims[target]?.since ?? now };
104
+ }
105
+ writeClaims(p, claims);
106
+
107
+ return { granted: normalized, conflicts: [] };
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Release specific paths, but only those held by this agent.
113
+ */
114
+ export async function releaseFiles(
115
+ teamName: string,
116
+ agent: string,
117
+ paths: string[]
118
+ ): Promise<string[]> {
119
+ const p = claimsPath(teamName);
120
+ const normalized = new Set(paths.map(normalizeClaimPath).filter(Boolean));
121
+
122
+ return await withLock(p, async () => {
123
+ const claims = readClaims(p);
124
+ const released: string[] = [];
125
+
126
+ for (const target of normalized) {
127
+ if (claims[target]?.agent === agent) {
128
+ delete claims[target];
129
+ released.push(target);
130
+ }
131
+ }
132
+
133
+ if (released.length > 0) writeClaims(p, claims);
134
+ return released;
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Release every claim held by an agent. Called on orderly exit and on
140
+ * watchdog-confirmed death.
141
+ */
142
+ export async function releaseAllForAgent(teamName: string, agent: string): Promise<string[]> {
143
+ const p = claimsPath(teamName);
144
+
145
+ return await withLock(p, async () => {
146
+ const claims = readClaims(p);
147
+ const released: string[] = [];
148
+
149
+ for (const [target, claim] of Object.entries(claims)) {
150
+ if (claim.agent === agent) {
151
+ delete claims[target];
152
+ released.push(target);
153
+ }
154
+ }
155
+
156
+ if (released.length > 0) writeClaims(p, claims);
157
+ return released;
158
+ });
159
+ }
160
+
161
+ /**
162
+ * List all current claims.
163
+ */
164
+ export async function listClaims(teamName: string): Promise<FileClaim[]> {
165
+ const p = claimsPath(teamName);
166
+ return await withLock(p, async () => {
167
+ return Object.values(readClaims(p));
168
+ });
169
+ }
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { runHook } from "./hooks";
4
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
5
+
6
+ describe("runHook", () => {
7
+ const hooksDir = path.join(process.cwd(), ".pi", "team-hooks");
8
+
9
+ beforeAll(() => {
10
+ if (!fs.existsSync(hooksDir)) {
11
+ fs.mkdirSync(hooksDir, { recursive: true });
12
+ }
13
+ });
14
+
15
+ afterAll(() => {
16
+ // Optional: Clean up created scripts
17
+ const files = ["success_hook.sh", "fail_hook.sh"];
18
+ files.forEach(f => {
19
+ const p = path.join(hooksDir, f);
20
+ if (fs.existsSync(p)) fs.unlinkSync(p);
21
+ });
22
+ });
23
+
24
+ it("should return true if hook script does not exist", async () => {
25
+ const result = await runHook("test_team", "non_existent_hook", { data: "test" });
26
+ expect(result).toBe(true);
27
+ });
28
+
29
+ it("should return true if hook script succeeds", async () => {
30
+ const hookName = "success_hook";
31
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`);
32
+
33
+ // Create a simple script that exits with 0
34
+ fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 });
35
+
36
+ const result = await runHook("test_team", hookName, { data: "test" });
37
+ expect(result).toBe(true);
38
+ });
39
+
40
+ it("should return false if hook script fails", async () => {
41
+ const hookName = "fail_hook";
42
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`);
43
+
44
+ // Create a simple script that exits with 1
45
+ fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 });
46
+
47
+ // Mock console.error to avoid noise in test output
48
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
49
+
50
+ const result = await runHook("test_team", hookName, { data: "test" });
51
+ expect(result).toBe(false);
52
+
53
+ consoleSpy.mockRestore();
54
+ });
55
+
56
+ it("should pass the payload to the hook script", async () => {
57
+ const hookName = "payload_hook";
58
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`);
59
+ const outputFile = path.join(hooksDir, "payload_output.txt");
60
+
61
+ // Create a script that writes its first argument to a file
62
+ fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, { mode: 0o755 });
63
+
64
+ const payload = { key: "value", "special'char": true };
65
+ const result = await runHook("test_team", hookName, payload);
66
+
67
+ expect(result).toBe(true);
68
+ const output = fs.readFileSync(outputFile, "utf-8").trim();
69
+ expect(JSON.parse(output)).toEqual(payload);
70
+
71
+ // Clean up
72
+ fs.unlinkSync(scriptPath);
73
+ if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
74
+ });
75
+ });
@@ -0,0 +1,35 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Runs a hook script asynchronously if it exists.
10
+ * Hooks are located in .pi/team-hooks/{hookName}.sh relative to the CWD.
11
+ *
12
+ * @param teamName The name of the team.
13
+ * @param hookName The name of the hook to run (e.g., 'task_completed').
14
+ * @param payload The payload to pass to the hook script as the first argument.
15
+ * @returns true if the hook doesn't exist or executes successfully; false otherwise.
16
+ */
17
+ export async function runHook(teamName: string, hookName: string, payload: any): Promise<boolean> {
18
+ const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`);
19
+
20
+ if (!fs.existsSync(hookPath)) {
21
+ return true;
22
+ }
23
+
24
+ try {
25
+ const payloadStr = JSON.stringify(payload);
26
+ // Use execFile: More secure (no shell interpolation) and asynchronous
27
+ await execFileAsync(hookPath, [payloadStr], {
28
+ env: { ...process.env, PI_TEAM: teamName },
29
+ });
30
+ return true;
31
+ } catch (error) {
32
+ console.error(`Hook ${hookName} failed:`, error);
33
+ return false;
34
+ }
35
+ }
@@ -0,0 +1,105 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import * as paths from "./paths";
6
+ import {
7
+ readLifecycleTombstone,
8
+ withLifecycleTombstoneLock,
9
+ } from "./lifecycle-tombstone";
10
+
11
+ let root = "";
12
+
13
+ function tombstonePath(teamName: string, agentName: string): string {
14
+ return path.join(root, teamName, "lifecycle", "quarantine", `${agentName}.json`);
15
+ }
16
+
17
+ describe("lifecycle tombstone store", () => {
18
+ beforeEach(() => {
19
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "lifecycle-tombstone-"));
20
+ vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation(tombstonePath);
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.restoreAllMocks();
25
+ fs.rmSync(root, { recursive: true, force: true });
26
+ });
27
+
28
+ it("atomically writes and reads a schema-v1 tombstone", async () => {
29
+ await withLifecycleTombstoneLock("team", "reader", async lock => {
30
+ const written = lock.occupy({
31
+ team: "team",
32
+ agent: "reader",
33
+ runId: "run-1",
34
+ role: "read",
35
+ reason: "quit",
36
+ extensionInstanceId: "extension-a",
37
+ now: 100,
38
+ });
39
+ expect(written).toMatchObject({
40
+ version: 1,
41
+ team: "team",
42
+ agent: "reader",
43
+ runId: "run-1",
44
+ phase: "closing",
45
+ timestamps: { createdAt: 100, updatedAt: 100 },
46
+ });
47
+ });
48
+
49
+ await expect(readLifecycleTombstone("team", "reader")).resolves.toMatchObject({
50
+ status: "occupied",
51
+ tombstone: { version: 1, runId: "run-1", extensionInstanceId: "extension-a" },
52
+ });
53
+ expect(fs.readdirSync(path.dirname(tombstonePath("team", "reader"))).filter(file => file.endsWith(".tmp"))).toEqual([]);
54
+ });
55
+
56
+ it("clears only a matching run and leaves a wrong-run request occupied", async () => {
57
+ await withLifecycleTombstoneLock("team", "reader", async lock => {
58
+ lock.occupy({
59
+ team: "team", agent: "reader", runId: "run-1", role: "read", reason: "quit", extensionInstanceId: "extension-a",
60
+ });
61
+ expect(lock.clearMatching("run-2")).toBe(false);
62
+ });
63
+ expect((await readLifecycleTombstone("team", "reader")).status).toBe("occupied");
64
+
65
+ await withLifecycleTombstoneLock("team", "reader", async lock => {
66
+ expect(lock.clearMatching("run-1")).toBe(true);
67
+ });
68
+ expect(await readLifecycleTombstone("team", "reader")).toEqual({ status: "absent" });
69
+ });
70
+
71
+ it("treats a tombstone unlinked mid-read as absent rather than corrupt", async () => {
72
+ const file = tombstonePath("team", "reader");
73
+ await withLifecycleTombstoneLock("team", "reader", async lock => {
74
+ lock.occupy({
75
+ team: "team", agent: "reader", runId: "run-1", role: "read", reason: "quit", extensionInstanceId: "extension-a",
76
+ });
77
+ });
78
+
79
+ // The owning run clears its fence between the existsSync probe and the open.
80
+ const realReadFileSync = fs.readFileSync;
81
+ vi.spyOn(fs, "readFileSync").mockImplementationOnce(((target: any, options: any) => {
82
+ if (target === file) fs.unlinkSync(file);
83
+ return realReadFileSync(target, options);
84
+ }) as typeof fs.readFileSync);
85
+
86
+ expect(await readLifecycleTombstone("team", "reader")).toEqual({ status: "absent" });
87
+ });
88
+
89
+ it("treats corrupt content as occupied and never overwrites or clears it", async () => {
90
+ const file = tombstonePath("team", "reader");
91
+ fs.mkdirSync(path.dirname(file), { recursive: true });
92
+ fs.writeFileSync(file, "{ malformed");
93
+
94
+ await expect(readLifecycleTombstone("team", "reader")).resolves.toMatchObject({ status: "corrupt" });
95
+ await expect(withLifecycleTombstoneLock("team", "reader", async lock => {
96
+ lock.occupy({
97
+ team: "team", agent: "reader", runId: "run-1", role: "read", reason: "quit", extensionInstanceId: "extension-a",
98
+ });
99
+ })).rejects.toThrow("corrupt tombstone");
100
+ await withLifecycleTombstoneLock("team", "reader", async lock => {
101
+ expect(lock.clearMatching("run-1")).toBe(false);
102
+ });
103
+ expect(fs.readFileSync(file, "utf-8")).toBe("{ malformed");
104
+ });
105
+ });
@@ -0,0 +1,268 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { withLock } from "./lock";
5
+ import { lifecycleTombstonePath } from "./paths";
6
+
7
+ export const LIFECYCLE_TOMBSTONE_VERSION = 1 as const;
8
+
9
+ export type LifecycleTombstonePhase =
10
+ | "closing"
11
+ | "persistence_closed"
12
+ | "timed_out"
13
+ | "finalizing"
14
+ | "cleanup_failed";
15
+
16
+ export interface LifecycleTombstone {
17
+ version: typeof LIFECYCLE_TOMBSTONE_VERSION;
18
+ team: string;
19
+ agent: string;
20
+ runId: string;
21
+ role: "read" | "write";
22
+ reason: string;
23
+ phase: LifecycleTombstonePhase;
24
+ ownerPid: number;
25
+ extensionInstanceId: string;
26
+ timestamps: {
27
+ createdAt: number;
28
+ updatedAt: number;
29
+ };
30
+ timeout?: {
31
+ afterMs: number;
32
+ at: number;
33
+ };
34
+ error?: string;
35
+ }
36
+
37
+ export type LifecycleTombstoneReadResult =
38
+ | { status: "absent" }
39
+ | { status: "occupied"; tombstone: LifecycleTombstone }
40
+ | { status: "corrupt"; error: string };
41
+
42
+ export interface CreateLifecycleTombstoneInput {
43
+ team: string;
44
+ agent: string;
45
+ runId: string;
46
+ role: "read" | "write";
47
+ reason: string;
48
+ phase?: LifecycleTombstonePhase;
49
+ ownerPid?: number;
50
+ extensionInstanceId: string;
51
+ now?: number;
52
+ }
53
+
54
+ export interface LifecycleTombstoneLock {
55
+ path: string;
56
+ read(): LifecycleTombstoneReadResult;
57
+ occupy(input: CreateLifecycleTombstoneInput): LifecycleTombstone;
58
+ updateMatching(runId: string, updates: Partial<Pick<LifecycleTombstone, "phase" | "timeout" | "error">>): LifecycleTombstone | null;
59
+ clearMatching(runId: string): boolean;
60
+ }
61
+
62
+ const clearListeners = new Set<(teamName: string, agentName: string, runId: string) => void>();
63
+
64
+ export function generateLifecycleRunId(): string {
65
+ return crypto.randomUUID();
66
+ }
67
+
68
+ export function generateExtensionInstanceId(): string {
69
+ return crypto.randomUUID();
70
+ }
71
+
72
+ function errorText(error: unknown): string {
73
+ return error instanceof Error ? error.message : String(error);
74
+ }
75
+
76
+ function isNonEmptyString(value: unknown): value is string {
77
+ return typeof value === "string" && value.length > 0;
78
+ }
79
+
80
+ function isLifecycleTombstone(value: unknown, teamName: string, agentName: string): value is LifecycleTombstone {
81
+ if (!value || typeof value !== "object") return false;
82
+ const item = value as Partial<LifecycleTombstone>;
83
+ return item.version === LIFECYCLE_TOMBSTONE_VERSION
84
+ && item.team === teamName
85
+ && item.agent === agentName
86
+ && isNonEmptyString(item.runId)
87
+ && (item.role === "read" || item.role === "write")
88
+ && isNonEmptyString(item.reason)
89
+ && ["closing", "persistence_closed", "timed_out", "finalizing", "cleanup_failed"].includes(String(item.phase))
90
+ && typeof item.ownerPid === "number"
91
+ && isNonEmptyString(item.extensionInstanceId)
92
+ && !!item.timestamps
93
+ && typeof item.timestamps.createdAt === "number"
94
+ && typeof item.timestamps.updatedAt === "number"
95
+ && (item.timeout === undefined || (
96
+ typeof item.timeout.afterMs === "number" && typeof item.timeout.at === "number"
97
+ ))
98
+ && (item.error === undefined || typeof item.error === "string");
99
+ }
100
+
101
+ function readUnlocked(teamName: string, agentName: string, tombstonePath: string): LifecycleTombstoneReadResult {
102
+ if (!fs.existsSync(tombstonePath)) return { status: "absent" };
103
+ try {
104
+ const parsed = JSON.parse(fs.readFileSync(tombstonePath, "utf-8"));
105
+ if (!isLifecycleTombstone(parsed, teamName, agentName)) {
106
+ return { status: "corrupt", error: "Lifecycle tombstone schema or identity is invalid." };
107
+ }
108
+ return { status: "occupied", tombstone: parsed };
109
+ } catch (error) {
110
+ // clearMatching may unlink between the existence probe and this read.
111
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "absent" };
112
+ return { status: "corrupt", error: `Lifecycle tombstone could not be read: ${errorText(error)}` };
113
+ }
114
+ }
115
+
116
+ function atomicWriteJson(filePath: string, value: unknown): void {
117
+ const dir = path.dirname(filePath);
118
+ fs.mkdirSync(dir, { recursive: true });
119
+ const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
120
+ let descriptor: number | undefined;
121
+ try {
122
+ descriptor = fs.openSync(tempPath, "wx", 0o600);
123
+ fs.writeFileSync(descriptor, JSON.stringify(value, null, 2));
124
+ fs.fsyncSync(descriptor);
125
+ fs.closeSync(descriptor);
126
+ descriptor = undefined;
127
+ fs.renameSync(tempPath, filePath);
128
+ try {
129
+ const dirDescriptor = fs.openSync(dir, "r");
130
+ try { fs.fsyncSync(dirDescriptor); } finally { fs.closeSync(dirDescriptor); }
131
+ } catch {
132
+ // Directory fsync is not supported on every platform/filesystem.
133
+ }
134
+ } catch (error) {
135
+ if (descriptor !== undefined) {
136
+ try { fs.closeSync(descriptor); } catch { /* preserve original error */ }
137
+ }
138
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch { /* preserve original error */ }
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ function occupiedError(teamName: string, agentName: string, result: Exclude<LifecycleTombstoneReadResult, { status: "absent" }>): Error {
144
+ if (result.status === "corrupt") {
145
+ return new Error(`Agent ${agentName} in ${teamName} is lifecycle-quarantined by a corrupt tombstone: ${result.error}`);
146
+ }
147
+ return new Error(
148
+ `Agent ${agentName} in ${teamName} is lifecycle-quarantined for run ${result.tombstone.runId} (${result.tombstone.phase}).`
149
+ );
150
+ }
151
+
152
+ export function assertLifecycleTombstoneAbsent(
153
+ teamName: string,
154
+ agentName: string,
155
+ result: LifecycleTombstoneReadResult
156
+ ): asserts result is { status: "absent" } {
157
+ if (result.status !== "absent") throw occupiedError(teamName, agentName, result);
158
+ }
159
+
160
+ export async function withLifecycleTombstoneLock<T>(
161
+ teamName: string,
162
+ agentName: string,
163
+ fn: (lock: LifecycleTombstoneLock) => Promise<T>
164
+ ): Promise<T> {
165
+ const tombstonePath = lifecycleTombstonePath(teamName, agentName);
166
+ let clearedRunId: string | undefined;
167
+ const result = await withLock(tombstonePath, async () => {
168
+ const lock: LifecycleTombstoneLock = {
169
+ path: tombstonePath,
170
+ read: () => readUnlocked(teamName, agentName, tombstonePath),
171
+ occupy: (input) => {
172
+ const current = readUnlocked(teamName, agentName, tombstonePath);
173
+ if (current.status === "corrupt") throw occupiedError(teamName, agentName, current);
174
+ if (current.status === "occupied") {
175
+ if (current.tombstone.runId !== input.runId) throw occupiedError(teamName, agentName, current);
176
+ return current.tombstone;
177
+ }
178
+ const now = input.now ?? Date.now();
179
+ const tombstone: LifecycleTombstone = {
180
+ version: LIFECYCLE_TOMBSTONE_VERSION,
181
+ team: input.team,
182
+ agent: input.agent,
183
+ runId: input.runId,
184
+ role: input.role,
185
+ reason: input.reason,
186
+ phase: input.phase ?? "closing",
187
+ ownerPid: input.ownerPid ?? process.pid,
188
+ extensionInstanceId: input.extensionInstanceId,
189
+ timestamps: { createdAt: now, updatedAt: now },
190
+ };
191
+ if (!isLifecycleTombstone(tombstone, teamName, agentName)) {
192
+ throw new Error(`Refusing to write invalid lifecycle tombstone for ${agentName} in ${teamName}.`);
193
+ }
194
+ atomicWriteJson(tombstonePath, tombstone);
195
+ return tombstone;
196
+ },
197
+ updateMatching: (runId, updates) => {
198
+ const current = readUnlocked(teamName, agentName, tombstonePath);
199
+ if (current.status !== "occupied" || current.tombstone.runId !== runId) return null;
200
+ const next: LifecycleTombstone = {
201
+ ...current.tombstone,
202
+ ...updates,
203
+ timestamps: { ...current.tombstone.timestamps, updatedAt: Date.now() },
204
+ };
205
+ atomicWriteJson(tombstonePath, next);
206
+ return next;
207
+ },
208
+ clearMatching: (runId) => {
209
+ const current = readUnlocked(teamName, agentName, tombstonePath);
210
+ if (current.status !== "occupied" || current.tombstone.runId !== runId) return false;
211
+ fs.unlinkSync(tombstonePath);
212
+ clearedRunId = runId;
213
+ return true;
214
+ },
215
+ };
216
+ return fn(lock);
217
+ });
218
+ if (clearedRunId) {
219
+ for (const listener of clearListeners) {
220
+ try { listener(teamName, agentName, clearedRunId); } catch { /* observers are best-effort */ }
221
+ }
222
+ }
223
+ return result;
224
+ }
225
+
226
+ export function readLifecycleTombstoneSnapshot(teamName: string, agentName: string): LifecycleTombstoneReadResult {
227
+ const tombstonePath = lifecycleTombstonePath(teamName, agentName);
228
+ return readUnlocked(teamName, agentName, tombstonePath);
229
+ }
230
+
231
+ export async function readLifecycleTombstone(teamName: string, agentName: string): Promise<LifecycleTombstoneReadResult> {
232
+ const tombstonePath = lifecycleTombstonePath(teamName, agentName);
233
+ // Existing/corrupt content is already a fail-closed answer and must be
234
+ // observable even while its owner holds the exact lock for slow cleanup.
235
+ if (fs.existsSync(tombstonePath)) return readUnlocked(teamName, agentName, tombstonePath);
236
+ // Absence needs the lock and a second read so admission cannot race creation.
237
+ return withLifecycleTombstoneLock(teamName, agentName, async lock => lock.read());
238
+ }
239
+
240
+ export async function listLifecycleTombstones(
241
+ teamName: string
242
+ ): Promise<Array<{ agentName: string; result: Exclude<LifecycleTombstoneReadResult, { status: "absent" }> }>> {
243
+ const quarantineDir = path.dirname(lifecycleTombstonePath(teamName, "placeholder"));
244
+ if (!fs.existsSync(quarantineDir)) return [];
245
+ const entries: Array<{ agentName: string; result: Exclude<LifecycleTombstoneReadResult, { status: "absent" }> }> = [];
246
+ for (const file of fs.readdirSync(quarantineDir).filter(item => item.endsWith(".json")).sort()) {
247
+ const agentName = file.slice(0, -".json".length);
248
+ const result = await readLifecycleTombstone(teamName, agentName);
249
+ if (result.status !== "absent") entries.push({ agentName, result });
250
+ }
251
+ return entries;
252
+ }
253
+
254
+ export async function updateMatchingLifecycleTombstone(
255
+ teamName: string,
256
+ agentName: string,
257
+ runId: string,
258
+ updates: Partial<Pick<LifecycleTombstone, "phase" | "timeout" | "error">>
259
+ ): Promise<LifecycleTombstone | null> {
260
+ return withLifecycleTombstoneLock(teamName, agentName, async lock => lock.updateMatching(runId, updates));
261
+ }
262
+
263
+ export function onLifecycleTombstoneCleared(
264
+ listener: (teamName: string, agentName: string, runId: string) => void
265
+ ): () => void {
266
+ clearListeners.add(listener);
267
+ return () => clearListeners.delete(listener);
268
+ }