@shawnowen/comet-mcp 2.3.1 → 2.4.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.
- package/README.md +97 -19
- package/dist/alert-dispatcher.d.ts +23 -0
- package/dist/alert-dispatcher.js +101 -0
- package/dist/binding-reaper.d.ts +46 -0
- package/dist/binding-reaper.js +73 -0
- package/dist/bound-session.d.ts +23 -0
- package/dist/bound-session.js +119 -0
- package/dist/bridge-config.d.ts +6 -0
- package/dist/bridge-config.js +78 -0
- package/dist/cdp-client.d.ts +40 -4
- package/dist/cdp-client.js +502 -155
- package/dist/comet-ai.d.ts +15 -0
- package/dist/comet-ai.js +114 -38
- package/dist/delegate-binding.d.ts +19 -0
- package/dist/delegate-binding.js +73 -0
- package/dist/http-server.js +2188 -47
- package/dist/index.js +3545 -788
- package/dist/observer.d.ts +47 -0
- package/dist/observer.js +516 -0
- package/dist/project-config.d.ts +46 -0
- package/dist/project-config.js +166 -0
- package/dist/session-registry.d.ts +57 -0
- package/dist/session-registry.js +500 -0
- package/dist/sidecar-artifacts.d.ts +49 -0
- package/dist/sidecar-artifacts.js +146 -0
- package/dist/snapshot-capture.d.ts +3 -0
- package/dist/snapshot-capture.js +91 -0
- package/dist/tab-group-archive.js +3 -1
- package/dist/tab-groups.d.ts +28 -1
- package/dist/tab-groups.js +205 -3
- package/dist/types.d.ts +237 -0
- package/dist/window-bindings.d.ts +160 -0
- package/dist/window-bindings.js +561 -0
- package/extension/background.js +1577 -300
- package/extension/icons/icon.svg +9 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +34 -4
- package/extension/perplexity-capability-manifest.json +1181 -0
- package/extension/perplexity-capability-manifest.schema.json +142 -0
- package/extension/session-logic.js +3054 -0
- package/extension/session-manager.html +311 -0
- package/extension/sidepanel.css +5338 -528
- package/extension/sidepanel.html +282 -2
- package/extension/sidepanel.js +10604 -950
- package/extension/window-policy.js +162 -0
- package/package.json +10 -7
- package/vendor/lifecycle-mcp-adapter.mjs +103 -0
- package/vendor/lifecycle-metadata.mjs +252 -0
- package/vendor/readiness-report.mjs +742 -0
- package/dist/cdp-client.d.ts.map +0 -1
- package/dist/cdp-client.js.map +0 -1
- package/dist/comet-ai.d.ts.map +0 -1
- package/dist/comet-ai.js.map +0 -1
- package/dist/http-server.d.ts.map +0 -1
- package/dist/http-server.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/tab-group-archive.d.ts.map +0 -1
- package/dist/tab-group-archive.js.map +0 -1
- package/dist/tab-groups.d.ts.map +0 -1
- package/dist/tab-groups.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, unlink, writeFile } from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
6
|
+
export class WindowBindingConflictError extends Error {
|
|
7
|
+
conflicts;
|
|
8
|
+
constructor(message, conflicts) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.conflicts = conflicts;
|
|
11
|
+
this.name = "WindowBindingConflictError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class CodexIdentityError extends Error {
|
|
15
|
+
missingFields;
|
|
16
|
+
constructor(message, missingFields) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.missingFields = missingFields;
|
|
19
|
+
this.name = "CodexIdentityError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_BINDING_DIR = path.join(os.homedir(), ".claude", "comet-browser");
|
|
23
|
+
const DEFAULT_BINDING_FILE = path.join(DEFAULT_BINDING_DIR, "window-bindings.json");
|
|
24
|
+
const LOCK_RETRY_MS = 25;
|
|
25
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
26
|
+
const VALID_CODEX_ROLES = new Set([
|
|
27
|
+
"session_agent",
|
|
28
|
+
"worktree_orchestrator",
|
|
29
|
+
"fleet_orchestrator",
|
|
30
|
+
]);
|
|
31
|
+
const VALID_PROFILE_OWNERS = new Set([
|
|
32
|
+
"agent",
|
|
33
|
+
"human",
|
|
34
|
+
"shared_legacy",
|
|
35
|
+
"unknown",
|
|
36
|
+
]);
|
|
37
|
+
function nowIso() {
|
|
38
|
+
return new Date().toISOString();
|
|
39
|
+
}
|
|
40
|
+
function normalizeRunIds(runIds = []) {
|
|
41
|
+
return [...new Set(runIds.filter((runId) => runId.trim().length > 0))].sort();
|
|
42
|
+
}
|
|
43
|
+
function firstNonEmpty(...values) {
|
|
44
|
+
return values.find((value) => value !== undefined && value.trim().length > 0)?.trim();
|
|
45
|
+
}
|
|
46
|
+
function gitOutput(cwd, args) {
|
|
47
|
+
try {
|
|
48
|
+
return execFileSync("git", args, {
|
|
49
|
+
cwd,
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
52
|
+
}).trim();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function repoSlugFromRemote(remoteUrl) {
|
|
59
|
+
if (!remoteUrl)
|
|
60
|
+
return undefined;
|
|
61
|
+
const match = remoteUrl.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
62
|
+
return match?.[1];
|
|
63
|
+
}
|
|
64
|
+
function deriveRepoSlug(cwd) {
|
|
65
|
+
return (repoSlugFromRemote(gitOutput(cwd, ["config", "--get", "remote.origin.url"])) ??
|
|
66
|
+
path.basename(cwd));
|
|
67
|
+
}
|
|
68
|
+
function deriveBranchName(cwd) {
|
|
69
|
+
return gitOutput(cwd, ["branch", "--show-current"]) || "unknown";
|
|
70
|
+
}
|
|
71
|
+
export function resolveCodexSessionRole(role) {
|
|
72
|
+
const normalized = role
|
|
73
|
+
?.trim()
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[-\s]+/g, "_");
|
|
76
|
+
if (normalized && VALID_CODEX_ROLES.has(normalized)) {
|
|
77
|
+
return normalized;
|
|
78
|
+
}
|
|
79
|
+
if (normalized === "fleet" || normalized === "fleet_orchestrator_agent") {
|
|
80
|
+
return "fleet_orchestrator";
|
|
81
|
+
}
|
|
82
|
+
if (normalized === "orchestrator" || normalized === "worktree") {
|
|
83
|
+
return "worktree_orchestrator";
|
|
84
|
+
}
|
|
85
|
+
return "session_agent";
|
|
86
|
+
}
|
|
87
|
+
export function deriveCodexSessionIdentity(input = {}, context = {}) {
|
|
88
|
+
const env = context.env ?? process.env;
|
|
89
|
+
const cwd = path.resolve(input.worktreePath ?? env.CODEX_WORKTREE_PATH ?? context.cwd ?? process.cwd());
|
|
90
|
+
const codexSessionId = firstNonEmpty(input.codexSessionId, env.CODEX_SESSION_ID, env.CODEX_RUN_ID, input.fallbackAgentId);
|
|
91
|
+
const projectThreadId = firstNonEmpty(input.projectThreadId, env.CODEX_PROJECT_THREAD_ID, env.COMET_TASK_GROUP, input.fallbackTaskThreadId);
|
|
92
|
+
const repoSlug = firstNonEmpty(input.repoSlug, env.CODEX_REPO_SLUG) ?? deriveRepoSlug(cwd);
|
|
93
|
+
const branchName = firstNonEmpty(input.branchName, env.CODEX_BRANCH_NAME, env.GIT_BRANCH) ?? deriveBranchName(cwd);
|
|
94
|
+
const missingFields = [
|
|
95
|
+
["codexSessionId", codexSessionId],
|
|
96
|
+
["projectThreadId", projectThreadId],
|
|
97
|
+
["worktreePath", cwd],
|
|
98
|
+
["repoSlug", repoSlug],
|
|
99
|
+
["branchName", branchName],
|
|
100
|
+
];
|
|
101
|
+
const missingFieldNames = missingFields.filter(([, value]) => !value).map(([field]) => field);
|
|
102
|
+
if (input.strict && missingFieldNames.length > 0) {
|
|
103
|
+
throw new CodexIdentityError("Missing required Codex identity fields", missingFieldNames);
|
|
104
|
+
}
|
|
105
|
+
const resolvedCodexSessionId = codexSessionId ?? "manual-codex-session";
|
|
106
|
+
const resolvedProjectThreadId = projectThreadId ?? "manual-project-thread";
|
|
107
|
+
return {
|
|
108
|
+
codexSessionId: resolvedCodexSessionId,
|
|
109
|
+
projectThreadId: resolvedProjectThreadId,
|
|
110
|
+
projectThreadFamily: firstNonEmpty(input.projectThreadFamily, env.CODEX_PROJECT_THREAD_FAMILY),
|
|
111
|
+
worktreePath: cwd,
|
|
112
|
+
repoSlug,
|
|
113
|
+
branchName,
|
|
114
|
+
sessionKey: firstNonEmpty(input.sessionKey, env.CODEX_SESSION_KEY) ??
|
|
115
|
+
`${resolvedCodexSessionId}:${resolvedProjectThreadId}`,
|
|
116
|
+
role: resolveCodexSessionRole(firstNonEmpty(input.role, env.CODEX_SESSION_ROLE, env.CODEX_ROLE)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function isBindingInWorktreeScope(caller, binding) {
|
|
120
|
+
if (caller.role === "fleet_orchestrator")
|
|
121
|
+
return true;
|
|
122
|
+
if (caller.role === "session_agent")
|
|
123
|
+
return caller.sessionKey === binding.sessionKey;
|
|
124
|
+
if (caller.worktreePath === binding.worktreePath)
|
|
125
|
+
return true;
|
|
126
|
+
return Boolean(caller.projectThreadFamily &&
|
|
127
|
+
binding.projectThreadFamily &&
|
|
128
|
+
caller.projectThreadFamily === binding.projectThreadFamily);
|
|
129
|
+
}
|
|
130
|
+
export function canReadBinding(caller, binding) {
|
|
131
|
+
return isBindingInWorktreeScope(caller, binding);
|
|
132
|
+
}
|
|
133
|
+
export function canMutateBinding(caller, binding, options = {}) {
|
|
134
|
+
if (caller.role === "session_agent") {
|
|
135
|
+
return caller.sessionKey === binding.sessionKey;
|
|
136
|
+
}
|
|
137
|
+
if (!isBindingInWorktreeScope(caller, binding))
|
|
138
|
+
return false;
|
|
139
|
+
const explicitTarget = options.targetBindingId === binding.bindingId;
|
|
140
|
+
if (!explicitTarget)
|
|
141
|
+
return false;
|
|
142
|
+
if (caller.role === "worktree_orchestrator" || caller.role === "fleet_orchestrator") {
|
|
143
|
+
return Boolean(options.reason?.trim() && options.audit);
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
export function assertBindingMutationAllowed(caller, binding, options = {}) {
|
|
148
|
+
if (canMutateBinding(caller, binding, options))
|
|
149
|
+
return;
|
|
150
|
+
throw new Error(`BINDING_MUTATION_SCOPE_VIOLATION: ${caller.role} ${caller.sessionKey} cannot mutate binding ${binding.bindingId} without explicit scope, target, reason, and audit`);
|
|
151
|
+
}
|
|
152
|
+
export function normalizeProfileOwner(owner) {
|
|
153
|
+
const normalized = owner?.trim().toLowerCase();
|
|
154
|
+
if (normalized && VALID_PROFILE_OWNERS.has(normalized)) {
|
|
155
|
+
return normalized;
|
|
156
|
+
}
|
|
157
|
+
return "unknown";
|
|
158
|
+
}
|
|
159
|
+
export function assertBindingProfileAllowed(identity, binding) {
|
|
160
|
+
if (identity.role !== "session_agent")
|
|
161
|
+
return;
|
|
162
|
+
if (binding.profileOwner === "agent")
|
|
163
|
+
return;
|
|
164
|
+
throw new Error(`PROFILE_OWNERSHIP_VIOLATION: binding profile ${binding.profileId} is ${binding.profileOwner}-owned and cannot be mutated by normal agents`);
|
|
165
|
+
}
|
|
166
|
+
function bindingStorePaths(storePath) {
|
|
167
|
+
const file = storePath ?? DEFAULT_BINDING_FILE;
|
|
168
|
+
return {
|
|
169
|
+
file,
|
|
170
|
+
dir: path.dirname(file),
|
|
171
|
+
lockDir: `${file}.lock`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function emptySnapshot() {
|
|
175
|
+
return {
|
|
176
|
+
version: 1,
|
|
177
|
+
bindings: {},
|
|
178
|
+
runBindingIndex: {},
|
|
179
|
+
updatedAt: nowIso(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async function sleep(ms) {
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
184
|
+
}
|
|
185
|
+
async function withFileLock(lockDir, fn) {
|
|
186
|
+
const startedAt = Date.now();
|
|
187
|
+
while (true) {
|
|
188
|
+
try {
|
|
189
|
+
await mkdir(lockDir);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
const code = err.code;
|
|
194
|
+
if (code !== "EEXIST" || Date.now() - startedAt > LOCK_TIMEOUT_MS)
|
|
195
|
+
throw err;
|
|
196
|
+
await sleep(LOCK_RETRY_MS);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return await fn();
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function readSnapshot(file) {
|
|
207
|
+
try {
|
|
208
|
+
const raw = await readFile(file, "utf-8");
|
|
209
|
+
const parsed = JSON.parse(raw);
|
|
210
|
+
return {
|
|
211
|
+
version: 1,
|
|
212
|
+
bindings: parsed.bindings ?? {},
|
|
213
|
+
runBindingIndex: parsed.runBindingIndex ?? {},
|
|
214
|
+
updatedAt: parsed.updatedAt ?? nowIso(),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
const code = err.code;
|
|
219
|
+
if (code === "ENOENT")
|
|
220
|
+
return emptySnapshot();
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function writeSnapshot(file, dir, snapshot) {
|
|
225
|
+
await mkdir(dir, { recursive: true });
|
|
226
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
|
|
227
|
+
try {
|
|
228
|
+
await writeFile(tmp, `${JSON.stringify(snapshot, null, 2)}\n`, "utf-8");
|
|
229
|
+
await rename(tmp, file);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
try {
|
|
233
|
+
await unlink(tmp);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* best-effort cleanup */
|
|
237
|
+
}
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function sameProjectThread(a, input) {
|
|
242
|
+
return (a.projectThreadId === input.projectThreadId &&
|
|
243
|
+
a.worktreePath === input.worktreePath &&
|
|
244
|
+
a.repoSlug === input.repoSlug);
|
|
245
|
+
}
|
|
246
|
+
function activeBindings(snapshot) {
|
|
247
|
+
return Object.values(snapshot.bindings).filter((binding) => binding.status === "active");
|
|
248
|
+
}
|
|
249
|
+
function rebuildRunBindingIndex(bindings) {
|
|
250
|
+
const updatedAt = nowIso();
|
|
251
|
+
const index = {};
|
|
252
|
+
for (const binding of Object.values(bindings)) {
|
|
253
|
+
for (const runId of binding.runIds) {
|
|
254
|
+
index[runId] = { runId, bindingId: binding.bindingId, updatedAt };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return index;
|
|
258
|
+
}
|
|
259
|
+
function applyRunIds(binding, runIds, index) {
|
|
260
|
+
binding.runIds = normalizeRunIds([...binding.runIds, ...runIds]);
|
|
261
|
+
const updatedAt = nowIso();
|
|
262
|
+
for (const runId of binding.runIds) {
|
|
263
|
+
index[runId] = { runId, bindingId: binding.bindingId, updatedAt };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function generateSidecarContextKey(input) {
|
|
267
|
+
return `sidecar:${input.repoSlug}:${input.projectThreadId}:${input.sessionKey}:${randomUUID()}`;
|
|
268
|
+
}
|
|
269
|
+
export class CodexWindowBindingStore {
|
|
270
|
+
file;
|
|
271
|
+
dir;
|
|
272
|
+
lockDir;
|
|
273
|
+
constructor(storePath) {
|
|
274
|
+
const paths = bindingStorePaths(storePath);
|
|
275
|
+
this.file = paths.file;
|
|
276
|
+
this.dir = paths.dir;
|
|
277
|
+
this.lockDir = paths.lockDir;
|
|
278
|
+
}
|
|
279
|
+
get path() {
|
|
280
|
+
return this.file;
|
|
281
|
+
}
|
|
282
|
+
async load() {
|
|
283
|
+
return readSnapshot(this.file);
|
|
284
|
+
}
|
|
285
|
+
async list() {
|
|
286
|
+
const snapshot = await this.load();
|
|
287
|
+
return Object.values(snapshot.bindings);
|
|
288
|
+
}
|
|
289
|
+
async get(bindingId) {
|
|
290
|
+
const snapshot = await this.load();
|
|
291
|
+
return snapshot.bindings[bindingId] ?? null;
|
|
292
|
+
}
|
|
293
|
+
async findActiveByIdentity(identity) {
|
|
294
|
+
const snapshot = await this.load();
|
|
295
|
+
return activeBindings(snapshot).find((binding) => sameProjectThread(binding, identity)) ?? null;
|
|
296
|
+
}
|
|
297
|
+
async findByRunId(runId) {
|
|
298
|
+
const snapshot = await this.load();
|
|
299
|
+
const indexEntry = snapshot.runBindingIndex[runId];
|
|
300
|
+
if (!indexEntry)
|
|
301
|
+
return null;
|
|
302
|
+
return snapshot.bindings[indexEntry.bindingId] ?? null;
|
|
303
|
+
}
|
|
304
|
+
async createOrReuse(input) {
|
|
305
|
+
return withFileLock(this.lockDir, async () => {
|
|
306
|
+
const snapshot = await readSnapshot(this.file);
|
|
307
|
+
const existing = activeBindings(snapshot).find((binding) => sameProjectThread(binding, input));
|
|
308
|
+
const runIds = normalizeRunIds(input.runIds);
|
|
309
|
+
const timestamp = nowIso();
|
|
310
|
+
if (existing) {
|
|
311
|
+
const repaired = existing.windowId !== input.windowId ||
|
|
312
|
+
existing.targetId !== (input.targetId ?? null) ||
|
|
313
|
+
existing.tabGroupId !== (input.tabGroupId ?? null);
|
|
314
|
+
existing.windowId = input.windowId;
|
|
315
|
+
existing.tabGroupId = input.tabGroupId ?? null;
|
|
316
|
+
existing.targetId = input.targetId ?? null;
|
|
317
|
+
existing.profileId = input.profileId ?? existing.profileId ?? "agent";
|
|
318
|
+
existing.profileAlias = input.profileAlias ?? existing.profileAlias ?? "oe";
|
|
319
|
+
existing.profileOwner = normalizeProfileOwner(input.profileOwner ?? existing.profileOwner);
|
|
320
|
+
existing.updatedAt = timestamp;
|
|
321
|
+
applyRunIds(existing, runIds, snapshot.runBindingIndex);
|
|
322
|
+
snapshot.updatedAt = timestamp;
|
|
323
|
+
this.assertNoActiveConflicts(snapshot);
|
|
324
|
+
await writeSnapshot(this.file, this.dir, snapshot);
|
|
325
|
+
return { binding: existing, action: repaired ? "repaired" : "reused" };
|
|
326
|
+
}
|
|
327
|
+
const conflictingWindowOwners = activeBindings(snapshot).filter((binding) => binding.windowId === input.windowId);
|
|
328
|
+
if (conflictingWindowOwners.length > 0) {
|
|
329
|
+
throw new WindowBindingConflictError(`Window ${input.windowId} is already owned by an active Codex binding`, conflictingWindowOwners);
|
|
330
|
+
}
|
|
331
|
+
if (input.sidecarContextKey &&
|
|
332
|
+
Object.values(snapshot.bindings).some((binding) => binding.sidecarContextKey === input.sidecarContextKey)) {
|
|
333
|
+
throw new WindowBindingConflictError(`Sidecar context ${input.sidecarContextKey} is already owned by an active Codex binding`, Object.values(snapshot.bindings).filter((binding) => binding.sidecarContextKey === input.sidecarContextKey));
|
|
334
|
+
}
|
|
335
|
+
const binding = {
|
|
336
|
+
bindingId: randomUUID(),
|
|
337
|
+
codexSessionId: input.codexSessionId,
|
|
338
|
+
projectThreadId: input.projectThreadId,
|
|
339
|
+
projectThreadFamily: input.projectThreadFamily,
|
|
340
|
+
worktreePath: input.worktreePath,
|
|
341
|
+
repoSlug: input.repoSlug,
|
|
342
|
+
branchName: input.branchName,
|
|
343
|
+
sessionKey: input.sessionKey,
|
|
344
|
+
role: input.role,
|
|
345
|
+
runIds,
|
|
346
|
+
windowId: input.windowId,
|
|
347
|
+
tabGroupId: input.tabGroupId ?? null,
|
|
348
|
+
targetId: input.targetId ?? null,
|
|
349
|
+
profileId: input.profileId ?? "agent",
|
|
350
|
+
profileAlias: input.profileAlias ?? "oe",
|
|
351
|
+
profileOwner: normalizeProfileOwner(input.profileOwner ?? "agent"),
|
|
352
|
+
sidecarContextKey: input.sidecarContextKey ?? generateSidecarContextKey(input),
|
|
353
|
+
status: "active",
|
|
354
|
+
createdAt: timestamp,
|
|
355
|
+
updatedAt: timestamp,
|
|
356
|
+
};
|
|
357
|
+
snapshot.bindings[binding.bindingId] = binding;
|
|
358
|
+
applyRunIds(binding, runIds, snapshot.runBindingIndex);
|
|
359
|
+
snapshot.updatedAt = timestamp;
|
|
360
|
+
this.assertNoActiveConflicts(snapshot);
|
|
361
|
+
await writeSnapshot(this.file, this.dir, snapshot);
|
|
362
|
+
return { binding, action: "created" };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
async addRunId(bindingId, runId) {
|
|
366
|
+
return withFileLock(this.lockDir, async () => {
|
|
367
|
+
const snapshot = await readSnapshot(this.file);
|
|
368
|
+
const binding = snapshot.bindings[bindingId];
|
|
369
|
+
if (!binding)
|
|
370
|
+
throw new Error(`Unknown bindingId: ${bindingId}`);
|
|
371
|
+
binding.updatedAt = nowIso();
|
|
372
|
+
applyRunIds(binding, [runId], snapshot.runBindingIndex);
|
|
373
|
+
snapshot.updatedAt = binding.updatedAt;
|
|
374
|
+
await writeSnapshot(this.file, this.dir, snapshot);
|
|
375
|
+
return binding;
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async transition(bindingId, status) {
|
|
379
|
+
return withFileLock(this.lockDir, async () => {
|
|
380
|
+
const snapshot = await readSnapshot(this.file);
|
|
381
|
+
const binding = snapshot.bindings[bindingId];
|
|
382
|
+
if (!binding)
|
|
383
|
+
throw new Error(`Unknown bindingId: ${bindingId}`);
|
|
384
|
+
binding.status = status;
|
|
385
|
+
binding.updatedAt = nowIso();
|
|
386
|
+
snapshot.updatedAt = binding.updatedAt;
|
|
387
|
+
await writeSnapshot(this.file, this.dir, snapshot);
|
|
388
|
+
return binding;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
async transitionByRunId(runId, status) {
|
|
392
|
+
return withFileLock(this.lockDir, async () => {
|
|
393
|
+
const snapshot = await readSnapshot(this.file);
|
|
394
|
+
const indexEntry = snapshot.runBindingIndex[runId];
|
|
395
|
+
if (!indexEntry)
|
|
396
|
+
return null;
|
|
397
|
+
const binding = snapshot.bindings[indexEntry.bindingId];
|
|
398
|
+
if (!binding)
|
|
399
|
+
return null;
|
|
400
|
+
binding.status = status;
|
|
401
|
+
binding.updatedAt = nowIso();
|
|
402
|
+
snapshot.runBindingIndex[runId] = {
|
|
403
|
+
runId,
|
|
404
|
+
bindingId: binding.bindingId,
|
|
405
|
+
updatedAt: binding.updatedAt,
|
|
406
|
+
};
|
|
407
|
+
snapshot.updatedAt = binding.updatedAt;
|
|
408
|
+
await writeSnapshot(this.file, this.dir, snapshot);
|
|
409
|
+
return binding;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async classifyConflicts() {
|
|
413
|
+
return withFileLock(this.lockDir, async () => {
|
|
414
|
+
const snapshot = await readSnapshot(this.file);
|
|
415
|
+
const conflicts = this.findActiveConflicts(snapshot);
|
|
416
|
+
const timestamp = nowIso();
|
|
417
|
+
for (const conflict of conflicts) {
|
|
418
|
+
conflict.status = "conflict";
|
|
419
|
+
conflict.updatedAt = timestamp;
|
|
420
|
+
}
|
|
421
|
+
if (conflicts.length > 0) {
|
|
422
|
+
snapshot.updatedAt = timestamp;
|
|
423
|
+
snapshot.runBindingIndex = rebuildRunBindingIndex(snapshot.bindings);
|
|
424
|
+
await writeSnapshot(this.file, this.dir, snapshot);
|
|
425
|
+
}
|
|
426
|
+
return conflicts;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async markStaleForMissingWindows(liveWindowIds) {
|
|
430
|
+
const live = new Set(liveWindowIds);
|
|
431
|
+
return withFileLock(this.lockDir, async () => {
|
|
432
|
+
const snapshot = await readSnapshot(this.file);
|
|
433
|
+
const stale = activeBindings(snapshot).filter((binding) => !live.has(binding.windowId));
|
|
434
|
+
const timestamp = nowIso();
|
|
435
|
+
for (const binding of stale) {
|
|
436
|
+
binding.status = "stale";
|
|
437
|
+
binding.updatedAt = timestamp;
|
|
438
|
+
}
|
|
439
|
+
if (stale.length > 0) {
|
|
440
|
+
snapshot.updatedAt = timestamp;
|
|
441
|
+
await writeSnapshot(this.file, this.dir, snapshot);
|
|
442
|
+
}
|
|
443
|
+
return stale;
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Reconcile every binding against the live window set and reap (delete) those
|
|
448
|
+
* whose window has been missing for at least {@link ReapOptions.ttlMs}.
|
|
449
|
+
*
|
|
450
|
+
* - Window live → clear `missingSince`, reactivate if the reaper marked it stale, retain.
|
|
451
|
+
* - Window missing, no `missingSince` → record `missingSince=now`, mark stale, retain (grace starts).
|
|
452
|
+
* - Window missing ≥ TTL, profile-owned → skip.
|
|
453
|
+
* - Window missing ≥ TTL, not owned → `archive()` then delete.
|
|
454
|
+
*
|
|
455
|
+
* Atomic + file-locked + idempotent. A no-op when nothing needs changing.
|
|
456
|
+
*/
|
|
457
|
+
async reapExpiredBindings(opts) {
|
|
458
|
+
const live = new Set(opts.liveWindowIds);
|
|
459
|
+
const now = opts.now ?? Date.now();
|
|
460
|
+
const ttlMs = opts.ttlMs;
|
|
461
|
+
const isOwned = opts.isProfileOwned ??
|
|
462
|
+
((b) => b.profileOwner != null && b.profileOwner !== "agent");
|
|
463
|
+
const result = {
|
|
464
|
+
evaluated: 0,
|
|
465
|
+
newlyMissing: 0,
|
|
466
|
+
retainedLive: 0,
|
|
467
|
+
reaped: 0,
|
|
468
|
+
skippedOwned: 0,
|
|
469
|
+
reapedBindingIds: [],
|
|
470
|
+
};
|
|
471
|
+
return withFileLock(this.lockDir, async () => {
|
|
472
|
+
const snapshot = await readSnapshot(this.file);
|
|
473
|
+
let mutated = false;
|
|
474
|
+
const reapCandidates = [];
|
|
475
|
+
for (const binding of Object.values(snapshot.bindings)) {
|
|
476
|
+
result.evaluated += 1;
|
|
477
|
+
if (live.has(binding.windowId)) {
|
|
478
|
+
if (binding.missingSince != null) {
|
|
479
|
+
delete binding.missingSince;
|
|
480
|
+
if (binding.status === "stale") {
|
|
481
|
+
binding.status = "active";
|
|
482
|
+
}
|
|
483
|
+
binding.updatedAt = nowIso();
|
|
484
|
+
mutated = true;
|
|
485
|
+
}
|
|
486
|
+
result.retainedLive += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
// Window is missing from the live set.
|
|
490
|
+
if (binding.missingSince == null) {
|
|
491
|
+
binding.missingSince = new Date(now).toISOString();
|
|
492
|
+
binding.status = "stale";
|
|
493
|
+
binding.updatedAt = nowIso();
|
|
494
|
+
mutated = true;
|
|
495
|
+
result.newlyMissing += 1;
|
|
496
|
+
continue; // retained this cycle; grace period starts
|
|
497
|
+
}
|
|
498
|
+
const missingForMs = now - Date.parse(binding.missingSince);
|
|
499
|
+
if (!Number.isFinite(missingForMs) || missingForMs < ttlMs) {
|
|
500
|
+
continue; // still within grace (or unparseable) → retain
|
|
501
|
+
}
|
|
502
|
+
if (isOwned(binding)) {
|
|
503
|
+
result.skippedOwned += 1;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
reapCandidates.push(binding);
|
|
507
|
+
}
|
|
508
|
+
for (const binding of reapCandidates) {
|
|
509
|
+
if (opts.archive) {
|
|
510
|
+
try {
|
|
511
|
+
await opts.archive(binding);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// Archive failed — do NOT delete (fail-safe, recoverable next cycle).
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
delete snapshot.bindings[binding.bindingId];
|
|
519
|
+
result.reaped += 1;
|
|
520
|
+
result.reapedBindingIds.push(binding.bindingId);
|
|
521
|
+
mutated = true;
|
|
522
|
+
}
|
|
523
|
+
if (mutated) {
|
|
524
|
+
snapshot.updatedAt = nowIso();
|
|
525
|
+
snapshot.runBindingIndex = rebuildRunBindingIndex(snapshot.bindings);
|
|
526
|
+
await writeSnapshot(this.file, this.dir, snapshot);
|
|
527
|
+
}
|
|
528
|
+
return result;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
assertNoActiveConflicts(snapshot) {
|
|
532
|
+
const conflicts = this.findActiveConflicts(snapshot);
|
|
533
|
+
if (conflicts.length > 0) {
|
|
534
|
+
throw new WindowBindingConflictError("Active Codex window binding conflict", conflicts);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
findActiveConflicts(snapshot) {
|
|
538
|
+
const active = activeBindings(snapshot);
|
|
539
|
+
const conflicted = new Set();
|
|
540
|
+
const byWindow = new Map();
|
|
541
|
+
const byProject = new Map();
|
|
542
|
+
for (const binding of active) {
|
|
543
|
+
const windowGroup = byWindow.get(binding.windowId) ?? [];
|
|
544
|
+
windowGroup.push(binding);
|
|
545
|
+
byWindow.set(binding.windowId, windowGroup);
|
|
546
|
+
const projectKey = `${binding.repoSlug}\0${binding.worktreePath}\0${binding.projectThreadId}`;
|
|
547
|
+
const projectGroup = byProject.get(projectKey) ?? [];
|
|
548
|
+
projectGroup.push(binding);
|
|
549
|
+
byProject.set(projectKey, projectGroup);
|
|
550
|
+
}
|
|
551
|
+
for (const group of [...byWindow.values(), ...byProject.values()]) {
|
|
552
|
+
if (group.length > 1) {
|
|
553
|
+
for (const binding of group)
|
|
554
|
+
conflicted.add(binding.bindingId);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return active.filter((binding) => conflicted.has(binding.bindingId));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
export const windowBindingStore = new CodexWindowBindingStore();
|
|
561
|
+
//# sourceMappingURL=window-bindings.js.map
|