@lyupro/codex-bridge 0.3.1 → 0.5.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +88 -84
  3. package/bin/codex-bridge.mjs +38 -11
  4. package/cli/copy.mjs +7 -6
  5. package/cli/doctor.mjs +73 -12
  6. package/cli/hook-targets.mjs +39 -0
  7. package/cli/hook.mjs +34 -0
  8. package/cli/hosts.mjs +28 -4
  9. package/cli/install-record.mjs +287 -0
  10. package/cli/install.mjs +73 -39
  11. package/cli/invoked-directly.mjs +37 -0
  12. package/cli/manifest.mjs +90 -164
  13. package/cli/permissions.mjs +179 -0
  14. package/cli/projects.mjs +20 -5
  15. package/cli/prune-args.mjs +1 -1
  16. package/cli/remove-layout.mjs +59 -0
  17. package/cli/rules-owners.mjs +6 -8
  18. package/cli/runs-inventory.mjs +2 -1
  19. package/cli/settings-merge.mjs +182 -50
  20. package/cli/stop.mjs +33 -20
  21. package/cli/uninstall.mjs +64 -36
  22. package/cli/unlock.mjs +239 -0
  23. package/cli/update.mjs +109 -48
  24. package/package.json +2 -2
  25. package/scripts/check-file-size.mjs +8 -10
  26. package/scripts/isolated-roots.mjs +13 -0
  27. package/scripts/run-tests.mjs +14 -4
  28. package/src/agents/codex-build.md +17 -13
  29. package/src/agents/codex-review.md +15 -9
  30. package/src/agents/codex-scout.md +16 -11
  31. package/src/commands/env.md +4 -4
  32. package/src/commands/usage.md +8 -6
  33. package/src/heartbeat.mjs +1 -1
  34. package/src/hook-definitions.mjs +17 -4
  35. package/src/hooks/guard-tries.mjs +59 -0
  36. package/src/hooks/live-runs.mjs +31 -20
  37. package/src/hooks/order-gate.mjs +7 -2
  38. package/src/hooks/reply-guard.mjs +4 -60
  39. package/src/hooks/stop-guard.mjs +71 -0
  40. package/src/json-file.mjs +30 -0
  41. package/src/meta/chain.mjs +8 -6
  42. package/src/meta/paths.mjs +5 -9
  43. package/src/meta/pre-start.mjs +30 -0
  44. package/src/meta/run-state.mjs +31 -16
  45. package/src/process-identity.mjs +140 -0
  46. package/src/required-inputs.mjs +42 -0
  47. package/src/run-config.mjs +4 -8
  48. package/src/runner/args.mjs +26 -3
  49. package/src/runner/attach.mjs +32 -19
  50. package/src/runner/codex-cmd.mjs +1 -1
  51. package/src/runner/continuation.mjs +34 -3
  52. package/src/runner/launcher.mjs +54 -28
  53. package/src/runner/project-dir.mjs +4 -3
  54. package/src/runner/scope-check.mjs +159 -0
  55. package/src/runner/worker.mjs +13 -5
  56. package/src/stop-contract.mjs +15 -0
  57. package/src/write-meta.mjs +12 -1
  58. package/cli/sweep.mjs +0 -144
