@lyupro/codex-bridge 0.3.1 → 0.4.0
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/CHANGELOG.md +48 -0
- package/README.md +19 -6
- package/bin/codex-bridge.mjs +27 -4
- package/cli/doctor.mjs +18 -1
- package/cli/install.mjs +8 -2
- package/cli/manifest.mjs +20 -9
- package/cli/permissions.mjs +179 -0
- package/cli/prune-args.mjs +1 -1
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +93 -45
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +23 -10
- package/cli/unlock.mjs +239 -0
- package/package.json +1 -1
- package/scripts/check-file-size.mjs +8 -10
- package/src/agents/codex-build.md +2 -0
- package/src/agents/codex-review.md +2 -0
- package/src/agents/codex-scout.md +2 -0
- package/src/heartbeat.mjs +1 -1
- package/src/hooks/live-runs.mjs +15 -14
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/reply-guard.mjs +4 -3
- package/src/json-file.mjs +30 -0
- package/src/meta/chain.mjs +8 -6
- package/src/meta/paths.mjs +5 -9
- package/src/meta/pre-start.mjs +30 -0
- package/src/meta/run-state.mjs +31 -16
- package/src/process-identity.mjs +140 -0
- package/src/required-inputs.mjs +42 -0
- package/src/run-config.mjs +4 -8
- package/src/runner/args.mjs +16 -2
- package/src/runner/attach.mjs +23 -17
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/launcher.mjs +40 -16
- package/src/runner/project-dir.mjs +4 -3
- package/src/runner/scope-check.mjs +159 -0
- package/src/runner/worker.mjs +13 -5
- package/src/write-meta.mjs +8 -1
- package/cli/sweep.mjs +0 -144
package/cli/settings-merge.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** Merges and removes named codex-bridge hooks without disturbing host settings. */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
3
|
import fs from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { readJsonFileWithRaw } from '../src/json-file.mjs';
|
|
5
7
|
|
|
6
8
|
const LEGACY_SPEC = { event: 'SubagentStop', matcher: '*' };
|
|
9
|
+
const settingsRuns = new AsyncLocalStorage();
|
|
7
10
|
|
|
8
11
|
export function commandFor(guardPath) {
|
|
9
12
|
return `node "${path.resolve(guardPath)}"`;
|
|
@@ -26,18 +29,13 @@ function normalizeSpec(specOrPath) {
|
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
async function readSettings(settingsPath) {
|
|
32
|
+
export async function readSettings(settingsPath) {
|
|
30
33
|
try {
|
|
31
|
-
const raw = await
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
35
|
-
throw new Error('top level must be an object');
|
|
36
|
-
}
|
|
37
|
-
return { exists: true, raw, settings: parsed };
|
|
38
|
-
} catch (err) {
|
|
39
|
-
throw new Error(`cannot parse ${settingsPath}: ${err.message}`);
|
|
34
|
+
const { raw, value: settings } = await readJsonFileWithRaw(settingsPath);
|
|
35
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
36
|
+
throw new Error(`cannot parse ${settingsPath}: top level must be an object`);
|
|
40
37
|
}
|
|
38
|
+
return { exists: true, raw, settings };
|
|
41
39
|
} catch (err) {
|
|
42
40
|
if (err.code === 'ENOENT') return { exists: false, raw: null, settings: {} };
|
|
43
41
|
throw err;
|
|
@@ -83,9 +81,38 @@ function backupName(settingsPath) {
|
|
|
83
81
|
return `${settingsPath}.codex-bridge-backup-${stamp}-${randomUUID()}`;
|
|
84
82
|
}
|
|
85
83
|
|
|
84
|
+
function runKey(settingsPath) {
|
|
85
|
+
return path.resolve(settingsPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A command gets one in-memory backup scope. AsyncLocalStorage keeps concurrent commands separate
|
|
90
|
+
* in one process, while nested install calls reuse their caller's scope (Plan_24).
|
|
91
|
+
*/
|
|
92
|
+
export async function withSettingsRun(settingsPath, action) {
|
|
93
|
+
if (typeof action !== 'function') throw new TypeError('settings run action must be a function');
|
|
94
|
+
const key = runKey(settingsPath);
|
|
95
|
+
const active = settingsRuns.getStore();
|
|
96
|
+
if (active?.key === key) return action();
|
|
97
|
+
return settingsRuns.run({ key, backupTaken: false }, action);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function withMutationRun(settingsPath, action) {
|
|
101
|
+
const active = settingsRuns.getStore();
|
|
102
|
+
return active?.key === runKey(settingsPath)
|
|
103
|
+
? action()
|
|
104
|
+
: withSettingsRun(settingsPath, action);
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
async function atomicWrite(settingsPath, settings, previous) {
|
|
87
108
|
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
88
|
-
|
|
109
|
+
const active = settingsRuns.getStore();
|
|
110
|
+
const ownsScope = active?.key === runKey(settingsPath);
|
|
111
|
+
const shouldBackup = !ownsScope || !active.backupTaken;
|
|
112
|
+
if (ownsScope) active.backupTaken = true;
|
|
113
|
+
if (shouldBackup && previous.exists) {
|
|
114
|
+
await fs.writeFile(backupName(settingsPath), previous.raw, { flag: 'wx' });
|
|
115
|
+
}
|
|
89
116
|
const temporary = `${settingsPath}.${randomUUID()}.tmp`;
|
|
90
117
|
try {
|
|
91
118
|
await fs.writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { flag: 'wx' });
|
|
@@ -96,42 +123,63 @@ async function atomicWrite(settingsPath, settings, previous) {
|
|
|
96
123
|
}
|
|
97
124
|
}
|
|
98
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Runs a settings mutation through the hook writer's backup and atomic-write boundary.
|
|
128
|
+
* Plan_22 requires permissions to preserve the same recovery guarantee after the settings merge
|
|
129
|
+
* incident: a second writer must not grow its own backup or serialization path.
|
|
130
|
+
*/
|
|
131
|
+
export async function updateSettings(settingsPath, edit) {
|
|
132
|
+
if (typeof edit !== 'function') throw new TypeError('settings edit must be a function');
|
|
133
|
+
return withMutationRun(settingsPath, async () => {
|
|
134
|
+
const previous = await readSettings(settingsPath);
|
|
135
|
+
const settings = structuredClone(previous.settings);
|
|
136
|
+
const result = await edit(settings);
|
|
137
|
+
if (!result?.changed) return result || { changed: false };
|
|
138
|
+
await atomicWrite(settingsPath, settings, previous);
|
|
139
|
+
return result;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
99
143
|
export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
group
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
144
|
+
return withMutationRun(settingsPath, async () => {
|
|
145
|
+
const spec = normalizeSpec(specOrPath);
|
|
146
|
+
const state = inspected || await inspectHook(settingsPath, spec);
|
|
147
|
+
if (state.present) return { changed: false, createdGroup: false };
|
|
148
|
+
const settings = structuredClone(state.settings);
|
|
149
|
+
settings.hooks ??= {};
|
|
150
|
+
if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
|
|
151
|
+
let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
|
|
152
|
+
const createdGroup = !group;
|
|
153
|
+
if (!group) {
|
|
154
|
+
group = { matcher: spec.matcher, hooks: [] };
|
|
155
|
+
settings.hooks[spec.event].push(group);
|
|
156
|
+
}
|
|
157
|
+
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
158
|
+
group.hooks.push(ownHook(spec.command));
|
|
159
|
+
await atomicWrite(settingsPath, settings, state);
|
|
160
|
+
return { changed: true, createdGroup };
|
|
161
|
+
});
|
|
116
162
|
}
|
|
117
163
|
|
|
118
164
|
export async function removeHook(settingsPath, specOrPath, { createdGroup = false } = {}) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
165
|
+
return withMutationRun(settingsPath, async () => {
|
|
166
|
+
const spec = normalizeSpec(specOrPath);
|
|
167
|
+
const state = await inspectHook(settingsPath, spec);
|
|
168
|
+
if (!state.present) return { changed: false };
|
|
169
|
+
const settings = structuredClone(state.settings);
|
|
170
|
+
const eventGroups = groups(settings, spec.event);
|
|
171
|
+
// Removal follows the same rule as the lookup above: our command, whatever matcher it is
|
|
172
|
+
// filed under. A foreign hook is left alone because its command is not ours, not because it
|
|
173
|
+
// sits in another group.
|
|
174
|
+
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
175
|
+
const group = eventGroups[index];
|
|
176
|
+
if (!hasOwnCommand(group, spec.command)) continue;
|
|
177
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
178
|
+
group.hooks = group.hooks.filter((hook) =>
|
|
179
|
+
!(hook?.type === 'command' && hook.command === spec.command));
|
|
180
|
+
if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
181
|
+
}
|
|
182
|
+
await atomicWrite(settingsPath, settings, state);
|
|
183
|
+
return { changed: true };
|
|
184
|
+
});
|
|
137
185
|
}
|
package/cli/stop.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
/** Stops a live run and records the same abandoned FAIL artifacts as the normal
|
|
1
|
+
/** Stops a live run and records the same abandoned FAIL artifacts as the normal unlock. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { stopCodex } from '../src/runner/codex-cmd.mjs';
|
|
4
4
|
import { git, worktreeSnapshot } from '../src/runner/git-state.mjs';
|
|
5
|
+
import {
|
|
6
|
+
IDENTITY_ALIVE,
|
|
7
|
+
IDENTITY_FOREIGN,
|
|
8
|
+
IDENTITY_UNVERIFIED,
|
|
9
|
+
processAlive,
|
|
10
|
+
processIdentity,
|
|
11
|
+
} from '../src/process-identity.mjs';
|
|
5
12
|
import { markAbandoned, readJson } from '../src/write-meta.mjs';
|
|
6
13
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
7
14
|
import { resolveRunFolder } from './run-lookup.mjs';
|
|
@@ -32,27 +39,22 @@ function processHandle(pid) {
|
|
|
32
39
|
};
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
function alive(pid) {
|
|
36
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
37
|
-
try {
|
|
38
|
-
process.kill(pid, 0);
|
|
39
|
-
return true;
|
|
40
|
-
} catch (err) {
|
|
41
|
-
return err.code === 'EPERM';
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
42
|
async function waitForExit(pid) {
|
|
46
43
|
const deadline = Date.now() + EXIT_WAIT_MS;
|
|
47
|
-
while (
|
|
48
|
-
return !
|
|
44
|
+
while (processAlive(pid) && Date.now() < deadline) await sleep(EXIT_POLL_MS);
|
|
45
|
+
return !processAlive(pid);
|
|
49
46
|
}
|
|
50
47
|
|
|
51
48
|
function result(exitCode, output) {
|
|
52
49
|
return { exitCode, output };
|
|
53
50
|
}
|
|
54
51
|
|
|
55
|
-
export async function stop({
|
|
52
|
+
export async function stop({
|
|
53
|
+
run,
|
|
54
|
+
cwd = process.cwd(),
|
|
55
|
+
runsRootPath = runsRoot(),
|
|
56
|
+
commandRunner,
|
|
57
|
+
} = {}) {
|
|
56
58
|
const lookup = resolveRunFolder({ command: 'stop', run, cwd, runsRootPath });
|
|
57
59
|
if (lookup.error) return result(1, lookup.error);
|
|
58
60
|
const { runDir } = lookup;
|
|
@@ -70,13 +72,24 @@ export async function stop({ run, cwd = process.cwd(), runsRootPath = runsRoot()
|
|
|
70
72
|
return result(1, `Run ${runDir} has no valid recorded pid; it was not changed.`);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
const identity = processIdentity({ runDir, status, commandRunner });
|
|
76
|
+
if (identity === IDENTITY_UNVERIFIED || identity === IDENTITY_FOREIGN) {
|
|
77
|
+
return result(
|
|
78
|
+
1,
|
|
79
|
+
`Process identity could not be confirmed for run ${runDir} (${identity}); no signal was sent. ` +
|
|
80
|
+
'"codex-bridge unlock" closes the record without killing a process.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (identity === IDENTITY_ALIVE) {
|
|
85
|
+
try {
|
|
86
|
+
stopCodex(processHandle(status.pid));
|
|
87
|
+
if (!(await waitForExit(status.pid))) {
|
|
88
|
+
return result(1, `Run ${runDir} did not stop within ${EXIT_WAIT_MS}ms; it was not closed.`);
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return result(1, `Could not stop run ${runDir}: ${err.message}; it was not closed.`);
|
|
77
92
|
}
|
|
78
|
-
} catch (err) {
|
|
79
|
-
return result(1, `Could not stop run ${runDir}: ${err.message}; it was not closed.`);
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
// The snapshot is what turns the verdict from a bare label into a list of what the stopped run
|
package/cli/uninstall.mjs
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {
|
|
5
|
+
definitionForRecordedHook,
|
|
5
6
|
fileFingerprint,
|
|
6
|
-
HOOK_DEFINITIONS,
|
|
7
7
|
installRecordPath,
|
|
8
8
|
readInstallRecord,
|
|
9
9
|
} from './manifest.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { removePermissionRules } from './permissions.mjs';
|
|
11
|
+
import { commandFor, removeHook, withSettingsRun } from './settings-merge.mjs';
|
|
11
12
|
import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
|
|
12
13
|
|
|
13
14
|
async function removeEmpty(directory) {
|
|
@@ -30,11 +31,13 @@ function remainingOwnersText(count) {
|
|
|
30
31
|
return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
function
|
|
34
|
-
|
|
34
|
+
function permissionOutput(host, removed, dryRun) {
|
|
35
|
+
const verb = dryRun ? 'Would remove' : 'Removed';
|
|
36
|
+
const plural = removed === 1 ? 'string' : 'strings';
|
|
37
|
+
return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
38
41
|
// Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
|
|
39
42
|
let registry = null;
|
|
40
43
|
let registryError = null;
|
|
@@ -43,13 +46,17 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
43
46
|
} catch (err) {
|
|
44
47
|
registryError = err;
|
|
45
48
|
}
|
|
49
|
+
const permissionResult = await removePermissionRules(host.settingsPath, { dryRun });
|
|
50
|
+
const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
|
|
46
51
|
const record = await readInstallRecord(host);
|
|
47
52
|
const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
|
|
48
53
|
+ `configuration in ${path.join(host.agentsDir, 'run-config.json')} are preserved.`;
|
|
49
|
-
if (!record)
|
|
54
|
+
if (!record) {
|
|
55
|
+
return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
|
|
56
|
+
}
|
|
50
57
|
|
|
51
58
|
if (dryRun) {
|
|
52
|
-
const lines = record.files.map((file) => `Would remove ${file}`);
|
|
59
|
+
const lines = [permissionLine, ...record.files.map((file) => `Would remove ${file}`)];
|
|
53
60
|
if (record.rules) {
|
|
54
61
|
if (registryError) {
|
|
55
62
|
lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
|
|
@@ -71,7 +78,7 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
for (const hook of record.hooks) {
|
|
74
|
-
const definition =
|
|
81
|
+
const definition = definitionForRecordedHook(hook);
|
|
75
82
|
lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
|
|
76
83
|
}
|
|
77
84
|
lines.push('Would remove the installation record.');
|
|
@@ -80,7 +87,7 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
for (const hook of record.hooks) {
|
|
83
|
-
const definition =
|
|
90
|
+
const definition = definitionForRecordedHook(hook);
|
|
84
91
|
const target = path.join(host.root, hook.path);
|
|
85
92
|
await removeHook(host.settingsPath, {
|
|
86
93
|
event: hook.event,
|
|
@@ -127,6 +134,12 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
127
134
|
await removeEmpty(host.agentsDir);
|
|
128
135
|
return {
|
|
129
136
|
exitCode: 0,
|
|
130
|
-
output: [`Uninstalled codex-bridge.`, ...rulesOutput, preservation].join('\n'),
|
|
137
|
+
output: [`Uninstalled codex-bridge.`, permissionLine, ...rulesOutput, preservation].join('\n'),
|
|
131
138
|
};
|
|
132
139
|
}
|
|
140
|
+
|
|
141
|
+
export async function uninstall(options = {}) {
|
|
142
|
+
const host = options?.host;
|
|
143
|
+
if (!host?.settingsPath) return uninstallInRun(options);
|
|
144
|
+
return withSettingsRun(host.settingsPath, () => uninstallInRun(options));
|
|
145
|
+
}
|
package/cli/unlock.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Closes dead-pid running records without deleting any run artifacts. */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/heartbeat.mjs';
|
|
6
|
+
import { markAbandoned } from '../src/meta/run-state.mjs';
|
|
7
|
+
import {
|
|
8
|
+
IDENTITY_ALIVE,
|
|
9
|
+
IDENTITY_UNVERIFIED,
|
|
10
|
+
processIdentity,
|
|
11
|
+
} from '../src/process-identity.mjs';
|
|
12
|
+
import { git } from '../src/runner/git-state.mjs';
|
|
13
|
+
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
14
|
+
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
15
|
+
import { readJson } from '../src/write-meta.mjs';
|
|
16
|
+
import { renderTable } from './table.mjs';
|
|
17
|
+
|
|
18
|
+
const CLOSED_COLUMNS = [
|
|
19
|
+
{ key: 'project', label: 'project' },
|
|
20
|
+
{ key: 'run', label: 'run' },
|
|
21
|
+
{ key: 'state', label: 'state' },
|
|
22
|
+
];
|
|
23
|
+
const RECORD_COLUMNS = [
|
|
24
|
+
{ key: 'project', label: 'project', truncate: null },
|
|
25
|
+
{ key: 'run', label: 'run', truncate: null },
|
|
26
|
+
{ key: 'age', label: 'age', truncate: null },
|
|
27
|
+
{ key: 'silence', label: 'silent for', truncate: null },
|
|
28
|
+
{ key: 'identity', label: 'identity', truncate: null },
|
|
29
|
+
{ key: 'state', label: 'state', truncate: null },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function result(exitCode, output) {
|
|
33
|
+
return { exitCode, output };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const positional = [];
|
|
38
|
+
let all = false;
|
|
39
|
+
for (const arg of argv) {
|
|
40
|
+
if (arg === '--all') {
|
|
41
|
+
if (all) return { error: 'codex-bridge unlock accepts --all only once.' };
|
|
42
|
+
all = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (typeof arg !== 'string' || arg.startsWith('-')) {
|
|
46
|
+
return { error: `codex-bridge unlock: unknown option "${arg}".` };
|
|
47
|
+
}
|
|
48
|
+
positional.push(arg);
|
|
49
|
+
}
|
|
50
|
+
if (all && positional.length) {
|
|
51
|
+
return { error: 'codex-bridge unlock accepts either --all or one project name.' };
|
|
52
|
+
}
|
|
53
|
+
if (positional.length > 1) {
|
|
54
|
+
return { error: 'codex-bridge unlock accepts at most one project name.' };
|
|
55
|
+
}
|
|
56
|
+
const project = positional[0] || null;
|
|
57
|
+
if (project && (path.isAbsolute(project) || path.dirname(project) !== '.')) {
|
|
58
|
+
return { error: 'codex-bridge unlock requires a bare project name.' };
|
|
59
|
+
}
|
|
60
|
+
return { all, project };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function directories(root) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
66
|
+
.filter((entry) => entry.isDirectory())
|
|
67
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function allProjects(root) {
|
|
74
|
+
return directories(root).map((entry) => ({ name: entry.name, dir: path.join(root, entry.name) }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function namedProject(root, projectName) {
|
|
78
|
+
const entry = directories(root).find((candidate) => candidate.name === projectName);
|
|
79
|
+
if (!entry) return { error: `codex-bridge unlock: unknown project "${projectName}".` };
|
|
80
|
+
return [{ name: entry.name, dir: path.join(root, entry.name) }];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function currentRepository(cwd) {
|
|
84
|
+
const result = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
85
|
+
const output = String(result.stdout ?? '').trim();
|
|
86
|
+
return result.status === 0 && output ? output : cwd;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function selectedProjects(root, parsed, cwd) {
|
|
90
|
+
if (parsed.all) return allProjects(root);
|
|
91
|
+
if (parsed.project) return namedProject(root, parsed.project);
|
|
92
|
+
const resolved = resolveProjectRunsDir(root, currentRepository(cwd), { create: false });
|
|
93
|
+
return [{ name: resolved.name, dir: resolved.dir }];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function unlockPlan(argv = [], options = {}) {
|
|
97
|
+
const parsed = parseArgs(argv);
|
|
98
|
+
if (parsed.error) return parsed;
|
|
99
|
+
const root = path.resolve(options.runsRootPath || runsRoot());
|
|
100
|
+
const projects = selectedProjects(root, parsed, options.cwd || process.cwd());
|
|
101
|
+
if (projects.error) return projects;
|
|
102
|
+
return {
|
|
103
|
+
root,
|
|
104
|
+
all: parsed.all,
|
|
105
|
+
project: parsed.project,
|
|
106
|
+
currentRepository: !parsed.all && !parsed.project,
|
|
107
|
+
projects,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function timestamp(value) {
|
|
112
|
+
const parsed = Date.parse(value || '');
|
|
113
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runAge(startedAt, now) {
|
|
117
|
+
const started = timestamp(startedAt);
|
|
118
|
+
return formatSilence(started === null ? null : Math.max(0, now - started));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function identityOptions(runDir, status, options, now) {
|
|
122
|
+
return {
|
|
123
|
+
runDir,
|
|
124
|
+
status,
|
|
125
|
+
now,
|
|
126
|
+
kill: options.kill,
|
|
127
|
+
probe: options.probe,
|
|
128
|
+
processStartProbe: options.processStartProbe,
|
|
129
|
+
commandRunner: options.commandRunner,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function inspectRuns(project, options) {
|
|
134
|
+
const now = options.now ?? Date.now();
|
|
135
|
+
const records = [];
|
|
136
|
+
for (const entry of directories(project.dir)) {
|
|
137
|
+
const dir = path.join(project.dir, entry.name);
|
|
138
|
+
const status = readJson(path.join(dir, 'status.json'));
|
|
139
|
+
if (!status || status.state !== 'running') continue;
|
|
140
|
+
const silenceAge = heartbeatAge(dir, now);
|
|
141
|
+
records.push({
|
|
142
|
+
project: project.name,
|
|
143
|
+
run: entry.name,
|
|
144
|
+
age: runAge(status.started_at, now),
|
|
145
|
+
silence: formatSilence(silenceAge),
|
|
146
|
+
silenceAge,
|
|
147
|
+
identity: processIdentity(identityOptions(dir, status, options, now)),
|
|
148
|
+
state: status.state,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return records;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function closeDeadRuns(plan) {
|
|
155
|
+
const closed = [];
|
|
156
|
+
const failures = [];
|
|
157
|
+
for (const project of plan.projects) {
|
|
158
|
+
try {
|
|
159
|
+
// No worktree snapshot is passed, and that is the choice rather than an omission: `stop`
|
|
160
|
+
// closes one named run and knows which repository to look at, while an unlock over the
|
|
161
|
+
// store would have to run git in every repository ever pointed at. The runs it closes died
|
|
162
|
+
// long ago, so a current tree would describe today's work, not their abandoned run.
|
|
163
|
+
for (const change of markAbandoned(project.dir)) {
|
|
164
|
+
closed.push({ project: project.name, run: change.run, state: change.state });
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
failures.push({ project: project.name, message: error.message });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { closed, failures };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function reportRows(records, closed) {
|
|
174
|
+
const closedByRun = new Map(closed.map((change) => [`${change.project}\u0000${change.run}`, change.state]));
|
|
175
|
+
return records.map((record) => ({
|
|
176
|
+
...record,
|
|
177
|
+
state: closedByRun.get(`${record.project}\u0000${record.run}`) || record.state,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderReport(plan, records, closed, failures, options) {
|
|
182
|
+
const scope = plan.all ? 'all projects' : plan.project ? `project "${plan.project}"` : 'the current repository';
|
|
183
|
+
const rows = reportRows(records, closed);
|
|
184
|
+
const stalled = records.filter((record) => (
|
|
185
|
+
record.identity === IDENTITY_ALIVE
|
|
186
|
+
&& Number.isFinite(record.silenceAge)
|
|
187
|
+
&& record.silenceAge > HEARTBEAT_STALE_MS
|
|
188
|
+
));
|
|
189
|
+
const alive = records.filter((record) => record.identity === IDENTITY_ALIVE);
|
|
190
|
+
const unverified = records.filter((record) => record.identity === IDENTITY_UNVERIFIED);
|
|
191
|
+
const lines = [`Unlock of ${scope} completed.`];
|
|
192
|
+
if (closed.length) {
|
|
193
|
+
lines.push(`Closed ${closed.length} running record${closed.length === 1 ? '' : 's'}:`);
|
|
194
|
+
lines.push(renderTable(CLOSED_COLUMNS, closed, options));
|
|
195
|
+
} else {
|
|
196
|
+
lines.push('Nothing to close; the store is unchanged.');
|
|
197
|
+
}
|
|
198
|
+
if (rows.length) {
|
|
199
|
+
lines.push('Running records inspected:');
|
|
200
|
+
lines.push(renderTable(RECORD_COLUMNS, rows, options));
|
|
201
|
+
} else {
|
|
202
|
+
lines.push('Running records inspected: none.');
|
|
203
|
+
}
|
|
204
|
+
if (alive.length) {
|
|
205
|
+
lines.push(
|
|
206
|
+
`Refused to close ${alive.length} confirmed-alive run${alive.length === 1 ? '' : 's'}; `
|
|
207
|
+
+ 'use codex-bridge stop <run> instead.',
|
|
208
|
+
);
|
|
209
|
+
for (const run of alive) lines.push(` codex-bridge stop ${run.run}`);
|
|
210
|
+
}
|
|
211
|
+
if (unverified.length) {
|
|
212
|
+
lines.push(
|
|
213
|
+
`Left ${unverified.length} run${unverified.length === 1 ? '' : 's'} untouched because `
|
|
214
|
+
+ 'process identity is unverified.',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (stalled.length) {
|
|
218
|
+
lines.push(`Left ${stalled.length} live-pid run${stalled.length === 1 ? '' : 's'} with stale heartbeats untouched:`);
|
|
219
|
+
lines.push(renderTable(RECORD_COLUMNS, stalled, options));
|
|
220
|
+
} else {
|
|
221
|
+
lines.push('Live-pid runs with stale heartbeats left untouched: none.');
|
|
222
|
+
}
|
|
223
|
+
if (failures.length) {
|
|
224
|
+
lines.push('Some project records could not be unlocked:');
|
|
225
|
+
for (const failure of failures) lines.push(` ${failure.project}: ${failure.message}`);
|
|
226
|
+
}
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function unlock(argv = [], options = {}) {
|
|
231
|
+
const plan = unlockPlan(argv, options);
|
|
232
|
+
if (plan.error) return result(2, plan.error);
|
|
233
|
+
const records = plan.projects.flatMap((project) => inspectRuns(project, options));
|
|
234
|
+
const { closed, failures } = closeDeadRuns(plan);
|
|
235
|
+
return result(
|
|
236
|
+
failures.length ? 1 : 0,
|
|
237
|
+
renderReport(plan, records, closed, failures, options),
|
|
238
|
+
);
|
|
239
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Delegate implementation, reconnaissance and code review from Claude Code to the Codex CLI: dispatcher agents, a runner that survives a dropped call, and a verdict that checks the report against the worktree.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* error mode, 2 internal error (config missing/malformed, or an exclude entry has no
|
|
14
14
|
* matching exclusionRationale).
|
|
15
15
|
*
|
|
16
|
-
* Every exclude entry is classified as either a "pattern" (contains a `*`,
|
|
16
|
+
* Every exclude entry is classified as either a "pattern" (contains a `*`, covers a
|
|
17
17
|
* whole category — e.g. `**\/node_modules/**`) or a "specific path" (no `*`, names one
|
|
18
18
|
* file). Specific paths must carry an exclusionRationale entry; patterns are exempt. This
|
|
19
19
|
* is the structural guard against silently hiding a file in exclude without saying why.
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs/promises';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { readJsonFile } from '../src/json-file.mjs';
|
|
24
25
|
|
|
25
26
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
export const REPO_ROOT = path.resolve(HERE, '..');
|
|
@@ -31,17 +32,14 @@ const VALID_MODES = ['error', 'warning'];
|
|
|
31
32
|
|
|
32
33
|
/** Reads and structurally validates .file-size-limit.json. Throws on anything wrong. */
|
|
33
34
|
export async function loadConfig(configPath = CONFIG_PATH) {
|
|
34
|
-
let raw;
|
|
35
|
-
try {
|
|
36
|
-
raw = await fs.readFile(configPath, 'utf8');
|
|
37
|
-
} catch (err) {
|
|
38
|
-
throw new Error(`cannot read ${configPath}: ${err.message}`);
|
|
39
|
-
}
|
|
40
35
|
let parsed;
|
|
41
36
|
try {
|
|
42
|
-
parsed =
|
|
37
|
+
parsed = await readJsonFile(configPath);
|
|
43
38
|
} catch (err) {
|
|
44
|
-
|
|
39
|
+
if (err.cause) {
|
|
40
|
+
throw new Error(`${configPath} is not valid JSON: ${err.cause.message}`, { cause: err.cause });
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`cannot read ${configPath}: ${err.message}`);
|
|
45
43
|
}
|
|
46
44
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
47
45
|
throw new Error(`${configPath} must contain a JSON object`);
|
|
@@ -69,7 +67,7 @@ export async function loadConfig(configPath = CONFIG_PATH) {
|
|
|
69
67
|
return parsed;
|
|
70
68
|
}
|
|
71
69
|
|
|
72
|
-
/** A pattern (contains `*`)
|
|
70
|
+
/** A pattern (contains `*`) covers a category; a specific path (no `*`) names one file. */
|
|
73
71
|
export function isSpecificExclude(entry) {
|
|
74
72
|
return !entry.includes('*');
|
|
75
73
|
}
|
|
@@ -28,6 +28,8 @@ job is to report the run status honestly, including failure.
|
|
|
28
28
|
|
|
29
29
|
- The task statement: what to change and the completion criteria.
|
|
30
30
|
- The path to the repository. If none is given, use the current working directory.
|
|
31
|
+
- Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
|
|
32
|
+
refused before the run starts; a file this task is meant to create is declared with `--scope-new`.
|
|
31
33
|
- Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
|
|
32
34
|
`order id` as `--order-id`, `scope` as `--scope`. Never invent a value, never edit one, never
|
|
33
35
|
reuse an order id from another order — the runner chains runs by that label, and a made-up label
|
|
@@ -27,6 +27,8 @@ opinion, not a verdict.
|
|
|
27
27
|
- branch against base — `--mode base:<branch>`;
|
|
28
28
|
- specific commit — `--mode commit:<sha>`.
|
|
29
29
|
- The path to the repository. If none is given, use the current working directory.
|
|
30
|
+
- Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
|
|
31
|
+
refused before the run starts.
|
|
30
32
|
- Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
|
|
31
33
|
`order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
|
|
32
34
|
another order — the runner chains runs by that label, and a made-up label is how a repeat run
|
|
@@ -23,6 +23,8 @@ files, do not run grep, do not retell the report, and do not reason about the ta
|
|
|
23
23
|
|
|
24
24
|
- The task statement (what to find out / what to diagnose / what to review).
|
|
25
25
|
- The path to the repository. If none is given, work in the current working directory.
|
|
26
|
+
- Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
|
|
27
|
+
refused before the run starts.
|
|
26
28
|
- Optional: `effort: <none|minimal|low|medium|high|xhigh|max>` — Codex reasoning depth.
|
|
27
29
|
- Optional: `slug: <short-name>` for the run folder.
|
|
28
30
|
- Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
|