chord-cli 0.1.2 → 0.1.3
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 +4 -2
- package/dist/cli.js +5 -2
- package/dist/hooks.js +2 -2
- package/dist/launch.js +11 -5
- package/dist/pty.js +16 -5
- package/dist/tools.js +42 -10
- package/dist/trust.js +62 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ One repository, as many agents as you care to start. Each agent works in its own
|
|
|
22
22
|
- One repository, N agents, one worktree each: parallel work without stepping on each other
|
|
23
23
|
- A file-level claims board: register before editing, talk to the holder first, and a hook turns away any write that was never claimed
|
|
24
24
|
- Asynchronous messaging between agents: idle ones are woken, busy ones hear at their next tool call
|
|
25
|
-
- `finish` in one step: rebase, fast-forward main, release the claims, tell the others; a conflict
|
|
25
|
+
- `finish` in one step: rebase, fast-forward main, release the claims, tell the others; on a conflict nothing changes and the agent is told to rebase, resolve it and finish again, asking you only if it cannot
|
|
26
26
|
- Agents learn the rules from chord itself, at start-up and on every prompt; you give tasks, not instructions about chord
|
|
27
27
|
- Agent-agnostic by design: adding a tool means one launch recipe, not a new coordinator
|
|
28
28
|
- `chord ls` for the whole picture, `messages` and `events` to replay what happened, `stop` and `clean` to wind down
|
|
@@ -118,11 +118,13 @@ chord clean # remove the workspaces and branches of agents that hav
|
|
|
118
118
|
`<project>/chord/config.json`:
|
|
119
119
|
|
|
120
120
|
```json
|
|
121
|
-
{ "setup": "npm ci" }
|
|
121
|
+
{ "setup": "npm ci", "test": "npm test" }
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
`setup` runs once in every new worktree before the agent starts, with the `CHORD_*` variables in its environment, so a fresh worktree arrives with its dependencies in place. A failed setup abandons the launch and removes the worktree.
|
|
125
125
|
|
|
126
|
+
`test`, when set, runs in the agent's worktree each time `finish` has rebased the branch onto main, before main moves. If it fails nothing is merged and the agent gets the end of the output: a clean rebase can still break what another agent merged. Other agents' `finish` waits while it runs.
|
|
127
|
+
|
|
126
128
|
A repository's own Claude configuration (`.claude/settings*.json`, `.mcp.json`) reaches an agent once you have trusted that repository in Claude Code yourself; the MCP servers you approved there are the ones that start.
|
|
127
129
|
|
|
128
130
|
## 🔌 How chord works with your tools
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import * as db from "./db.js";
|
|
|
15
15
|
import { mergeUserSettings, prepare } from "./launch.js";
|
|
16
16
|
import { NeedsRestructure, knownProjects, resolveProject, restructure, restructureBlocker, validateRepoName } from "./project.js";
|
|
17
17
|
import { resolveCommand, run } from "./pty.js";
|
|
18
|
-
import { disabledCodexHooks, pretrustClaude } from "./trust.js";
|
|
18
|
+
import { codexFirstStart, disabledCodexHooks, pretrustClaude } from "./trust.js";
|
|
19
19
|
const USAGE = 'usage: chord <claude|codex> [--name <agent>] [tool args...]\n chord ls [--all]\n chord messages [agent] [-n N]\n chord events [agent] [-n N]\n chord stop <agent>\n chord clean [--force] [agent...]\n';
|
|
20
20
|
const gitOut = (cwd, ...args) => {
|
|
21
21
|
try {
|
|
@@ -315,7 +315,10 @@ async function main(argv) {
|
|
|
315
315
|
if (off.length)
|
|
316
316
|
process.stderr.write(`chord: Codex has chord's ${off.join(', ')} hook(s) switched off in ~/.codex/config.toml, so the matching chord features will not work. Remove the "enabled = false" line(s) under the [hooks.state.'...<session-flags>...'] tables to switch them back on.\n`);
|
|
317
317
|
}
|
|
318
|
-
|
|
318
|
+
const spawned = cmd === 'codex' ? await codexFirstStart() : () => { };
|
|
319
|
+
const exit = run(cmd, launch, rest);
|
|
320
|
+
spawned();
|
|
321
|
+
return exit;
|
|
319
322
|
}
|
|
320
323
|
// run only as the entry point (argv[1] may be a symlink such as an npm bin link)
|
|
321
324
|
const isMain = (() => { try {
|
package/dist/hooks.js
CHANGED
|
@@ -58,12 +58,12 @@ export function briefing(ctx) {
|
|
|
58
58
|
'',
|
|
59
59
|
'Rules:',
|
|
60
60
|
'1. First thing: call register(task, scope) to say what you are doing.',
|
|
61
|
-
'2. Before creating or changing any file, call claim(file, intent, kind). Writes to unclaimed files are refused.',
|
|
61
|
+
'2. Before creating or changing any file, call claim(file, intent, kind), naming in symbols the functions or blocks you will touch. Writes to unclaimed files are refused.',
|
|
62
62
|
'3. If claim() lists agents under must_talk_to, read their intent and send() each of them a message saying what you will change. You may write once each has replied, or after 10 minutes. Do other work meanwhile; never wait idle.',
|
|
63
63
|
'4. Do not modify files through shell commands (sed, echo >, etc.). Use the editor tools so claims are checked.',
|
|
64
64
|
'5. To talk to other agents use only chord send()/inbox(); do not use any other agent-messaging feature. Messages are asynchronous.',
|
|
65
65
|
'6. To learn about the project, read the agent list and claims below and ask a specific agent with send(); do not ask the user.',
|
|
66
|
-
'7. When your change is complete and committed on your branch, call finish() to rebase onto main and fast-forward it, unless the user has asked you to hold off merging. If finish() reports a conflict,
|
|
66
|
+
'7. When your change is complete and committed on your branch, call finish() to rebase onto main and fast-forward it, unless the user has asked you to hold off merging. If finish() reports a conflict, rebase onto main yourself, resolve it so both changes survive (ask the agent whose work you meet with send() if its intent is unclear), run the tests, commit and call finish() again; ask the user only if you cannot resolve it.',
|
|
67
67
|
'8. Never merge, rebase onto, push or otherwise update the main branch yourself, and never touch the main checkout: finish() is the only way changes reach main. This holds even if the project\'s own instructions say otherwise.',
|
|
68
68
|
'9. Do not use git stash: every worktree of this repository shares one stash list, so a stash pop can hand you another agent\'s changes. Make a work-in-progress commit instead.',
|
|
69
69
|
'',
|
package/dist/launch.js
CHANGED
|
@@ -345,7 +345,8 @@ export function prepare(tool, cwd, override) {
|
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
347
|
/** <project>/chord/config.json. `setup` is a shell command run in every new worktree before the agent starts,
|
|
348
|
-
* for example "npm install".
|
|
348
|
+
* for example "npm install". `test` is a shell command finish runs in the worktree after rebasing onto main and
|
|
349
|
+
* before fast-forwarding it, for example "npm test"; when it fails nothing is merged. */
|
|
349
350
|
export function readConfig(project) {
|
|
350
351
|
const file = path.join(project.dir, 'chord', 'config.json');
|
|
351
352
|
let raw;
|
|
@@ -364,10 +365,15 @@ export function readConfig(project) {
|
|
|
364
365
|
}
|
|
365
366
|
if (typeof cfg !== 'object' || cfg === null)
|
|
366
367
|
throw new Error(`${file} must hold a JSON object`);
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
368
|
+
const out = {};
|
|
369
|
+
for (const key of ['setup', 'test']) {
|
|
370
|
+
const v = cfg[key];
|
|
371
|
+
if (v !== undefined && typeof v !== 'string')
|
|
372
|
+
throw new Error(`${file}: "${key}" must be a string (a shell command)`);
|
|
373
|
+
if (v)
|
|
374
|
+
out[key] = v;
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
371
377
|
}
|
|
372
378
|
/** Where `chord clean` and a relaunch look to see whether the setup command has run for this agent. */
|
|
373
379
|
export const setupMarker = (project, name) => path.join(project.stateDir, 'setup-done', name);
|
package/dist/pty.js
CHANGED
|
@@ -194,6 +194,17 @@ export function oscState(chunk) {
|
|
|
194
194
|
last = x[1] === 'idle' ? 'idle' : 'busy';
|
|
195
195
|
return last;
|
|
196
196
|
}
|
|
197
|
+
/** A database call from the wrapper's own callbacks. One that fails (a lock held past busy_timeout while the disk
|
|
198
|
+
* stalls) is logged and dropped: thrown from a timer or a PTY callback it would end the wrapper and the agent in
|
|
199
|
+
* it, and the next tick or terminal report puts the state right anyway. */
|
|
200
|
+
function tolerant(what, f) {
|
|
201
|
+
try {
|
|
202
|
+
f();
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
db.log(`${what}: ${e.message}; skipped`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
197
208
|
export function run(tool, launch, extraArgs) {
|
|
198
209
|
const { file, args } = resolveCommand(tool);
|
|
199
210
|
const env = withNodeOnPath({ ...process.env, ...launch.env });
|
|
@@ -216,7 +227,7 @@ export function run(tool, launch, extraArgs) {
|
|
|
216
227
|
if (tool === 'codex') { // the hooks set status too; the terminal report catches turns the hooks miss (Esc, untrusted hooks)
|
|
217
228
|
const state = oscState(outTail + x);
|
|
218
229
|
if (state)
|
|
219
|
-
db.setStatus(d, launch.name, state);
|
|
230
|
+
tolerant('terminal state report', () => db.setStatus(d, launch.name, state));
|
|
220
231
|
outTail = x.slice(-64);
|
|
221
232
|
}
|
|
222
233
|
});
|
|
@@ -233,7 +244,7 @@ export function run(tool, launch, extraArgs) {
|
|
|
233
244
|
process.stdout.on('resize', onResize);
|
|
234
245
|
Object.assign(process.env, launch.env); // so the wrapper writes to the same chord.db.log as the hooks
|
|
235
246
|
let typed = null, typedAt = 0, enteredAt = 0, enters = 0, echoed = false, lastState = '';
|
|
236
|
-
const timer = setInterval(() => {
|
|
247
|
+
const timer = setInterval(() => tolerant('wake loop', () => {
|
|
237
248
|
db.heartbeat(d, launch.name);
|
|
238
249
|
const me = db.getAgent(d, launch.name);
|
|
239
250
|
const wanted = titleFor(launch.name, me?.task);
|
|
@@ -287,7 +298,7 @@ export function run(tool, launch, extraArgs) {
|
|
|
287
298
|
enteredAt = Date.now();
|
|
288
299
|
enters = 1;
|
|
289
300
|
}, 1500);
|
|
290
|
-
}, 1000);
|
|
301
|
+
}), 1000);
|
|
291
302
|
return new Promise(resolve => {
|
|
292
303
|
term.onExit(({ exitCode }) => {
|
|
293
304
|
clearInterval(timer);
|
|
@@ -296,8 +307,8 @@ export function run(tool, launch, extraArgs) {
|
|
|
296
307
|
if (process.stdin.isTTY)
|
|
297
308
|
process.stdin.setRawMode(false);
|
|
298
309
|
process.stdin.pause();
|
|
299
|
-
db.setStatus(d, launch.name, 'offline');
|
|
300
|
-
db.releaseAll(d, launch.name, 'exit');
|
|
310
|
+
tolerant('exit', () => db.setStatus(d, launch.name, 'offline'));
|
|
311
|
+
tolerant('exit', () => db.releaseAll(d, launch.name, 'exit')); // missed here, the next sweep releases them
|
|
301
312
|
resolve(exitCode);
|
|
302
313
|
});
|
|
303
314
|
});
|
package/dist/tools.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as db from "./db.js";
|
|
5
|
+
import { readConfig } from "./launch.js";
|
|
5
6
|
/** Is `file` inside `dir`? False for paths on another drive or above it. */
|
|
6
7
|
export function insideDir(dir, file) {
|
|
7
8
|
const rel = path.relative(dir, path.resolve(dir, file));
|
|
@@ -54,10 +55,23 @@ export function peers(ctx) {
|
|
|
54
55
|
name: a.name, tool: a.tool, status: a.status, cwd: a.cwd, task: a.task, scope: a.scope,
|
|
55
56
|
}));
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
+
// words that describe code rather than name it: "the parse function" and "parse tests" share only "parse"
|
|
59
|
+
const NOT_SYMBOLS = new Set(['a', 'an', 'and', 'or', 'the', 'of', 'in', 'to', 'for', 'with', 'new', 'its', 'all', 'function', 'functions', 'method', 'methods', 'class', 'classes', 'test', 'tests', 'block', 'blocks', 'section', 'line', 'lines', 'file', 'helper', 'helpers', 'code', 'logic', 'docs', 'docstring']);
|
|
60
|
+
const symbolSet = (s) => new Set((s ?? '').toLowerCase().match(/[a-z_$][\w$.]*/g)?.filter(w => !NOT_SYMBOLS.has(w)) ?? []);
|
|
61
|
+
/** Two modify claims on one file that each name the functions or blocks they touch, with none in common, do not
|
|
62
|
+
* need to talk first. Anything less certain (no symbols on either side, or an add, delete or rename) does. */
|
|
63
|
+
export function disjointEdits(a, b) {
|
|
64
|
+
if (a.kind !== 'modify' || b.kind !== 'modify')
|
|
65
|
+
return false;
|
|
66
|
+
const x = symbolSet(a.symbols), y = symbolSet(b.symbols);
|
|
67
|
+
return x.size > 0 && y.size > 0 && ![...x].some(w => y.has(w));
|
|
68
|
+
}
|
|
69
|
+
/** Online agents who claimed `file` before `mine` did, split into those `mine` overlaps and must talk to and those
|
|
70
|
+
* editing other parts of it. */
|
|
58
71
|
function earlierOccupants(ctx, mine) {
|
|
59
72
|
const before = (c) => c.created_at < mine.created_at || (c.created_at === mine.created_at && c.rid < mine.rid);
|
|
60
|
-
|
|
73
|
+
const all = db.claimsFor(ctx.db, mine.file).filter(c => c.agent !== ctx.agent && before(c) && db.getAgent(ctx.db, c.agent)?.status !== 'offline');
|
|
74
|
+
return { overlap: all.filter(c => !disjointEdits(c, mine)), elsewhere: all.filter(c => disjointEdits(c, mine)) };
|
|
61
75
|
}
|
|
62
76
|
/**
|
|
63
77
|
* May `ctx.agent` write `file` now? It must hold a claim, and if others claimed the file first it must
|
|
@@ -70,7 +84,7 @@ export function canWrite(ctx, file, now = Date.now()) {
|
|
|
70
84
|
const mine = db.claimsFor(ctx.db, rel).find(c => c.agent === ctx.agent);
|
|
71
85
|
if (!mine)
|
|
72
86
|
return { ok: false, reason: `${rel} is not claimed by you. Call claim(file, intent, kind) first.` };
|
|
73
|
-
const others = earlierOccupants(ctx, mine);
|
|
87
|
+
const others = earlierOccupants(ctx, mine).overlap;
|
|
74
88
|
if (!others.length)
|
|
75
89
|
return { ok: true };
|
|
76
90
|
const myStart = db.getAgent(ctx.db, ctx.agent)?.started_at ?? mine.created_at;
|
|
@@ -230,7 +244,23 @@ function finishLocked(ctx) {
|
|
|
230
244
|
throw new Error(`rebase onto ${main} failed (not a conflict); nothing was merged.\n${stderrOf(e)}`);
|
|
231
245
|
db.log(`finish: rebase of ${branch} onto ${main} conflicted; aborted`);
|
|
232
246
|
db.addEvent(ctx.db, ctx.agent, 'conflict', null, `rebase of ${branch} onto ${main} conflicted; aborted, nothing merged`);
|
|
233
|
-
throw new Error(`rebase onto ${main} failed; nothing was merged.
|
|
247
|
+
throw new Error(`rebase onto ${main} failed; nothing was merged. Run \`git rebase ${main}\` in your worktree, resolve the conflicts so both changes survive, run the tests, then call finish again; ask the user only if you cannot resolve them.\n${stderrOf(e)}`);
|
|
248
|
+
}
|
|
249
|
+
// The project's tests, on the branch as it now sits on top of main: a clean rebase can still break what another
|
|
250
|
+
// agent merged. ponytail: they run inside the finish lock, so other agents' finish waits for them (withLock gives
|
|
251
|
+
// up after 240 s); run them before taking the lock and re-check main if test suites get long.
|
|
252
|
+
const test = ctx.project ? readConfig({ dir: ctx.project }).test : undefined;
|
|
253
|
+
if (test) {
|
|
254
|
+
db.log(`finish: running the test command in ${wt}: ${test}`);
|
|
255
|
+
try {
|
|
256
|
+
execSync(test, { cwd: wt, stdio: 'pipe', timeout: 600_000, maxBuffer: 64 << 20 });
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
const x = e;
|
|
260
|
+
const out = `${x.stdout ?? ''}${x.stderr ?? ''}`.trimEnd().split('\n').slice(-40).join('\n');
|
|
261
|
+
db.log(`finish: the test command failed${x.status != null ? ` with exit code ${x.status}` : ''}; nothing merged`);
|
|
262
|
+
throw new Error(`your branch is now rebased onto ${main}, but the project's test command failed there, so nothing was merged: ${test}\n${out}\nFix it (another agent's merged work may be what broke), commit, and call finish again.`);
|
|
263
|
+
}
|
|
234
264
|
}
|
|
235
265
|
const subjects = git(root, 'log', '--format=%s', `${main}..${branch}`).split('\n').filter(Boolean);
|
|
236
266
|
// A submodule commit the branch points at must exist in the main checkout's copy of the submodule, or the
|
|
@@ -309,7 +339,7 @@ export function tools(ctx) {
|
|
|
309
339
|
file: str('The file, as a path from your working directory (e.g. <repo>/src/x.ts) or relative to the repository root (src/x.ts)'),
|
|
310
340
|
intent: str('One sentence: what you will change and why'),
|
|
311
341
|
kind: { type: 'string', enum: ['add', 'modify', 'delete', 'rename'] },
|
|
312
|
-
symbols: str('Optional: functions or blocks you will touch'),
|
|
342
|
+
symbols: str('Optional: the functions or blocks you will touch, comma-separated. When you and an earlier holder of the file both name them and none are shared, you need not talk first.'),
|
|
313
343
|
},
|
|
314
344
|
required: ['file', 'intent', 'kind'],
|
|
315
345
|
},
|
|
@@ -320,11 +350,13 @@ export function tools(ctx) {
|
|
|
320
350
|
throw new Error('kind must be add, modify, delete or rename');
|
|
321
351
|
db.addClaim(ctx.db, { agent: ctx.agent, file, intent: req(a, 'intent'), kind, symbols: typeof a.symbols === 'string' ? a.symbols : null });
|
|
322
352
|
const mine = db.claimsFor(ctx.db, file).find(c => c.agent === ctx.agent);
|
|
323
|
-
const others = earlierOccupants(ctx, mine);
|
|
324
|
-
db.log(`claim ${file} by ${ctx.agent} (cwd ${ctx.cwd}): rows=${JSON.stringify(db.claimsFor(ctx.db, file).map(c => [c.agent, c.created_at]))} agents=${JSON.stringify(db.listAgents(ctx.db).map(a => [a.name, a.status, a.pid]))} others=${others.map(o => o.agent)}`);
|
|
353
|
+
const { overlap: others, elsewhere } = earlierOccupants(ctx, mine);
|
|
354
|
+
db.log(`claim ${file} by ${ctx.agent} (cwd ${ctx.cwd}): rows=${JSON.stringify(db.claimsFor(ctx.db, file).map(c => [c.agent, c.created_at]))} agents=${JSON.stringify(db.listAgents(ctx.db).map(a => [a.name, a.status, a.pid]))} others=${others.map(o => o.agent)} elsewhere=${elsewhere.map(o => o.agent)}`);
|
|
355
|
+
const brief = (o) => ({ agent: o.agent, intent: o.intent, kind: o.kind, symbols: o.symbols });
|
|
325
356
|
return {
|
|
326
357
|
claimed: file,
|
|
327
|
-
must_talk_to: others.map(
|
|
358
|
+
must_talk_to: others.map(brief),
|
|
359
|
+
...(elsewhere.length ? { also_editing: elsewhere.map(brief) } : {}),
|
|
328
360
|
...(others.length ? { next: 'Send each agent above a message describing your change. You may write once each has replied, or after 10 minutes.' } : {}),
|
|
329
361
|
};
|
|
330
362
|
},
|
|
@@ -366,7 +398,7 @@ export function tools(ctx) {
|
|
|
366
398
|
},
|
|
367
399
|
{
|
|
368
400
|
name: 'finish',
|
|
369
|
-
description: 'Your work is committed on your branch: rebase it onto main, fast-forward main, release all your claims and tell the other agents. On a rebase conflict nothing is changed;
|
|
401
|
+
description: 'Your work is committed on your branch: rebase it onto main, fast-forward main, release all your claims and tell the other agents. On a rebase conflict nothing is changed; rebase onto main yourself, resolve it, then call finish again.',
|
|
370
402
|
inputSchema: { type: 'object', properties: {} },
|
|
371
403
|
handler: () => finish(ctx),
|
|
372
404
|
},
|
package/dist/trust.js
CHANGED
|
@@ -88,3 +88,65 @@ export function disabledCodexHooks(file = codexConfigPath()) {
|
|
|
88
88
|
}
|
|
89
89
|
return off;
|
|
90
90
|
}
|
|
91
|
+
const hasCodexState = (home) => { try {
|
|
92
|
+
return fs.readdirSync(home).some(f => /^state_\d+\.sqlite$/.test(f));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
} };
|
|
97
|
+
/** Codex creates and migrates its state database (~/.codex/state_N.sqlite) on its first start, and when two start at
|
|
98
|
+
* once the second exits with "local database appears to be damaged". Until that database exists, launches take
|
|
99
|
+
* turns: the first holds a lock while its Codex creates it, the others wait for the lock. Call the returned
|
|
100
|
+
* function once Codex has been spawned; it lets go of the lock when the database is there, or after a minute. */
|
|
101
|
+
export async function codexFirstStart(home = path.join(os.homedir(), '.codex')) {
|
|
102
|
+
if (hasCodexState(home))
|
|
103
|
+
return () => { };
|
|
104
|
+
const lock = path.join(home, '.chord-first-start');
|
|
105
|
+
const t0 = Date.now();
|
|
106
|
+
try {
|
|
107
|
+
fs.mkdirSync(home, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return () => { };
|
|
111
|
+
}
|
|
112
|
+
for (;;) {
|
|
113
|
+
try {
|
|
114
|
+
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (e.code !== 'EEXIST')
|
|
119
|
+
return () => { };
|
|
120
|
+
try {
|
|
121
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 60_000) {
|
|
122
|
+
fs.unlinkSync(lock);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (Date.now() - t0 > 90_000)
|
|
130
|
+
return () => { }; // a launch that never lets go must not hold this one up for good
|
|
131
|
+
await new Promise(r => setTimeout(r, 250));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const unlock = () => { try {
|
|
135
|
+
fs.unlinkSync(lock);
|
|
136
|
+
}
|
|
137
|
+
catch { /* gone */ } };
|
|
138
|
+
if (hasCodexState(home)) {
|
|
139
|
+
unlock();
|
|
140
|
+
return () => { };
|
|
141
|
+
} // the launch before this one created it
|
|
142
|
+
return () => {
|
|
143
|
+
const started = Date.now();
|
|
144
|
+
const poll = setInterval(() => {
|
|
145
|
+
if (!hasCodexState(home) && Date.now() - started < 60_000)
|
|
146
|
+
return;
|
|
147
|
+
clearInterval(poll);
|
|
148
|
+
setTimeout(unlock, 2000).unref(); // the file appears before its migrations have run
|
|
149
|
+
}, 250);
|
|
150
|
+
poll.unref();
|
|
151
|
+
};
|
|
152
|
+
}
|