package/cli/manifest.mjs CHANGED
@@ -1,24 +1,52 @@
1
- /** Defines install mappings and validates the persisted installation record. */
1
+ /** Defines the package install mappings and renders files before they are copied. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
- // The hook table lives under src/ because the installed gate reads it too: cli/ is not copied
7
- // into a host, so a definition kept here would be invisible to the very hook it describes.
8
6
  import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
9
7
  import {
10
8
  renderRequiredInputSummary,
11
9
  renderRequiredInputs,
12
10
  } from '../src/required-inputs.mjs';
13
11
  import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
14
-
15
- export { HOOK_DEFINITIONS };
12
+ import { renderStopSummary } from '../src/stop-contract.mjs';
13
+ import { readJsonFile } from '../src/json-file.mjs';
14
+ import {
15
+ INSTALL_RECORD_NAME,
16
+ RULES_NAME,
17
+ definitionForRecordedHook,
18
+ fileEntry,
19
+ installRecordPath,
20
+ legacyInstallRecordPath,
21
+ normalizeInstallRecord,
22
+ readInstallRecord,
23
+ recordFileKey,
24
+ recordMatchesPackage,
25
+ recordTarget,
26
+ validateInstallRecord,
27
+ writeInstallRecord,
28
+ } from './install-record.mjs';
29
+
30
+ export {
31
+ HOOK_DEFINITIONS,
32
+ INSTALL_RECORD_NAME,
33
+ RULES_NAME,
34
+ definitionForRecordedHook,
35
+ installRecordPath,
36
+ legacyInstallRecordPath,
37
+ normalizeInstallRecord,
38
+ readInstallRecord,
39
+ recordFileKey,
40
+ recordMatchesPackage,
41
+ recordTarget,
42
+ validateInstallRecord,
43
+ writeInstallRecord,
44
+ };
16
45
 
17
46
  const HERE = path.dirname(fileURLToPath(import.meta.url));
18
47
  export const PACKAGE_ROOT = path.resolve(HERE, '..');
19
- export const INSTALL_RECORD_NAME = '.codex-bridge-install.json';
20
- export const RULES_NAME = 'codex-bridge.rules';
21
48
  const RULES_SOURCE = `src/rules/${RULES_NAME}`;
49
+
22
50
  /**
23
51
  * Files the operator owns once they exist. Seeded on first install so the defaults are
24
52
  * visible and editable, then never written, never compared, never removed: run-config.json
@@ -27,47 +55,54 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
27
55
  * update — the same way overwriting a .env would.
28
56
  */
29
57
  export const SEEDED_SOURCES = Object.freeze(['src/run-config.json', 'src/conventions.md']);
30
- const seededTargets = (host) =>
31
- new Set(SEEDED_SOURCES.map((source) => posix(
32
- path.relative(host.root, path.join(host.agentsDir, path.relative('src', source))),
33
- )));
58
+
34
59
  export const INSTALL_TABLE = Object.freeze([
35
- { source: 'src/agents/*.md', target: 'agentsDir', processing: 'placeholders' },
36
- { source: 'src/commands/*.md', target: 'commandsDir', processing: 'placeholders' },
37
- { source: 'src/**', target: 'agentsDir', processing: 'copy' },
60
+ { source: 'src/agents/*.md', root: 'claude', target: 'agentsDir', processing: 'placeholders' },
61
+ { source: 'src/commands/*.md', root: 'claude', target: 'commandsDir', processing: 'placeholders' },
62
+ { source: 'src/hooks/**', root: 'brand', target: 'brandHooksDir', processing: 'copy' },
63
+ { source: 'src/**', root: 'brand', target: 'brandRunnerDir', processing: 'copy' },
64
+ // The runner reads its own version out of the package.json beside it, so meta.json can say
65
+ // which code wrote a run (Plan_34). In the clone that file sits one level above src/; without
66
+ // this line the installed layout has nothing one level above lib/, and every run on a real host
67
+ // would die on import while the whole suite stayed green against the clone.
68
+ { source: 'package.json', root: 'brand', target: 'brandRoot', processing: 'copy' },
38
69
  ]);
39
70
 
40
71
  const posix = (value) => value.split(path.sep).join('/');
41
72
 
42
- export function replacePlaceholders(content, agentsDir) {
73
+ export function replacePlaceholders(content, installationRoot) {
43
74
  const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
44
75
  return content
45
- .replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)))
76
+ .replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(installationRoot)))
46
77
  .replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
47
78
  .replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
48
- .replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution());
79
+ .replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
80
+ .replaceAll('{{CODEX_STOP_SUMMARY}}', renderStopSummary());
49
81
  }
50
82
 
51
83
  export async function packageInfo(packageRoot = PACKAGE_ROOT) {
52
- const parsed = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
84
+ const parsed = await readJsonFile(path.join(packageRoot, 'package.json'));
53
85
  return { name: parsed.name, version: parsed.version };
54
86
  }
55
87
 
56
- export function installRecordPath(host) {
57
- return path.join(host.agentsDir, INSTALL_RECORD_NAME);
58
- }
59
-
60
88
  export function installedHookPath(host, definition) {
61
- return path.join(host.agentsDir, 'hooks', definition.file);
89
+ return path.join(host.brandHooksDir, definition.file);
62
90
  }
63
91
 
64
92
  /** Where each seeded file comes from and where it goes; contents are copied verbatim. */
