@lyupro/codex-bridge 0.1.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 +39 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/codex-bridge.mjs +105 -0
- package/cli/copy.mjs +38 -0
- package/cli/doctor.mjs +170 -0
- package/cli/hosts.mjs +50 -0
- package/cli/install.mjs +109 -0
- package/cli/manifest.mjs +210 -0
- package/cli/settings-merge.mjs +101 -0
- package/cli/stop.mjs +125 -0
- package/cli/uninstall.mjs +62 -0
- package/cli/update.mjs +156 -0
- package/package.json +48 -0
- package/scripts/check-file-size.mjs +193 -0
- package/scripts/run-tests.mjs +26 -0
- package/src/agents/codex-build.md +224 -0
- package/src/agents/codex-review.md +166 -0
- package/src/agents/codex-scout.md +188 -0
- package/src/commands/env.md +38 -0
- package/src/commands/usage.md +88 -0
- package/src/hooks/reply-guard.mjs +311 -0
- package/src/meta/chain.mjs +87 -0
- package/src/meta/environment.mjs +36 -0
- package/src/meta/paths.mjs +193 -0
- package/src/meta/reply.mjs +133 -0
- package/src/meta/run-state.mjs +197 -0
- package/src/meta/verdict.mjs +362 -0
- package/src/rules/codex-bridge.rules +173 -0
- package/src/run-codex.mjs +113 -0
- package/src/run-config.json +4 -0
- package/src/run-config.mjs +302 -0
- package/src/runner/args.mjs +111 -0
- package/src/runner/attach.mjs +111 -0
- package/src/runner/codex-cmd.mjs +265 -0
- package/src/runner/git-state.mjs +144 -0
- package/src/runner/launcher.mjs +374 -0
- package/src/runner/project-dir.mjs +92 -0
- package/src/runner/prompts.mjs +97 -0
- package/src/runner/run-context.mjs +52 -0
- package/src/runner/run-env.mjs +38 -0
- package/src/runner/runs-root.mjs +16 -0
- package/src/runner/schemas.mjs +135 -0
- package/src/runner/worker.mjs +62 -0
- package/src/write-meta.mjs +135 -0
package/cli/manifest.mjs
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** Defines install mappings and validates the persisted installation record. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
export const PACKAGE_ROOT = path.resolve(HERE, '..');
|
|
9
|
+
export const INSTALL_RECORD_NAME = '.codex-bridge-install.json';
|
|
10
|
+
export const RULES_NAME = 'codex-bridge.rules';
|
|
11
|
+
const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
12
|
+
/**
|
|
13
|
+
* Files the operator owns once they exist. Seeded on first install so the defaults are
|
|
14
|
+
* visible and editable, then never written, never compared, never removed: run-config.json
|
|
15
|
+
* is where a host states which model each mode runs on, and an installer that overwrites it
|
|
16
|
+
* would erase that on the next update — the same way overwriting a .env would.
|
|
17
|
+
*/
|
|
18
|
+
export const SEEDED_SOURCES = Object.freeze(['src/run-config.json']);
|
|
19
|
+
const seededTargets = (host) =>
|
|
20
|
+
new Set(SEEDED_SOURCES.map((source) => posix(
|
|
21
|
+
path.relative(host.root, path.join(host.agentsDir, path.relative('src', source))),
|
|
22
|
+
)));
|
|
23
|
+
export const INSTALL_TABLE = Object.freeze([
|
|
24
|
+
{ source: 'src/agents/*.md', target: 'agentsDir', processing: 'placeholders' },
|
|
25
|
+
{ source: 'src/commands/*.md', target: 'commandsDir', processing: 'placeholders' },
|
|
26
|
+
{ source: 'src/**', target: 'agentsDir', processing: 'copy' },
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const posix = (value) => value.split(path.sep).join('/');
|
|
30
|
+
|
|
31
|
+
export function replacePlaceholders(content, agentsDir) {
|
|
32
|
+
return content.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
|
36
|
+
const parsed = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
37
|
+
return { name: parsed.name, version: parsed.version };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function installRecordPath(host) {
|
|
41
|
+
return path.join(host.agentsDir, INSTALL_RECORD_NAME);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where each seeded file comes from and where it goes; contents are copied verbatim. */
|
|
45
|
+
export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
46
|
+
return SEEDED_SOURCES.map((source) => ({
|
|
47
|
+
source: path.join(packageRoot, source),
|
|
48
|
+
target: path.join(host.agentsDir, path.relative('src', source)),
|
|
49
|
+
processing: 'copy',
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function rulesPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
54
|
+
return {
|
|
55
|
+
source: path.join(packageRoot, RULES_SOURCE),
|
|
56
|
+
target: path.join(host.codexRulesDir, RULES_NAME),
|
|
57
|
+
name: RULES_NAME,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function fileFingerprint(absolutePath) {
|
|
62
|
+
try {
|
|
63
|
+
return createHash('sha256').update(await fs.readFile(absolutePath)).digest('hex');
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code === 'ENOENT') return null;
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether the recorded installation is exactly what this package would install right now. Install
|
|
72
|
+
* and update both decide "nothing to do" by this question, so it is answered in one place: two
|
|
73
|
+
* copies of the condition would drift the first time a field is added to the record.
|
|
74
|
+
*/
|
|
75
|
+
export function recordMatchesPackage(record, plan, currentPackage, fingerprints, ruleState) {
|
|
76
|
+
return Boolean(record)
|
|
77
|
+
&& record.name === currentPackage.name
|
|
78
|
+
&& record.version === currentPackage.version
|
|
79
|
+
&& record.files.length === plan.length
|
|
80
|
+
&& record.files.every((file, index) => file === plan[index].relativeToHost)
|
|
81
|
+
&& Boolean(record.fingerprints)
|
|
82
|
+
&& plan.every((item) => record.fingerprints[item.relativeToHost] === fingerprints.get(item.relativeToHost))
|
|
83
|
+
&& (!ruleState || (record.rules?.path === ruleState.path
|
|
84
|
+
&& record.rules.fingerprint === ruleState.fingerprint));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateInstallRecord(record) {
|
|
88
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
89
|
+
throw new Error('installation record must be an object');
|
|
90
|
+
}
|
|
91
|
+
const strings = ['name', 'version', 'installedAt', 'mode'];
|
|
92
|
+
for (const key of strings) {
|
|
93
|
+
if (typeof record[key] !== 'string' || !record[key]) throw new Error(`installation record has invalid ${key}`);
|
|
94
|
+
}
|
|
95
|
+
if (record.mode !== 'copy') throw new Error('installation record mode must be copy');
|
|
96
|
+
if (Number.isNaN(Date.parse(record.installedAt))) throw new Error('installation record installedAt is invalid');
|
|
97
|
+
if (!Array.isArray(record.files) || !record.files.length || record.files.some((file) => typeof file !== 'string' || !file)) {
|
|
98
|
+
throw new Error('installation record files must be a non-empty list of strings');
|
|
99
|
+
}
|
|
100
|
+
if (record.files.some((file) => path.isAbsolute(file) || file.split(/[\\/]/).some((part) => part === '..' || part === '.'))) {
|
|
101
|
+
throw new Error('installation record files must stay relative to the host root');
|
|
102
|
+
}
|
|
103
|
+
if (new Set(record.files).size !== record.files.length) {
|
|
104
|
+
throw new Error('installation record files must not contain duplicates');
|
|
105
|
+
}
|
|
106
|
+
// Run artifacts are the user's data, not ours. Refusing them here means neither uninstall nor
|
|
107
|
+
// update needs its own guard against deleting a run folder someone listed as an installed file.
|
|
108
|
+
if (record.files.some((file) => file === 'codex-runs' || file.split(/[\\/]/)[0] === 'codex-runs')) {
|
|
109
|
+
throw new Error('installation record files must not name run artifacts under codex-runs');
|
|
110
|
+
}
|
|
111
|
+
if (record.fingerprints !== undefined) {
|
|
112
|
+
if (!record.fingerprints || typeof record.fingerprints !== 'object' || Array.isArray(record.fingerprints)) {
|
|
113
|
+
throw new Error('installation record fingerprints must be an object');
|
|
114
|
+
}
|
|
115
|
+
const fingerprintKeys = Object.keys(record.fingerprints);
|
|
116
|
+
if (fingerprintKeys.length !== record.files.length || fingerprintKeys.some((file) => !record.files.includes(file))) {
|
|
117
|
+
throw new Error('installation record fingerprints keys must exactly match files');
|
|
118
|
+
}
|
|
119
|
+
if (Object.values(record.fingerprints).some((fingerprint) =>
|
|
120
|
+
typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/i.test(fingerprint))) {
|
|
121
|
+
throw new Error('installation record fingerprints must be 64-character hexadecimal SHA256 strings');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (record.rules !== undefined) {
|
|
125
|
+
if (!record.rules || typeof record.rules !== 'object' || Array.isArray(record.rules)) {
|
|
126
|
+
throw new Error('installation record rules must be an object');
|
|
127
|
+
}
|
|
128
|
+
if (typeof record.rules.path !== 'string' || !record.rules.path) {
|
|
129
|
+
throw new Error('installation record rules path must be a non-empty string');
|
|
130
|
+
}
|
|
131
|
+
if (path.basename(record.rules.path) !== RULES_NAME) {
|
|
132
|
+
throw new Error(`installation record rules path must name ${RULES_NAME}`);
|
|
133
|
+
}
|
|
134
|
+
if (typeof record.rules.fingerprint !== 'string'
|
|
135
|
+
|| !/^[a-f0-9]{64}$/i.test(record.rules.fingerprint)) {
|
|
136
|
+
throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!record.hook || typeof record.hook !== 'object' || Array.isArray(record.hook)) {
|
|
140
|
+
throw new Error('installation record hook must be an object');
|
|
141
|
+
}
|
|
142
|
+
if (record.hook.event !== 'SubagentStop' || typeof record.hook.path !== 'string' || !record.hook.path) {
|
|
143
|
+
throw new Error('installation record hook must identify SubagentStop and its path');
|
|
144
|
+
}
|
|
145
|
+
if (path.isAbsolute(record.hook.path) || record.hook.path.split(/[\\/]/).includes('..')) {
|
|
146
|
+
throw new Error('installation record hook path must stay relative to the host root');
|
|
147
|
+
}
|
|
148
|
+
if (path.basename(record.hook.path) !== 'reply-guard.mjs' || !record.files.includes(record.hook.path)) {
|
|
149
|
+
throw new Error('installation record hook path must name the installed reply-guard.mjs');
|
|
150
|
+
}
|
|
151
|
+
return record;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function readInstallRecord(host) {
|
|
155
|
+
let raw;
|
|
156
|
+
try {
|
|
157
|
+
raw = await fs.readFile(installRecordPath(host), 'utf8');
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (err.code === 'ENOENT') return null;
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(raw);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
throw new Error(`invalid installation record JSON: ${err.message}`);
|
|
167
|
+
}
|
|
168
|
+
const record = validateInstallRecord(parsed);
|
|
169
|
+
// Earlier versions installed the config as a package file and recorded it. Dropping it here
|
|
170
|
+
// is what stops the next update from calling the operator's edited config an orphan and
|
|
171
|
+
// deleting it, and it needs no migration step: the record is rewritten on the next write.
|
|
172
|
+
const seeded = seededTargets(host);
|
|
173
|
+
record.files = record.files.filter((file) => !seeded.has(file));
|
|
174
|
+
if (record.fingerprints) {
|
|
175
|
+
record.fingerprints = Object.fromEntries(
|
|
176
|
+
Object.entries(record.fingerprints).filter(([file]) => !seeded.has(file)),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return record;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function writeInstallRecord(host, record) {
|
|
183
|
+
validateInstallRecord(record);
|
|
184
|
+
await fs.writeFile(installRecordPath(host), `${JSON.stringify(record, null, 2)}\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
188
|
+
const files = [];
|
|
189
|
+
const claimedSources = [];
|
|
190
|
+
for (const mapping of INSTALL_TABLE) {
|
|
191
|
+
for await (const relative of fs.glob(mapping.source, { cwd: packageRoot, exclude: claimedSources })) {
|
|
192
|
+
if (posix(relative) === RULES_SOURCE) continue;
|
|
193
|
+
if (SEEDED_SOURCES.includes(posix(relative))) continue;
|
|
194
|
+
const source = path.join(packageRoot, relative);
|
|
195
|
+
if (!(await fs.stat(source)).isFile()) continue;
|
|
196
|
+
const targetRelative = mapping.processing === 'copy'
|
|
197
|
+
? path.relative(path.join(packageRoot, 'src'), source)
|
|
198
|
+
: path.basename(source);
|
|
199
|
+
const target = path.join(host[mapping.target], targetRelative);
|
|
200
|
+
files.push({
|
|
201
|
+
source,
|
|
202
|
+
target,
|
|
203
|
+
relativeToHost: posix(path.relative(host.root, target)),
|
|
204
|
+
processing: mapping.processing,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
claimedSources.push(mapping.source);
|
|
208
|
+
}
|
|
209
|
+
return files.sort((a, b) => a.relativeToHost.localeCompare(b.relativeToHost));
|
|
210
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Merges and removes the codex-bridge SubagentStop hook without disturbing host settings. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const EVENT = 'SubagentStop';
|
|
7
|
+
|
|
8
|
+
function commandFor(guardPath) {
|
|
9
|
+
return `node "${path.resolve(guardPath)}"`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readSettings(settingsPath) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = await fs.readFile(settingsPath, 'utf8');
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
18
|
+
throw new Error('top level must be an object');
|
|
19
|
+
}
|
|
20
|
+
return { exists: true, raw, settings: parsed };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
throw new Error(`cannot parse ${settingsPath}: ${err.message}`);
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err.code === 'ENOENT') return { exists: false, raw: null, settings: {} };
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function groups(settings) {
|
|
31
|
+
const value = settings?.hooks?.[EVENT];
|
|
32
|
+
return Array.isArray(value) ? value : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ownHook(command) {
|
|
36
|
+
return { type: 'command', command, timeout: 10 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function groupHooks(group) {
|
|
40
|
+
return Array.isArray(group?.hooks) ? group.hooks : [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function inspectHook(settingsPath, guardPath) {
|
|
44
|
+
const state = await readSettings(settingsPath);
|
|
45
|
+
const command = commandFor(guardPath);
|
|
46
|
+
const present = groups(state.settings).some((group) =>
|
|
47
|
+
groupHooks(group).some((hook) => hook?.type === 'command' && hook.command === command));
|
|
48
|
+
return { ...state, command, present };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function backupName(settingsPath) {
|
|
52
|
+
const stamp = new Date().toISOString().replaceAll(':', '-');
|
|
53
|
+
return `${settingsPath}.codex-bridge-backup-${stamp}-${randomUUID()}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function atomicWrite(settingsPath, settings, previous) {
|
|
57
|
+
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
58
|
+
if (previous.exists) await fs.writeFile(backupName(settingsPath), previous.raw, { flag: 'wx' });
|
|
59
|
+
const temporary = `${settingsPath}.${randomUUID()}.tmp`;
|
|
60
|
+
try {
|
|
61
|
+
await fs.writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { flag: 'wx' });
|
|
62
|
+
await fs.rename(temporary, settingsPath);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function mergeHook(settingsPath, guardPath, inspected) {
|
|
70
|
+
const state = inspected || await inspectHook(settingsPath, guardPath);
|
|
71
|
+
if (state.present) return { changed: false, createdGroup: false };
|
|
72
|
+
const settings = structuredClone(state.settings);
|
|
73
|
+
settings.hooks ??= {};
|
|
74
|
+
if (!Array.isArray(settings.hooks[EVENT])) settings.hooks[EVENT] = [];
|
|
75
|
+
let group = settings.hooks[EVENT].find((entry) => entry?.matcher === '*');
|
|
76
|
+
const createdGroup = !group;
|
|
77
|
+
if (!group) {
|
|
78
|
+
group = { matcher: '*', hooks: [] };
|
|
79
|
+
settings.hooks[EVENT].push(group);
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
82
|
+
group.hooks.push(ownHook(state.command));
|
|
83
|
+
await atomicWrite(settingsPath, settings, state);
|
|
84
|
+
return { changed: true, createdGroup };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function removeHook(settingsPath, guardPath, { createdGroup = false } = {}) {
|
|
88
|
+
const state = await inspectHook(settingsPath, guardPath);
|
|
89
|
+
if (!state.present) return { changed: false };
|
|
90
|
+
const settings = structuredClone(state.settings);
|
|
91
|
+
const eventGroups = groups(settings);
|
|
92
|
+
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
93
|
+
const group = eventGroups[index];
|
|
94
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
95
|
+
group.hooks = group.hooks.filter((hook) =>
|
|
96
|
+
!(hook?.type === 'command' && hook.command === state.command));
|
|
97
|
+
if (createdGroup && group.matcher === '*' && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
98
|
+
}
|
|
99
|
+
await atomicWrite(settingsPath, settings, state);
|
|
100
|
+
return { changed: true };
|
|
101
|
+
}
|
package/cli/stop.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Stops a live run and records the same abandoned FAIL artifacts as the normal sweep. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { stopCodex } from '../src/runner/codex-cmd.mjs';
|
|
5
|
+
import { git, worktreeSnapshot } from '../src/runner/git-state.mjs';
|
|
6
|
+
import { markAbandoned, readJson } from '../src/write-meta.mjs';
|
|
7
|
+
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
8
|
+
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
9
|
+
|
|
10
|
+
const EXIT_POLL_MS = 25;
|
|
11
|
+
const EXIT_WAIT_MS = 2000;
|
|
12
|
+
|
|
13
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
|
|
15
|
+
function projectRoot(cwd) {
|
|
16
|
+
const result = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
17
|
+
return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : cwd;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveRunFolder(run, cwd, runsRootPath) {
|
|
21
|
+
const value = String(run ?? '').trim();
|
|
22
|
+
if (!value || value === '.' || value === '..') {
|
|
23
|
+
throw new Error('stop requires a run folder (full path or bare folder name)');
|
|
24
|
+
}
|
|
25
|
+
if (path.isAbsolute(value)) return path.resolve(value);
|
|
26
|
+
if (path.dirname(value) !== '.') {
|
|
27
|
+
throw new Error('a bare run folder name or a full path is required');
|
|
28
|
+
}
|
|
29
|
+
const projectRuns = resolveProjectRunsDir(runsRootPath, projectRoot(cwd), { create: false });
|
|
30
|
+
return path.join(projectRuns.dir, value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The stopped run's worktree as it stands now, or undefined when the repo is not a git one. */
|
|
34
|
+
function treeOf(repo) {
|
|
35
|
+
const root = String(repo ?? '').trim();
|
|
36
|
+
if (!root) return undefined;
|
|
37
|
+
if (git(root, ['rev-parse', '--show-toplevel']).status !== 0) return undefined;
|
|
38
|
+
return worktreeSnapshot(root);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function processHandle(pid) {
|
|
42
|
+
return {
|
|
43
|
+
pid,
|
|
44
|
+
kill(signal) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, signal);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err.code !== 'ESRCH') throw err;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function alive(pid) {
|
|
55
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
56
|
+
try {
|
|
57
|
+
process.kill(pid, 0);
|
|
58
|
+
return true;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return err.code === 'EPERM';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function waitForExit(pid) {
|
|
65
|
+
const deadline = Date.now() + EXIT_WAIT_MS;
|
|
66
|
+
while (alive(pid) && Date.now() < deadline) await sleep(EXIT_POLL_MS);
|
|
67
|
+
return !alive(pid);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function result(exitCode, output) {
|
|
71
|
+
return { exitCode, output };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function stop({ run, cwd = process.cwd(), runsRootPath = runsRoot() } = {}) {
|
|
75
|
+
let runDir;
|
|
76
|
+
try {
|
|
77
|
+
runDir = resolveRunFolder(run, cwd, runsRootPath);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return result(1, `codex-bridge stop: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let isDirectory = false;
|
|
83
|
+
try {
|
|
84
|
+
isDirectory = fs.statSync(runDir).isDirectory();
|
|
85
|
+
} catch {}
|
|
86
|
+
if (!isDirectory) {
|
|
87
|
+
return result(
|
|
88
|
+
1,
|
|
89
|
+
`Run folder not found: ${runDir}. Pass a full path or a bare run folder name from the current project's runs directory.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
94
|
+
const meta = readJson(path.join(runDir, 'meta.json'));
|
|
95
|
+
if (meta && typeof meta.status === 'string') {
|
|
96
|
+
return result(0, `Run ${runDir} already has a verdict (${meta.status}); no changes made.`);
|
|
97
|
+
}
|
|
98
|
+
if (!status) return result(1, `Run ${runDir} has no readable status.json; it was not changed.`);
|
|
99
|
+
if (status.state !== 'running') {
|
|
100
|
+
return result(0, `Run ${runDir} is not marked running (state: ${status.state || 'unknown'}); no changes made.`);
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isInteger(status.pid) || status.pid <= 0) {
|
|
103
|
+
return result(1, `Run ${runDir} has no valid recorded pid; it was not changed.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
stopCodex(processHandle(status.pid));
|
|
108
|
+
if (!(await waitForExit(status.pid))) {
|
|
109
|
+
return result(1, `Run ${runDir} did not stop within ${EXIT_WAIT_MS}ms; it was not closed.`);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return result(1, `Could not stop run ${runDir}: ${err.message}; it was not closed.`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The snapshot is what turns the verdict from a bare label into a list of what the stopped run
|
|
116
|
+
// left in the worktree. Omitting it would close the folder with "no file list is available" —
|
|
117
|
+
// the operator stopping a run by hand is exactly who needs to know what it managed to write.
|
|
118
|
+
markAbandoned(path.dirname(runDir), treeOf(status.repo));
|
|
119
|
+
const closedMeta = readJson(path.join(runDir, 'meta.json'));
|
|
120
|
+
const closedStatus = readJson(path.join(runDir, 'status.json'));
|
|
121
|
+
if (closedMeta?.status === 'FAIL' && closedStatus?.status === 'FAIL' && closedStatus.state !== 'running') {
|
|
122
|
+
return result(0, `Stopped run ${runDir}; recorded FAIL verdict.`);
|
|
123
|
+
}
|
|
124
|
+
return result(1, `Run ${runDir} stopped but could not be closed with consistent FAIL artifacts.`);
|
|
125
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Uninstalls only recorded codex-bridge files while preserving host data and foreign files. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileFingerprint, installRecordPath, readInstallRecord } from './manifest.mjs';
|
|
5
|
+
import { removeHook } from './settings-merge.mjs';
|
|
6
|
+
|
|
7
|
+
async function removeEmpty(directory) {
|
|
8
|
+
try {
|
|
9
|
+
if ((await fs.readdir(directory)).length === 0) await fs.rmdir(directory);
|
|
10
|
+
} catch (err) {
|
|
11
|
+
if (err.code !== 'ENOENT') throw err;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function removeEmptyParents(target, boundary) {
|
|
16
|
+
let current = path.dirname(target);
|
|
17
|
+
while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
|
|
18
|
+
await removeEmpty(current);
|
|
19
|
+
current = path.dirname(current);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function uninstall({ host, dryRun = false } = {}) {
|
|
24
|
+
const record = await readInstallRecord(host);
|
|
25
|
+
const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
|
|
26
|
+
+ `configuration in ${path.join(host.agentsDir, 'run-config.json')} are preserved.`;
|
|
27
|
+
if (!record) return { exitCode: 1, output: `codex-bridge is not installed.\n${preservation}` };
|
|
28
|
+
|
|
29
|
+
if (dryRun) {
|
|
30
|
+
const lines = record.files.map((file) => `Would remove ${file}`);
|
|
31
|
+
if (record.rules) lines.push(`Would remove ${record.rules.path} if its fingerprint is unchanged.`);
|
|
32
|
+
lines.push('Would remove the SubagentStop hook and installation record.');
|
|
33
|
+
lines.push(preservation);
|
|
34
|
+
return { exitCode: 0, output: lines.join('\n') };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await removeHook(host.settingsPath, path.join(host.root, record.hook.path), {
|
|
38
|
+
createdGroup: record.hook.createdGroup === true,
|
|
39
|
+
});
|
|
40
|
+
let rulesOutput;
|
|
41
|
+
if (record.rules) {
|
|
42
|
+
const currentFingerprint = await fileFingerprint(record.rules.path);
|
|
43
|
+
if (currentFingerprint === record.rules.fingerprint) {
|
|
44
|
+
await fs.rm(record.rules.path, { force: true });
|
|
45
|
+
} else if (currentFingerprint !== null) {
|
|
46
|
+
rulesOutput = `Left ${record.rules.path} because its contents changed after installation.`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const relative of record.files) {
|
|
50
|
+
const target = path.join(host.root, relative);
|
|
51
|
+
await fs.rm(target, { force: true });
|
|
52
|
+
const boundary = target.startsWith(`${host.commandsDir}${path.sep}`) ? host.commandsDir : host.agentsDir;
|
|
53
|
+
await removeEmptyParents(target, boundary);
|
|
54
|
+
}
|
|
55
|
+
await removeEmpty(host.commandsDir);
|
|
56
|
+
await fs.rm(installRecordPath(host), { force: true });
|
|
57
|
+
await removeEmpty(host.agentsDir);
|
|
58
|
+
return {
|
|
59
|
+
exitCode: 0,
|
|
60
|
+
output: [`Uninstalled codex-bridge.`, rulesOutput, preservation].filter(Boolean).join('\n'),
|
|
61
|
+
};
|
|
62
|
+
}
|
package/cli/update.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Updates a recorded codex-bridge installation without overwriting unapproved host changes. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { install } from './install.mjs';
|
|
5
|
+
import {
|
|
6
|
+
buildInstallPlan,
|
|
7
|
+
fileFingerprint,
|
|
8
|
+
packageInfo,
|
|
9
|
+
readInstallRecord,
|
|
10
|
+
recordMatchesPackage,
|
|
11
|
+
rulesPlan,
|
|
12
|
+
} from './manifest.mjs';
|
|
13
|
+
import { targetMatches } from './copy.mjs';
|
|
14
|
+
import { inspectHook } from './settings-merge.mjs';
|
|
15
|
+
|
|
16
|
+
async function exists(target) {
|
|
17
|
+
try {
|
|
18
|
+
return (await fs.stat(target)).isFile();
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function classifyPlanned(state, record) {
|
|
26
|
+
if (!state.recorded) return 'new';
|
|
27
|
+
if (!state.exists) return 'missing';
|
|
28
|
+
if (state.matchesPackage) return 'up-to-date';
|
|
29
|
+
if (state.recordedFingerprint === state.fingerprint) return 'outdated';
|
|
30
|
+
return 'modified';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function classifyOrphan(state, record) {
|
|
34
|
+
if (!state.exists) return 'orphaned';
|
|
35
|
+
if (record.fingerprints?.[state.relative] === state.fingerprint) return 'orphaned';
|
|
36
|
+
return 'modified';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the run actually did, counted by outcome. `Updated codex-bridge.` alone told the operator
|
|
41
|
+
* nothing about the files that were rewritten or removed on their behalf.
|
|
42
|
+
*/
|
|
43
|
+
function appliedOutput(states) {
|
|
44
|
+
const count = (status) => states.filter((state) => state.status === status).length;
|
|
45
|
+
const parts = [
|
|
46
|
+
[count('outdated'), 'updated'],
|
|
47
|
+
[count('new'), 'added'],
|
|
48
|
+
[count('missing'), 'restored'],
|
|
49
|
+
// A modified orphan is preserved rather than rewritten, so it is not counted here.
|
|
50
|
+
[states.filter((state) => state.status === 'modified' && state.item).length, 'overwritten'],
|
|
51
|
+
[states.filter((state) => state.status === 'orphaned' && state.exists).length, 'removed'],
|
|
52
|
+
].filter(([total]) => total > 0).map(([total, label]) => `${total} ${label}`);
|
|
53
|
+
return parts.length ? `Updated codex-bridge: ${parts.join(', ')}.` : 'Updated codex-bridge.';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function conflictOutput(conflicts, legacy, dryRun) {
|
|
57
|
+
const heading = dryRun ? 'Update would stop for these paths:' : 'Update stopped for these paths:';
|
|
58
|
+
const lines = conflicts.map((state) => ` ${state.relative} (${state.status})`);
|
|
59
|
+
if (legacy) lines.push('Installation record has no fingerprints; differing files are treated as modified.');
|
|
60
|
+
lines.push('Run update again with --force to overwrite modified files and restore missing files.');
|
|
61
|
+
return [heading, ...lines].join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dryRunOutput(states, hookPresent, legacy) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
for (const state of states) {
|
|
67
|
+
if (state.status === 'outdated') lines.push(`Would update ${state.relative}.`);
|
|
68
|
+
if (state.status === 'new') lines.push(`Would create ${state.relative}.`);
|
|
69
|
+
if (state.status === 'modified' && state.item) lines.push(`Would overwrite ${state.relative}.`);
|
|
70
|
+
if (state.status === 'missing') lines.push(`Would restore ${state.relative}.`);
|
|
71
|
+
if (state.status === 'orphaned' && state.exists) lines.push(`Would remove ${state.relative}.`);
|
|
72
|
+
if (state.status === 'modified' && !state.item) lines.push(`Would preserve modified orphan ${state.relative}.`);
|
|
73
|
+
}
|
|
74
|
+
if (!hookPresent) lines.push('Would register SubagentStop hook.');
|
|
75
|
+
lines.push('Would write installation record with new fingerprints.');
|
|
76
|
+
if (legacy && states.some((state) => state.status === 'modified')) {
|
|
77
|
+
lines.push('Installation record has no fingerprints; differing files are treated as modified.');
|
|
78
|
+
}
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function update({ host, dryRun = false, force = false, packageRoot } = {}) {
|
|
83
|
+
const record = await readInstallRecord(host);
|
|
84
|
+
if (!record) {
|
|
85
|
+
return { exitCode: 1, output: 'codex-bridge is not installed. Run codex-bridge install first.' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const plan = await buildInstallPlan(host, packageRoot);
|
|
89
|
+
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
90
|
+
const planned = new Map(plan.map((item) => [item.relativeToHost, item]));
|
|
91
|
+
const plannedStates = await Promise.all(plan.map(async (item) => {
|
|
92
|
+
const relative = item.relativeToHost;
|
|
93
|
+
const targetExists = await exists(item.target);
|
|
94
|
+
const state = {
|
|
95
|
+
relative,
|
|
96
|
+
item,
|
|
97
|
+
recorded: record.files.includes(relative),
|
|
98
|
+
recordedFingerprint: record.fingerprints?.[relative],
|
|
99
|
+
exists: targetExists,
|
|
100
|
+
matchesPackage: targetExists && await targetMatches(item, host.agentsDir),
|
|
101
|
+
fingerprint: targetExists ? await fileFingerprint(item.target) : null,
|
|
102
|
+
};
|
|
103
|
+
return { ...state, status: classifyPlanned(state, record) };
|
|
104
|
+
}));
|
|
105
|
+
const ruleExists = await exists(rule.target);
|
|
106
|
+
const ruleState = {
|
|
107
|
+
relative: rule.target,
|
|
108
|
+
item: rule,
|
|
109
|
+
recorded: Boolean(record.rules),
|
|
110
|
+
recordedFingerprint: record.rules?.fingerprint,
|
|
111
|
+
exists: ruleExists,
|
|
112
|
+
matchesPackage: ruleExists && await targetMatches(rule, host.agentsDir),
|
|
113
|
+
fingerprint: ruleExists ? await fileFingerprint(rule.target) : null,
|
|
114
|
+
};
|
|
115
|
+
ruleState.status = classifyPlanned(ruleState, record);
|
|
116
|
+
const orphanStates = await Promise.all(record.files
|
|
117
|
+
.filter((relative) => !planned.has(relative))
|
|
118
|
+
.map(async (relative) => {
|
|
119
|
+
const target = path.join(host.root, relative);
|
|
120
|
+
const targetExists = await exists(target);
|
|
121
|
+
const state = {
|
|
122
|
+
relative,
|
|
123
|
+
item: null,
|
|
124
|
+
exists: targetExists,
|
|
125
|
+
fingerprint: targetExists ? await fileFingerprint(target) : null,
|
|
126
|
+
};
|
|
127
|
+
return { ...state, status: classifyOrphan(state, record) };
|
|
128
|
+
}));
|
|
129
|
+
const states = [...plannedStates, ruleState, ...orphanStates];
|
|
130
|
+
const conflicts = states.filter((state) => state.status === 'modified' || state.status === 'missing');
|
|
131
|
+
const legacy = !record.fingerprints;
|
|
132
|
+
if (conflicts.length && !force) {
|
|
133
|
+
return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const guardPath = path.join(host.agentsDir, 'hooks', 'reply-guard.mjs');
|
|
137
|
+
const inspectedHook = await inspectHook(host.settingsPath, guardPath);
|
|
138
|
+
const currentPackage = await packageInfo(packageRoot);
|
|
139
|
+
const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
|
|
140
|
+
new Map(plannedStates.map((state) => [state.relative, state.fingerprint])),
|
|
141
|
+
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
142
|
+
const changed = states.some((state) => state.status !== 'up-to-date');
|
|
143
|
+
if (!changed && inspectedHook.present && recordCurrent) {
|
|
144
|
+
return { exitCode: 0, output: 'codex-bridge is up to date' };
|
|
145
|
+
}
|
|
146
|
+
if (dryRun) {
|
|
147
|
+
return { exitCode: 0, output: dryRunOutput(states, inspectedHook.present, legacy) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const state of orphanStates) {
|
|
151
|
+
if (state.status === 'orphaned' && state.exists) await fs.rm(path.join(host.root, state.relative));
|
|
152
|
+
}
|
|
153
|
+
const installed = await install({ host, force: true, packageRoot });
|
|
154
|
+
if (installed.exitCode !== 0) return installed;
|
|
155
|
+
return { exitCode: 0, output: appliedOutput(states) };
|
|
156
|
+
}
|