65
93
  export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
66
- return SEEDED_SOURCES.map((source) => ({
67
- source: path.join(packageRoot, source),
68
- target: path.join(host.agentsDir, path.relative('src', source)),
69
- processing: 'copy',
70
- }));
94
+ return SEEDED_SOURCES.map((source) => {
95
+ const target = source.endsWith('run-config.json')
96
+ ? host.brandConfigPath
97
+ : host.brandConventionsPath;
98
+ return {
99
+ source: path.join(packageRoot, source),
100
+ target,
101
+ root: 'brand',
102
+ relativeToRoot: posix(path.relative(host.brandRoot, target)),
103
+ processing: 'copy',
104
+ };
105
+ });
71
106
  }
72
107
 
73
108
  export function rulesPlan(host, packageRoot = PACKAGE_ROOT) {
@@ -87,140 +122,24 @@ export async function fileFingerprint(absolutePath) {
87
122
  }
88
123
  }
89
124
 
90
- /**
91
- * Whether the recorded installation is exactly what this package would install right now. Install
92
- * and update both decide "nothing to do" by this question, so it is answered in one place: two
93
- * copies of the condition would drift the first time a field is added to the record.
94
- */
95
- export function recordMatchesPackage(record, plan, currentPackage, fingerprints, ruleState) {
96
- return Boolean(record)
97
- && record.name === currentPackage.name
98
- && record.version === currentPackage.version
99
- && record.files.length === plan.length
100
- && record.files.every((file, index) => file === plan[index].relativeToHost)
101
- && Boolean(record.fingerprints)
102
- && plan.every((item) => record.fingerprints[item.relativeToHost] === fingerprints.get(item.relativeToHost))
103
- && (!ruleState || (record.rules?.path === ruleState.path
104
- && record.rules.fingerprint === ruleState.fingerprint));
105
- }
106
-
107
- export function validateInstallRecord(record) {
108
- if (!record || typeof record !== 'object' || Array.isArray(record)) {
109
- throw new Error('installation record must be an object');
110
- }
111
- const strings = ['name', 'version', 'installedAt', 'mode'];
112
- for (const key of strings) {
113
- if (typeof record[key] !== 'string' || !record[key]) throw new Error(`installation record has invalid ${key}`);
114
- }
115
- if (record.mode !== 'copy') throw new Error('installation record mode must be copy');
116
- if (Number.isNaN(Date.parse(record.installedAt))) throw new Error('installation record installedAt is invalid');
117
- if (!Array.isArray(record.files) || !record.files.length || record.files.some((file) => typeof file !== 'string' || !file)) {
118
- throw new Error('installation record files must be a non-empty list of strings');
119
- }
120
- if (record.files.some((file) => path.isAbsolute(file) || file.split(/[\\/]/).some((part) => part === '..' || part === '.'))) {
121
- throw new Error('installation record files must stay relative to the host root');
122
- }
123
- if (new Set(record.files).size !== record.files.length) {
124
- throw new Error('installation record files must not contain duplicates');
125
- }
126
- // Run artifacts are the user's data, not ours. Refusing them here means neither uninstall nor
127
- // update needs its own guard against deleting a run folder someone listed as an installed file.
128
- if (record.files.some((file) => file === 'codex-runs' || file.split(/[\\/]/)[0] === 'codex-runs')) {
129
- throw new Error('installation record files must not name run artifacts under codex-runs');
130
- }
131
- if (record.fingerprints !== undefined) {
132
- if (!record.fingerprints || typeof record.fingerprints !== 'object' || Array.isArray(record.fingerprints)) {
133
- throw new Error('installation record fingerprints must be an object');
134
- }
135
- const fingerprintKeys = Object.keys(record.fingerprints);
136
- if (fingerprintKeys.length !== record.files.length || fingerprintKeys.some((file) => !record.files.includes(file))) {
137
- throw new Error('installation record fingerprints keys must exactly match files');
138
- }
139
- if (Object.values(record.fingerprints).some((fingerprint) =>
140
- typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/i.test(fingerprint))) {
141
- throw new Error('installation record fingerprints must be 64-character hexadecimal SHA256 strings');
142
- }
143
- }
144
- if (record.rules !== undefined) {
145
- if (!record.rules || typeof record.rules !== 'object' || Array.isArray(record.rules)) {
146
- throw new Error('installation record rules must be an object');
147
- }
148
- if (typeof record.rules.path !== 'string' || !record.rules.path) {
149
- throw new Error('installation record rules path must be a non-empty string');
150
- }
151
- if (path.basename(record.rules.path) !== RULES_NAME) {
152
- throw new Error(`installation record rules path must name ${RULES_NAME}`);
153
- }
154
- if (typeof record.rules.fingerprint !== 'string'
155
- || !/^[a-f0-9]{64}$/i.test(record.rules.fingerprint)) {
156
- throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
157
- }
158
- }
159
- if (record.hooks !== undefined && record.hook !== undefined) {
160
- throw new Error('installation record must use hooks instead of hook');
161
- }
162
- const hooks = record.hooks !== undefined ? record.hooks : record.hook !== undefined ? [record.hook] : null;
163
- if (!Array.isArray(hooks) || !hooks.length) {
164
- throw new Error('installation record hooks must be a non-empty list');
165
- }
166
- for (const hook of hooks) {
167
- if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
168
- throw new Error('installation record hook must be an object');
169
- }
170
- const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
171
- if (!definitions.length || typeof hook.path !== 'string' || !hook.path) {
172
- throw new Error('installation record hook must identify a supported event and its path');
173
- }
174
- if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
175
- throw new Error('installation record hook path must stay relative to the host root');
176
- }
177
- const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
178
- if (!definition || !record.files.includes(hook.path)) {
179
- const files = definitions.map((entry) => entry.file).join(' or ');
180
- throw new Error(`installation record hook path must name the installed ${files}`);
181
- }
182
- }
183
- return record;
125
+ function targetBase(host, mapping) {
126
+ const base = host[mapping.target];
127
+ if (!base) throw new Error(`host has no ${mapping.target} install target`);
128
+ return base;
184
129
  }
185
130
 
186
- export function normalizeInstallRecord(record) {
187
- validateInstallRecord(record);
188
- if (record.hooks !== undefined) return record;
189
- const { hook, ...withoutLegacyHook } = record;
190
- return { ...withoutLegacyHook, hooks: [hook] };
131
+ function rootFor(host, mapping) {
132
+ return mapping.root === 'brand' ? host.brandRoot : host.root;
191
133
  }
192
134
 
193
- export async function readInstallRecord(host) {
194
- let raw;
195
- try {
196
- raw = await fs.readFile(installRecordPath(host), 'utf8');
197
- } catch (err) {
198
- if (err.code === 'ENOENT') return null;
199
- throw err;
200
- }
201
- let parsed;
202
- try {
203
- parsed = JSON.parse(raw);
204
- } catch (err) {
205
- throw new Error(`invalid installation record JSON: ${err.message}`);
135
+ function targetRelative(packageRoot, source, mapping) {
136
+ if (mapping.processing === 'placeholders' || mapping.source === 'package.json') {
137
+ return path.basename(source);
206
138
  }
207
- const record = normalizeInstallRecord(parsed);
208
- // Earlier versions installed the config as a package file and recorded it. Dropping it here
209
- // is what stops the next update from calling the operator's edited config an orphan and
210
- // deleting it, and it needs no migration step: the record is rewritten on the next write.
211
- const seeded = seededTargets(host);
212
- record.files = record.files.filter((file) => !seeded.has(file));
213
- if (record.fingerprints) {
214
- record.fingerprints = Object.fromEntries(
215
- Object.entries(record.fingerprints).filter(([file]) => !seeded.has(file)),
216
- );
217
- }
218
- return record;
219
- }
220
-
221
- export async function writeInstallRecord(host, record) {
222
- const normalized = normalizeInstallRecord(record);
223
- await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
139
+ const sourceRoot = mapping.source === 'src/hooks/**'
140
+ ? path.join(packageRoot, 'src', 'hooks')
141
+ : path.join(packageRoot, 'src');
142
+ return path.relative(sourceRoot, source);
224
143
  }
225
144
 
226
145
  export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
@@ -228,22 +147,29 @@ export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
228
147
  const claimedSources = [];
229
148
  for (const mapping of INSTALL_TABLE) {
230
149
  for await (const relative of fs.glob(mapping.source, { cwd: packageRoot, exclude: claimedSources })) {
231
- if (posix(relative) === RULES_SOURCE) continue;
232
- if (SEEDED_SOURCES.includes(posix(relative))) continue;
150
+ if (posix(relative) === RULES_SOURCE || SEEDED_SOURCES.includes(posix(relative))) continue;
233
151
  const source = path.join(packageRoot, relative);
234
152
  if (!(await fs.stat(source)).isFile()) continue;
235
- const targetRelative = mapping.processing === 'copy'
236
- ? path.relative(path.join(packageRoot, 'src'), source)
237
- : path.basename(source);
238
- const target = path.join(host[mapping.target], targetRelative);
153
+ const relativeToTarget = posix(targetRelative(packageRoot, source, mapping));
154
+ const target = path.join(targetBase(host, mapping), relativeToTarget);
155
+ const relativeToRoot = posix(path.relative(rootFor(host, mapping), target));
239
156
  files.push({
240
157
  source,
241
158
  target,
159
+ root: mapping.root,
160
+ relativeToRoot,
242
161
  relativeToHost: posix(path.relative(host.root, target)),
243
162
  processing: mapping.processing,
163
+ installationRoot: mapping.processing === 'placeholders'
164
+ ? host.brandRunnerDir
165
+ : rootFor(host, mapping),
244
166
  });
245
167
  }
246
168
  claimedSources.push(mapping.source);
247
169
  }
248
- return files.sort((a, b) => a.relativeToHost.localeCompare(b.relativeToHost));
170
+ return files.sort((a, b) => recordFileKey(a).localeCompare(recordFileKey(b)));
171
+ }
172
+
173
+ export function targetForPlanItem(host, item) {
174
+ return recordTarget(host, fileEntry({ root: item.root, path: item.relativeToRoot }));
249
175
  }
@@ -0,0 +1,179 @@
1
+ /** Manages the optional shell permission rules for every codex-bridge command spelling. */
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { CLI_NAMES } from '../src/cli-names.mjs';
5
+ import { SHELL_TOOLS } from '../src/hook-definitions.mjs';
6
+ import { readSettings, updateSettings, withSettingsRun } from './settings-merge.mjs';
7
+
8
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const entryPoint = fileURLToPath(new URL('../bin/codex-bridge.mjs', import.meta.url));
10
+ const nodeCommand = `node ${path.relative(packageRoot, entryPoint).split(path.sep).join('/')}`;
11
+ const commandNames = Object.freeze([...CLI_NAMES, nodeCommand]);
12
+ const formsFor = (command) => [command, `${command}:*`];
13
+
14
+ function buildRules(commandTransform) {
15
+ return Object.freeze(SHELL_TOOLS.flatMap((tool) => commandNames.flatMap((command) =>
16
+ formsFor(commandTransform(command)).map((form) => `${tool}(${form})`))));
17
+ }
18
+
19
+ /**
20
+ * The executable names stay owned by cli-names.mjs; only the clone entry point is derived here.
21
+ * Plan_22 records the incident because repeating the node spelling in CLI_NAMES would make the
22
+ * source list answer two different questions and let a future alias bypass one of them.
23
+ *
24
+ * The deny half subtracts `prune` from the allow half rather than guarding it: `Bash(codex-bridge:*)`
25
+ * would otherwise hand out the deletion this package refuses to give an agent. Its refusal is never
26
+ * seen in a live session — a PreToolUse hook answers before permissions are consulted, and
27
+ * prune-guard is installed always while these rules are optional. The 2026-08-09 run held that
28
+ * observation open as a checklist item until it turned out to be the answer, not the obstacle.
29
+ */
30
+ export const PERMISSION_RULES = Object.freeze({
31
+ allow: buildRules((command) => command),
32
+ deny: buildRules((command) => `${command} prune`),
33
+ });
34
+
35
+ const allRules = new Set([...PERMISSION_RULES.allow, ...PERMISSION_RULES.deny]);
36
+ const ruleLists = Object.freeze(['allow', 'deny', 'ask']);
37
+ const activeLists = Object.freeze(['allow', 'deny']);
38
+ const totalRules = PERMISSION_RULES.allow.length + PERMISSION_RULES.deny.length;
39
+
40
+ function listValue(settings, name) {
41
+ const list = settings?.permissions?.[name];
42
+ return Array.isArray(list) ? list : [];
43
+ }
44
+
45
+ function matchingRules(list, rules) {
46
+ return rules.filter((rule) => list.includes(rule)).length;
47
+ }
48
+
49
+ /**
50
+ * A count of strings in allow and deny is not the state of the set, because `ask` outranks `allow`:
51
+ * a copy sitting there keeps asking the question the rule was added to end. The live run of
52
+ * Plan_22-1 caught doctor reporting `installed (24/24)` over exactly that arrangement — the number
53
+ * was true and the conclusion drawn from it was false. Every consumer reads the state, so the
54
+ * shadow has to live in the state rather than in one command's wording.
55
+ */
56
+ function permissionStatus(settings) {
57
+ const counts = Object.fromEntries(activeLists.map((name) => [
58
+ name,
59
+ matchingRules(listValue(settings, name), PERMISSION_RULES[name]),
60
+ ]));
61
+ const askCount = matchingRules(listValue(settings, 'ask'), [...allRules]);
62
+ const present = counts.allow + counts.deny;
63
+ const complete = present === totalRules;
64
+ const state = askCount
65
+ ? (complete ? 'shadowed by ask' : 'partially installed, shadowed by ask')
66
+ : complete ? 'installed' : present === 0 ? 'absent' : 'partially installed';
67
+ return {
68
+ state,
69
+ complete,
70
+ counts,
71
+ askCount,
72
+ present,
73
+ total: totalRules,
74
+ };
75
+ }
76
+
77
+ function ensureList(settings, name) {
78
+ if (settings.permissions === undefined) settings.permissions = {};
79
+ if (!settings.permissions || typeof settings.permissions !== 'object'
80
+ || Array.isArray(settings.permissions)) {
81
+ throw new TypeError('settings.permissions must be an object');
82
+ }
83
+ if (settings.permissions[name] === undefined) settings.permissions[name] = [];
84
+ if (!Array.isArray(settings.permissions[name])) {
85
+ throw new TypeError(`settings.permissions.${name} must be an array`);
86
+ }
87
+ return settings.permissions[name];
88
+ }
89
+
90
+ function countRemovable(settings) {
91
+ return ruleLists.reduce((total, name) => total + listValue(settings, name)
92
+ .filter((entry) => allRules.has(entry)).length, 0);
93
+ }
94
+
95
+ export async function inspectPermissions(settingsPath) {
96
+ const state = await readSettings(settingsPath);
97
+ return { ...state, ...permissionStatus(state.settings) };
98
+ }
99
+
100
+ export async function addPermissionRules(settingsPath) {
101
+ return updateSettings(settingsPath, (settings) => {
102
+ let added = 0;
103
+ for (const name of activeLists) {
104
+ const list = ensureList(settings, name);
105
+ for (const rule of PERMISSION_RULES[name]) {
106
+ if (list.includes(rule)) continue;
107
+ list.push(rule);
108
+ added += 1;
109
+ }
110
+ }
111
+ return { changed: added > 0, added };
112
+ });
113
+ }
114
+
115
+ export async function removePermissionRules(settingsPath, { dryRun = false } = {}) {
116
+ if (dryRun) {
117
+ const state = await readSettings(settingsPath);
118
+ return { changed: false, removed: countRemovable(state.settings) };
119
+ }
120
+ return updateSettings(settingsPath, (settings) => {
121
+ let removed = 0;
122
+ for (const name of ruleLists) {
123
+ const list = settings?.permissions?.[name];
124
+ if (!Array.isArray(list)) continue;
125
+ settings.permissions[name] = list.filter((entry) => {
126
+ if (!allRules.has(entry)) return true;
127
+ removed += 1;
128
+ return false;
129
+ });
130
+ }
131
+ return { changed: removed > 0, removed };
132
+ });
133
+ }
134
+
135
+ function statusOutput(status) {
136
+ const moved = status.askCount
137
+ ? `; ${status.askCount} own string(s) in ask, which outranks allow`
138
+ : '';
139
+ return `Permissions: ${status.state} (${status.present}/${status.total} own strings in allow/deny${moved}).`;
140
+ }
141
+
142
+ async function permissionsInRun({ host, action } = {}) {
143
+ if (!host?.settingsPath) throw new TypeError('permissions requires a resolved host');
144
+ if (!action) {
145
+ const status = await inspectPermissions(host.settingsPath);
146
+ return { exitCode: 0, output: statusOutput(status), ...status };
147
+ }
148
+ if (action === 'add') {
149
+ const result = await addPermissionRules(host.settingsPath);
150
+ // A string the operator moved into `ask` outranks the copy `add` just put into `allow`, so the
151
+ // command would report a complete set while the questions it was run to end keep appearing.
152
+ // `ask` stays untouched — it is a hand-made decision — but silence about it is a false success.
153
+ const status = await inspectPermissions(host.settingsPath);
154
+ const conflict = status.askCount
155
+ ? ` ${status.askCount} own string(s) also sit in ask, which outranks allow; they were left there.`
156
+ : '';
157
+ return {
158
+ exitCode: 0,
159
+ output: `Added ${result.added} permission rule string${result.added === 1 ? '' : 's'}.${conflict}`,
160
+ ...result,
161
+ askCount: status.askCount,
162
+ };
163
+ }
164
+ if (action === 'remove') {
165
+ const result = await removePermissionRules(host.settingsPath);
166
+ return {
167
+ exitCode: 0,
168
+ output: `Removed ${result.removed} permission rule string${result.removed === 1 ? '' : 's'}.`,
169
+ ...result,
170
+ };
171
+ }
172
+ throw new Error(`unknown permissions action "${action}"`);
173
+ }
174
+
175
+ export async function permissions(options = {}) {
176
+ const host = options?.host;
177
+ if (!host?.settingsPath) return permissionsInRun(options);
178
+ return withSettingsRun(host.settingsPath, () => permissionsInRun(options));
179
+ }
package/cli/projects.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  /** Implements the projects command. */
2
2
  import { listProjectRuns, listProjects } from './runs-inventory.mjs';
3
3
  import { renderTable } from './table.mjs';
4
+ import { allLiveRuns } from '../src/hooks/live-runs.mjs';
5
+ import { runsRoot } from '../src/runner/runs-root.mjs';
6
+ import { STOP_COMMAND_TEMPLATE } from '../src/stop-contract.mjs';
4
7
 
5
8
  // The table renders whatever it is given; how a byte count or a timestamp reads is this command's
6
9
  // business. Raw bytes and ISO milliseconds are technically exact and practically unreadable — an
@@ -75,9 +78,21 @@ function parseArgs(args) {
75
78
  return { json, projectName: positional[0] || null };
76
79
  }
77
80
 
78
- function output(rows, columns, options) {
81
+ /**
82
+ * Silence when nothing is running: this line exists to interrupt the operator who believes every
83
+ * run is stopped, and a "0 runs" line printed under every listing is the fastest way to teach them
84
+ * not to read it.
85
+ */
86
+ function workingRunsLine(root) {
87
+ const count = allLiveRuns(root, { requireConfirmedIdentity: true }).length;
88
+ if (!count) return '';
89
+ const noun = count === 1 ? 'run' : 'runs';
90
+ return `\n${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`;
91
+ }
92
+
93
+ function output(rows, columns, options, root) {
79
94
  if (options.json) return JSON.stringify(rows, null, 2);
80
- return renderTable(columns, rows, options.terminalWidth);
95
+ return `${renderTable(columns, rows, options.terminalWidth)}${workingRunsLine(root)}`;
81
96
  }
82
97
 
83
98
  /** Lists projects or runs for one project. */
@@ -86,7 +101,7 @@ export function projects(argv = [], options = {}) {
86
101
  if (parsed.error) return { exitCode: 2, output: parsed.error };
87
102
 
88
103
  const { projectName } = parsed;
89
- const root = options.runsRootPath;
104
+ const root = options.runsRootPath || runsRoot();
90
105
  const renderOptions = { json: parsed.json, terminalWidth: options.terminalWidth };
91
106
 
92
107
  if (projectName) {
@@ -97,9 +112,9 @@ export function projects(argv = [], options = {}) {
97
112
  output: `codex-bridge projects: unknown project "${projectName}"`,
98
113
  };
99
114
  }
100
- return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions) };
115
+ return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions, root) };
101
116
  }
102
117
 
103
118
  const rows = listProjects(root);
104
- return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions) };
119
+ return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions, root) };
105
120
  }
@@ -112,7 +112,7 @@ export function parsePruneArgs(argv = []) {
112
112
  if (invalid) return invalid;
113
113
  }
114
114
 
115
- // The 30-day default belongs to the gentle sweep, which searches history for weight to shed.
115
+ // The 30-day default belongs to the gentle cleanup, which searches history for weight to shed.
116
116
  // A purge is never implicitly aged: `prune sbx2 --purge` means that folder, now. Defaulting it
117
117
  // made the plan's own fourth scenario unreachable — a young project answered "nothing to
118
118
  // remove", which reads as a broken command, not as a refusal.
@@ -0,0 +1,59 @@
1
+ /** Removes emptied directories of an installed layout without walking out of the package's own. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * These four helpers existed as two copies — one in uninstall.mjs, one in update.mjs — and the
7
+ * copies had already drifted before this file was written: update's removeEmptyParents inlined the
8
+ * readdir/rmdir pair instead of calling removeEmpty, and only uninstall's removeEmptyLayout
9
+ * guarded a missing directory. Nothing failed, which is exactly the problem: the same divergence
10
+ * between the installer's file list and the hook's list went unnoticed until Plan_19 had to
11
+ * reconcile them. Removal logic decides what disappears from the operator's ~/.claude, so it gets
12
+ * one definition.
13
+ */
14
+
15
+ export async function removeEmpty(directory) {
16
+ try {
17
+ if ((await fs.readdir(directory)).length === 0) await fs.rmdir(directory);
18
+ } catch (err) {
19
+ if (err.code !== 'ENOENT') throw err;
20
+ }
21
+ }
22
+
23
+ export async function removeEmptyParents(target, boundary) {
24
+ let current = path.dirname(target);
25
+ while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
26
+ await removeEmpty(current);
27
+ current = path.dirname(current);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * The directory an emptied-parent walk must stop at. Plan_25 gave the host four package-owned
33
+ * directories at once — the current agents and commands subdirectories and the two the previous
34
+ * layout used — and a walk that started inside one of them but stopped at host.root would delete
35
+ * the operator's own emptied directories on the way up.
36
+ */
37
+ export function claudeBoundary(host, target) {
38
+ const directories = [
39
+ host.agentsDir,
40
+ host.commandsDir,
41
+ host.legacyAgentsDir,
42
+ host.legacyCommandsDir,
43
+ ].filter(Boolean);
44
+ return directories.find((directory) => target === directory
45
+ || target.startsWith(`${directory}${path.sep}`)) || host.root;
46
+ }
47
+
48
+ /**
49
+ * Takes down one emptied package-owned layout directory — and stops there.
50
+ *
51
+ * It deliberately does not walk into the parent: the parents here are `~/.claude/agents` and
52
+ * `~/.claude/commands`, which belong to Claude Code and are shared with every other agent the
53
+ * operator has. An operator whose only agents were ours would have had those directories deleted
54
+ * out from under Claude Code by an uninstall that was asked to remove our files. Directories
55
+ * *inside* our own are a different case and are still walked, bounded by claudeBoundary().
56
+ */
57
+ export async function removeEmptyLayout(directory) {
58
+ await removeEmpty(directory);
59
+ }
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import { normalizeRepoPath } from '../src/runner/project-dir.mjs';
6
+ import { readJsonFile } from '../src/json-file.mjs';
6
7
 
7
8
  export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
8
9
  export const RULES_REGISTRY_VERSION = 1;
@@ -40,19 +41,16 @@ function validateRegistry(parsed) {
40
41
  }
41
42
 
42
43
  async function loadRulesRegistry(host) {
43
- let raw;
44
+ let parsed;
44
45
  try {
45
- raw = await fs.readFile(rulesRegistryPath(host), 'utf8');
46
+ parsed = await readJsonFile(rulesRegistryPath(host));
46
47
  } catch (err) {
47
48
  if (err.code === 'ENOENT') return null;
49
+ if (err.cause) {
50
+ throw new Error(`invalid rules ownership registry JSON: ${err.cause.message}`, { cause: err.cause });
51
+ }
48
52
  throw err;
49
53
  }
50
- let parsed;
51
- try {
52
- parsed = JSON.parse(raw);
53
- } catch (err) {
54
- throw new Error(`invalid rules ownership registry JSON: ${err.message}`);
55
- }
56
54
  return { raw: parsed, registry: validateRegistry(parsed) };
57
55
  }
58
56