@lyupro/codex-bridge 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +420 -252
- package/README.md +21 -4
- package/bin/codex-bridge.mjs +4 -0
- package/cli/doctor.mjs +399 -293
- package/cli/frontmatter.mjs +137 -0
- package/cli/hook.mjs +2 -2
- package/cli/install-record.mjs +2 -2
- package/cli/install.mjs +14 -2
- package/cli/manifest.mjs +23 -22
- package/cli/permissions.mjs +2 -2
- package/cli/projects.mjs +3 -3
- package/cli/prune-plan.mjs +2 -2
- package/cli/prune.mjs +1 -1
- package/cli/read.mjs +2 -2
- package/cli/rules-owners.mjs +2 -2
- package/cli/run-lookup.mjs +3 -3
- package/cli/runs-inventory.mjs +3 -3
- package/cli/settings-merge.mjs +56 -11
- package/cli/stop.mjs +5 -5
- package/cli/unlock.mjs +7 -7
- package/package.json +2 -2
- package/scripts/check-file-size.mjs +1 -1
- package/scripts/run-tests.mjs +30 -1
- package/scripts/suite-count.mjs +65 -0
- package/src/agents/codex-build.md +265 -231
- package/src/agents/codex-review.md +219 -184
- package/src/agents/codex-scout.md +232 -200
- package/src/{hooks → home/hooks}/guard-tries.mjs +1 -1
- package/src/{hooks → home/hooks}/live-runs.mjs +43 -3
- package/src/home/hooks/order-gate.mjs +135 -0
- package/src/{hooks → home/hooks}/prune-guard.mjs +2 -2
- package/src/{hooks → home/hooks}/reply-guard.mjs +117 -97
- package/src/home/hooks/reply-parser.mjs +27 -0
- package/src/home/hooks/reply-verdicts.mjs +174 -0
- package/src/home/hooks/shell-write-intent.mjs +120 -0
- package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
- package/src/home/hooks/worktree-lock.mjs +109 -0
- package/src/home/hooks/worktree-witness.mjs +113 -0
- package/src/home/lib/dispatcher-order.mjs +36 -0
- package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +4 -1
- package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +11 -2
- package/src/home/lib/host-refusal.mjs +20 -0
- package/src/{json-file.mjs → home/lib/json-file.mjs} +4 -4
- package/src/{meta → home/lib/meta}/paths.mjs +20 -1
- package/src/{meta → home/lib/meta}/reply.mjs +1 -1
- package/src/{meta → home/lib/meta}/verdict.mjs +1 -1
- package/src/{no-self-execution.mjs → home/lib/no-self-execution.mjs} +7 -2
- package/src/{required-inputs.mjs → home/lib/required-inputs.mjs} +56 -5
- package/src/{retention.mjs → home/lib/retention.mjs} +1 -1
- package/src/{run-codex.mjs → home/lib/run-codex.mjs} +39 -11
- package/src/{run-config.mjs → home/lib/run-config.mjs} +2 -2
- package/src/{runner → home/lib/runner}/args.mjs +86 -10
- package/src/{runner → home/lib/runner}/attach.mjs +58 -9
- package/src/{runner → home/lib/runner}/codex-cmd.mjs +398 -392
- package/src/{runner → home/lib/runner}/launcher.mjs +33 -21
- package/src/home/lib/runner/order-owner.mjs +28 -0
- package/src/{runner → home/lib/runner}/run-env.mjs +1 -1
- package/src/home/lib/runner/sandbox-flags.mjs +27 -0
- package/src/home/lib/runner/task-file.mjs +92 -0
- package/src/home/lib/runner/task-input.mjs +26 -0
- package/src/{runner → home/lib/runner}/worker.mjs +1 -1
- package/src/home/lib/shell-unsafe.mjs +26 -0
- package/src/{write-meta.mjs → home/lib/write-meta.mjs} +12 -1
- package/src/hooks/order-gate.mjs +0 -73
- package/src/hooks/worktree-lock.mjs +0 -77
- /package/src/{run-config.json → home/config.json} +0 -0
- /package/src/{conventions.md → home/conventions.md} +0 -0
- /package/src/{cli-names.mjs → home/lib/cli-names.mjs} +0 -0
- /package/src/{meta → home/lib/meta}/chain.mjs +0 -0
- /package/src/{meta → home/lib/meta}/deadline.mjs +0 -0
- /package/src/{meta → home/lib/meta}/environment.mjs +0 -0
- /package/src/{meta → home/lib/meta}/events.mjs +0 -0
- /package/src/{meta → home/lib/meta}/outcome.mjs +0 -0
- /package/src/{meta → home/lib/meta}/pre-start.mjs +0 -0
- /package/src/{meta → home/lib/meta}/reason.mjs +0 -0
- /package/src/{meta → home/lib/meta}/run-state.mjs +0 -0
- /package/src/{meta → home/lib/meta}/startup.mjs +0 -0
- /package/src/{meta → home/lib/meta}/transport.mjs +0 -0
- /package/src/{process-identity.mjs → home/lib/process-identity.mjs} +0 -0
- /package/src/{runner → home/lib/runner}/continuation.mjs +0 -0
- /package/src/{runner → home/lib/runner}/conventions.mjs +0 -0
- /package/src/{runner → home/lib/runner}/git-state.mjs +0 -0
- /package/src/{runner → home/lib/runner}/project-dir.mjs +0 -0
- /package/src/{runner → home/lib/runner}/prompts.mjs +0 -0
- /package/src/{runner → home/lib/runner}/run-context.mjs +0 -0
- /package/src/{runner → home/lib/runner}/runs-root.mjs +0 -0
- /package/src/{runner → home/lib/runner}/schemas.mjs +0 -0
- /package/src/{runner → home/lib/runner}/scope-check.mjs +0 -0
- /package/src/{stop-contract.mjs → home/lib/stop-contract.mjs} +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and normalizes the flat frontmatter shipped by this package.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-08-10 agent-registration incident showed that placeholder text can turn a previously
|
|
5
|
+
* plain YAML value into invalid syntax. Keeping this boundary deliberately flat makes host
|
|
6
|
+
* readability testable without adding a general YAML parser or changing markdown bodies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PLAIN_START = /^(?:[-?:](?:[ \t]|$)|[,\[\]{}#&*!|>'"%@`])/;
|
|
10
|
+
|
|
11
|
+
function locateFrontmatter(content) {
|
|
12
|
+
const firstBreak = content.indexOf('\n');
|
|
13
|
+
if (firstBreak === -1 || content.slice(0, firstBreak).replace(/\r$/, '') !== '---') return null;
|
|
14
|
+
|
|
15
|
+
const blockStart = firstBreak + 1;
|
|
16
|
+
let lineStart = blockStart;
|
|
17
|
+
while (lineStart <= content.length) {
|
|
18
|
+
const nextBreak = content.indexOf('\n', lineStart);
|
|
19
|
+
const lineEnd = nextBreak === -1 ? content.length : nextBreak;
|
|
20
|
+
if (content.slice(lineStart, lineEnd).replace(/\r$/, '') === '---') {
|
|
21
|
+
return { blockStart, blockEnd: lineStart };
|
|
22
|
+
}
|
|
23
|
+
if (nextBreak === -1) break;
|
|
24
|
+
lineStart = nextBreak + 1;
|
|
25
|
+
}
|
|
26
|
+
throw new Error('frontmatter closing delimiter is missing');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function blockLines(block) {
|
|
30
|
+
const lines = [];
|
|
31
|
+
let start = 0;
|
|
32
|
+
while (start < block.length) {
|
|
33
|
+
const nextBreak = block.indexOf('\n', start);
|
|
34
|
+
const end = nextBreak === -1 ? block.length : nextBreak;
|
|
35
|
+
const hasCarriageReturn = block[end - 1] === '\r';
|
|
36
|
+
lines.push({
|
|
37
|
+
text: block.slice(start, hasCarriageReturn ? end - 1 : end),
|
|
38
|
+
ending: nextBreak === -1 ? '' : hasCarriageReturn ? '\r\n' : '\n',
|
|
39
|
+
});
|
|
40
|
+
if (nextBreak === -1) break;
|
|
41
|
+
start = nextBreak + 1;
|
|
42
|
+
}
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function splitEntry(line) {
|
|
47
|
+
const match = /^([A-Za-z0-9_-]+):(.*)$/.exec(line);
|
|
48
|
+
if (!match) throw new Error(`frontmatter line is not a flat key/value entry: ${line}`);
|
|
49
|
+
const remainder = match[2];
|
|
50
|
+
if (remainder && !/^[ \t]/.test(remainder)) {
|
|
51
|
+
throw new Error(`frontmatter value for ${match[1]} must follow YAML separator whitespace`);
|
|
52
|
+
}
|
|
53
|
+
return { key: match[1], rawValue: remainder ? remainder.slice(1) : '' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function decodeDoubleQuoted(value) {
|
|
57
|
+
if (!value.endsWith('"')) return null;
|
|
58
|
+
let decoded = '';
|
|
59
|
+
for (let index = 1; index < value.length - 1; index += 1) {
|
|
60
|
+
const character = value[index];
|
|
61
|
+
if (character === '"') return null;
|
|
62
|
+
if (character !== '\\') {
|
|
63
|
+
decoded += character;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const escaped = value[index + 1];
|
|
67
|
+
if (escaped !== '\\' && escaped !== '"') return null;
|
|
68
|
+
decoded += escaped;
|
|
69
|
+
index += 1;
|
|
70
|
+
}
|
|
71
|
+
return decoded;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function decodeSingleQuoted(value) {
|
|
75
|
+
if (!value.endsWith("'")) return null;
|
|
76
|
+
const inner = value.slice(1, -1);
|
|
77
|
+
if (/(^|[^'])'(?:[^']|$)/.test(inner)) return null;
|
|
78
|
+
return inner.replaceAll("''", "'");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function quotedValue(value) {
|
|
82
|
+
if (value.startsWith('"')) return decodeDoubleQuoted(value);
|
|
83
|
+
if (value.startsWith("'")) return decodeSingleQuoted(value);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function plainScalarIsSafe(value) {
|
|
88
|
+
return value.length > 0
|
|
89
|
+
&& value === value.trim()
|
|
90
|
+
&& !PLAIN_START.test(value)
|
|
91
|
+
&& !/:[ \t]/.test(value)
|
|
92
|
+
&& !/[ \t]#/.test(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseValue(rawValue) {
|
|
96
|
+
const decoded = quotedValue(rawValue);
|
|
97
|
+
if (decoded !== undefined) {
|
|
98
|
+
if (decoded === null) throw new Error('frontmatter contains an invalid quoted value');
|
|
99
|
+
return decoded;
|
|
100
|
+
}
|
|
101
|
+
if (!plainScalarIsSafe(rawValue)) {
|
|
102
|
+
throw new Error(`frontmatter contains an invalid plain scalar: ${rawValue}`);
|
|
103
|
+
}
|
|
104
|
+
return rawValue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function encodeValue(value) {
|
|
108
|
+
const decoded = quotedValue(value);
|
|
109
|
+
const intended = decoded === undefined || decoded === null ? value : decoded;
|
|
110
|
+
if (decoded === undefined && plainScalarIsSafe(value)) return value;
|
|
111
|
+
return `"${intended.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parseFrontmatter(content) {
|
|
115
|
+
const bounds = locateFrontmatter(content);
|
|
116
|
+
if (!bounds) return null;
|
|
117
|
+
const values = {};
|
|
118
|
+
for (const { text } of blockLines(content.slice(bounds.blockStart, bounds.blockEnd))) {
|
|
119
|
+
const { key, rawValue } = splitEntry(text);
|
|
120
|
+
if (Object.hasOwn(values, key)) throw new Error(`frontmatter contains duplicate key: ${key}`);
|
|
121
|
+
values[key] = parseValue(rawValue);
|
|
122
|
+
}
|
|
123
|
+
return values;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function normalizeFrontmatter(content) {
|
|
127
|
+
const bounds = locateFrontmatter(content);
|
|
128
|
+
if (!bounds) return content;
|
|
129
|
+
const keys = new Set();
|
|
130
|
+
const normalized = blockLines(content.slice(bounds.blockStart, bounds.blockEnd)).map(({ text, ending }) => {
|
|
131
|
+
const { key, rawValue } = splitEntry(text);
|
|
132
|
+
if (keys.has(key)) throw new Error(`frontmatter contains duplicate key: ${key}`);
|
|
133
|
+
keys.add(key);
|
|
134
|
+
return `${key}: ${encodeValue(rawValue)}${ending}`;
|
|
135
|
+
}).join('');
|
|
136
|
+
return `${content.slice(0, bounds.blockStart)}${normalized}${content.slice(bounds.blockEnd)}`;
|
|
137
|
+
}
|
package/cli/hook.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Dispatches a named host hook without changing the guard's stdin or process lifecycle. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
-
import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
|
|
4
|
+
import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
|
|
5
5
|
|
|
6
|
-
const HOOKS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'hooks');
|
|
6
|
+
const HOOKS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'home', 'hooks');
|
|
7
7
|
const VALID_NAMES = HOOK_DEFINITIONS.map(({ name }) => name);
|
|
8
8
|
|
|
9
9
|
function validNames() {
|
package/cli/install-record.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Reads, validates, and writes the installation record shared by installer commands. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { readJsonFile } from '../src/json-file.mjs';
|
|
5
|
-
import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
|
|
4
|
+
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
5
|
+
import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
|
|
6
6
|
|
|
7
7
|
export const INSTALL_RECORD_NAME = '.installed.json';
|
|
8
8
|
export const LEGACY_INSTALL_RECORD_NAME = '.codex-bridge-install.json';
|
package/cli/install.mjs
CHANGED
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
withSettingsRun,
|
|
26
26
|
} from './settings-merge.mjs';
|
|
27
27
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
28
|
-
import {
|
|
28
|
+
import { addPermissionRules } from './permissions.mjs';
|
|
29
|
+
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
29
30
|
|
|
30
31
|
const WARNING = '\u001b[33m';
|
|
31
32
|
const RESET = '\u001b[0m';
|
|
@@ -158,6 +159,13 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
|
|
|
158
159
|
}
|
|
159
160
|
const hookResults = [];
|
|
160
161
|
for (const { spec } of targets) hookResults.push(await mergeHook(host.settingsPath, spec));
|
|
162
|
+
// Uninstall has always removed these rules, and install never granted them — so removal took
|
|
163
|
+
// away what installation never gave. Without the rule the host refuses the package command,
|
|
164
|
+
// and the 2026-08-15 `cartoons-r136-scout-lock-scope` order shows what a dispatcher does next:
|
|
165
|
+
// it goes looking for a way around, down to the absolute path to run-codex.mjs that Plan_41
|
|
166
|
+
// removed. The rule follows the scope of the install, because host.settingsPath already is the
|
|
167
|
+
// scope — a --scope project install must not reach into the operator's global config.
|
|
168
|
+
const permissionResult = dryRun ? { added: 0 } : await addPermissionRules(host.settingsPath);
|
|
161
169
|
const fingerprints = {};
|
|
162
170
|
for (const item of plan) {
|
|
163
171
|
fingerprints[item.root] ??= {};
|
|
@@ -188,7 +196,11 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
|
|
|
188
196
|
});
|
|
189
197
|
return {
|
|
190
198
|
exitCode: 0,
|
|
191
|
-
output: retentionOutput(
|
|
199
|
+
output: retentionOutput(
|
|
200
|
+
configuredRetentionLine,
|
|
201
|
+
`Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`
|
|
202
|
+
+ `\nGranted ${permissionResult.added} permission rule(s) in ${host.settingsPath} so the host runs the package command without asking.`,
|
|
203
|
+
),
|
|
192
204
|
};
|
|
193
205
|
}
|
|
194
206
|
|
package/cli/manifest.mjs
CHANGED
|
@@ -3,14 +3,15 @@ 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
|
-
import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
|
|
6
|
+
import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
|
|
7
7
|
import {
|
|
8
8
|
renderRequiredInputSummary,
|
|
9
9
|
renderRequiredInputs,
|
|
10
|
-
} from '../src/required-inputs.mjs';
|
|
11
|
-
import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
|
|
12
|
-
import { renderStopSummary } from '../src/stop-contract.mjs';
|
|
13
|
-
import { readJsonFile } from '../src/json-file.mjs';
|
|
10
|
+
} from '../src/home/lib/required-inputs.mjs';
|
|
11
|
+
import { renderNoSelfExecution } from '../src/home/lib/no-self-execution.mjs';
|
|
12
|
+
import { renderStopSummary } from '../src/home/lib/stop-contract.mjs';
|
|
13
|
+
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
14
|
+
import { normalizeFrontmatter } from './frontmatter.mjs';
|
|
14
15
|
import {
|
|
15
16
|
INSTALL_RECORD_NAME,
|
|
16
17
|
RULES_NAME,
|
|
@@ -49,18 +50,19 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Files the operator owns once they exist. Seeded on first install so the defaults are
|
|
52
|
-
* visible and editable, then never written, never compared, never removed:
|
|
53
|
+
* visible and editable, then never written, never compared, never removed: config.json
|
|
53
54
|
* is where a host states which model each mode runs on, conventions.md is the rule set it hands
|
|
54
55
|
* every run, and an installer that overwrites either would erase host decisions on the next
|
|
55
56
|
* update — the same way overwriting a .env would.
|
|
56
57
|
*/
|
|
57
|
-
export const SEEDED_SOURCES = Object.freeze(['src/
|
|
58
|
+
export const SEEDED_SOURCES = Object.freeze(['src/home/config.json', 'src/home/conventions.md']);
|
|
58
59
|
|
|
59
60
|
export const INSTALL_TABLE = Object.freeze([
|
|
60
61
|
{ source: 'src/agents/*.md', root: 'claude', target: 'agentsDir', processing: 'placeholders' },
|
|
61
62
|
{ source: 'src/commands/*.md', root: 'claude', target: 'commandsDir', processing: 'placeholders' },
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
// The source is the host image. Keeping one source root and one target root makes any future
|
|
64
|
+
// remapping visible as a test failure instead of another clone-only import success.
|
|
65
|
+
{ source: 'src/home/**', root: 'brand', target: 'brandRoot', processing: 'copy' },
|
|
64
66
|
// The runner reads its own version out of the package.json beside it, so meta.json can say
|
|
65
67
|
// which code wrote a run (Plan_34). In the clone that file sits one level above src/; without
|
|
66
68
|
// this line the installed layout has nothing one level above lib/, and every run on a real host
|
|
@@ -72,12 +74,13 @@ const posix = (value) => value.split(path.sep).join('/');
|
|
|
72
74
|
|
|
73
75
|
export function replacePlaceholders(content, installationRoot) {
|
|
74
76
|
const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
|
|
75
|
-
|
|
77
|
+
const replaced = content
|
|
76
78
|
.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(installationRoot)))
|
|
77
79
|
.replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
|
|
78
80
|
.replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
|
|
79
81
|
.replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
|
|
80
82
|
.replaceAll('{{CODEX_STOP_SUMMARY}}', renderStopSummary());
|
|
83
|
+
return normalizeFrontmatter(replaced);
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
|
@@ -92,14 +95,13 @@ export function installedHookPath(host, definition) {
|
|
|
92
95
|
/** Where each seeded file comes from and where it goes; contents are copied verbatim. */
|
|
93
96
|
export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
94
97
|
return SEEDED_SOURCES.map((source) => {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
: host.brandConventionsPath;
|
|
98
|
+
const relativeToRoot = posix(path.relative('src/home', source));
|
|
99
|
+
const target = path.join(host.brandRoot, relativeToRoot);
|
|
98
100
|
return {
|
|
99
101
|
source: path.join(packageRoot, source),
|
|
100
102
|
target,
|
|
101
103
|
root: 'brand',
|
|
102
|
-
relativeToRoot
|
|
104
|
+
relativeToRoot,
|
|
103
105
|
processing: 'copy',
|
|
104
106
|
};
|
|
105
107
|
});
|
|
@@ -115,13 +117,17 @@ export function rulesPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
|
115
117
|
|
|
116
118
|
export async function fileFingerprint(absolutePath) {
|
|
117
119
|
try {
|
|
118
|
-
return
|
|
120
|
+
return contentFingerprint(await fs.readFile(absolutePath));
|
|
119
121
|
} catch (err) {
|
|
120
122
|
if (err.code === 'ENOENT') return null;
|
|
121
123
|
throw err;
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
export function contentFingerprint(content) {
|
|
128
|
+
return createHash('sha256').update(content).digest('hex');
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
function targetBase(host, mapping) {
|
|
126
132
|
const base = host[mapping.target];
|
|
127
133
|
if (!base) throw new Error(`host has no ${mapping.target} install target`);
|
|
@@ -136,17 +142,13 @@ function targetRelative(packageRoot, source, mapping) {
|
|
|
136
142
|
if (mapping.processing === 'placeholders' || mapping.source === 'package.json') {
|
|
137
143
|
return path.basename(source);
|
|
138
144
|
}
|
|
139
|
-
|
|
140
|
-
? path.join(packageRoot, 'src', 'hooks')
|
|
141
|
-
: path.join(packageRoot, 'src');
|
|
142
|
-
return path.relative(sourceRoot, source);
|
|
145
|
+
return path.relative(path.join(packageRoot, 'src', 'home'), source);
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
146
149
|
const files = [];
|
|
147
|
-
const claimedSources = [];
|
|
148
150
|
for (const mapping of INSTALL_TABLE) {
|
|
149
|
-
for await (const relative of fs.glob(mapping.source, { cwd: packageRoot
|
|
151
|
+
for await (const relative of fs.glob(mapping.source, { cwd: packageRoot })) {
|
|
150
152
|
if (posix(relative) === RULES_SOURCE || SEEDED_SOURCES.includes(posix(relative))) continue;
|
|
151
153
|
const source = path.join(packageRoot, relative);
|
|
152
154
|
if (!(await fs.stat(source)).isFile()) continue;
|
|
@@ -165,7 +167,6 @@ export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
|
165
167
|
: rootFor(host, mapping),
|
|
166
168
|
});
|
|
167
169
|
}
|
|
168
|
-
claimedSources.push(mapping.source);
|
|
169
170
|
}
|
|
170
171
|
return files.sort((a, b) => recordFileKey(a).localeCompare(recordFileKey(b)));
|
|
171
172
|
}
|
package/cli/permissions.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Manages the optional shell permission rules for every codex-bridge command spelling. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { CLI_NAMES } from '../src/cli-names.mjs';
|
|
5
|
-
import { SHELL_TOOLS } from '../src/hook-definitions.mjs';
|
|
4
|
+
import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
|
|
5
|
+
import { SHELL_TOOLS } from '../src/home/lib/hook-definitions.mjs';
|
|
6
6
|
import { readSettings, updateSettings, withSettingsRun } from './settings-merge.mjs';
|
|
7
7
|
|
|
8
8
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
package/cli/projects.mjs
CHANGED
|
@@ -1,9 +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
|
+
import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
5
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
6
|
+
import { STOP_COMMAND_TEMPLATE } from '../src/home/lib/stop-contract.mjs';
|
|
7
7
|
|
|
8
8
|
// The table renders whatever it is given; how a byte count or a timestamp reads is this command's
|
|
9
9
|
// business. Raw bytes and ISO milliseconds are technically exact and practically unreadable — an
|
package/cli/prune-plan.mjs
CHANGED
|
@@ -3,8 +3,8 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { listProjectRuns, listProjects, recursiveSize } from './runs-inventory.mjs';
|
|
5
5
|
import { parseOlderThan } from './prune-args.mjs';
|
|
6
|
-
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
7
|
-
import { runIsOlderThan, TRANSPORT_FILES } from '../src/retention.mjs';
|
|
6
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
7
|
+
import { runIsOlderThan, TRANSPORT_FILES } from '../src/home/lib/retention.mjs';
|
|
8
8
|
|
|
9
9
|
export { runIsOlderThan, TRANSPORT_FILES };
|
|
10
10
|
|
package/cli/prune.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import readline from 'node:readline/promises';
|
|
5
|
-
import { normalizePath } from '../src/hooks/live-runs.mjs';
|
|
5
|
+
import { normalizePath } from '../src/home/hooks/live-runs.mjs';
|
|
6
6
|
import { parsePruneArgs } from './prune-args.mjs';
|
|
7
7
|
import { prunePlan } from './prune-plan.mjs';
|
|
8
8
|
import { recursiveSize } from './runs-inventory.mjs';
|
package/cli/read.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Renders a run's structured Codex transport as a readable event-by-event report. */
|
|
2
|
-
import { readEvents } from '../src/meta/events.mjs';
|
|
3
|
-
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
2
|
+
import { readEvents } from '../src/home/lib/meta/events.mjs';
|
|
3
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
4
4
|
import { resolveRunFolder } from './run-lookup.mjs';
|
|
5
5
|
|
|
6
6
|
const result = (exitCode, output) => ({ exitCode, output });
|
package/cli/rules-owners.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { normalizeRepoPath } from '../src/runner/project-dir.mjs';
|
|
6
|
-
import { readJsonFile } from '../src/json-file.mjs';
|
|
5
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
6
|
+
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
7
7
|
|
|
8
8
|
export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
|
|
9
9
|
export const RULES_REGISTRY_VERSION = 1;
|
package/cli/run-lookup.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Resolves a run argument and confirms that it names an existing run directory. */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { git } from '../src/runner/git-state.mjs';
|
|
5
|
-
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
6
|
-
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
4
|
+
import { git } from '../src/home/lib/runner/git-state.mjs';
|
|
5
|
+
import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
6
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
7
7
|
|
|
8
8
|
function projectRoot(cwd) {
|
|
9
9
|
const result = git(cwd, ['rev-parse', '--show-toplevel']);
|
package/cli/runs-inventory.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Reads run-store directories into inventory data. */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { liveRuns } from '../src/hooks/live-runs.mjs';
|
|
5
|
-
import { readJsonFileSync } from '../src/json-file.mjs';
|
|
6
|
-
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
4
|
+
import { liveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
5
|
+
import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
|
|
6
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
7
7
|
|
|
8
8
|
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
9
9
|
|
package/cli/settings-merge.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
5
5
|
import fs from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { readJsonFileWithRaw } from '../src/json-file.mjs';
|
|
8
|
+
import { readJsonFileWithRaw } from '../src/home/lib/json-file.mjs';
|
|
9
9
|
|
|
10
10
|
const LEGACY_SPEC = { event: 'SubagentStop', matcher: '*' };
|
|
11
11
|
const settingsRuns = new AsyncLocalStorage();
|
|
@@ -134,29 +134,41 @@ const hasOwnCommand = (group, commands) => {
|
|
|
134
134
|
};
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Presence is decided by the command, not by the matcher it currently sits under
|
|
137
|
+
* Presence is decided by the command, not by the matcher it currently sits under; writing then
|
|
138
|
+
* places that command under the matcher the package declares. These are two halves of one rule.
|
|
138
139
|
*
|
|
139
140
|
* The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
|
|
140
141
|
* host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
|
|
141
142
|
* will grow the same way. A host installed under the old matcher would then be invisible to the
|
|
142
143
|
* new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
|
|
143
|
-
* deleted file.
|
|
144
|
-
*
|
|
144
|
+
* deleted file. Conversely, the 2026-08-17 host proved that finding alone is insufficient: its
|
|
145
|
+
* worktree lock stayed under the pre-shell matcher, so Bash never reached the guard. The command
|
|
146
|
+
* is this package's own absolute path, so it identifies our entry wherever the group ended up,
|
|
147
|
+
* and merge can move it without creating a duplicate.
|
|
145
148
|
*/
|
|
146
149
|
export async function inspectHook(settingsPath, specOrPath) {
|
|
147
150
|
const state = await readSettings(settingsPath);
|
|
148
151
|
const spec = normalizeSpec(specOrPath);
|
|
149
152
|
const commands = [spec.command, ...spec.alternateCommands];
|
|
150
153
|
let matchedCommand;
|
|
154
|
+
let matchedMatcher;
|
|
155
|
+
let requiresMove = false;
|
|
151
156
|
for (const group of groups(state.settings, spec.event)) {
|
|
152
|
-
const hook
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
for (const hook of groupHooks(group)) {
|
|
158
|
+
if (hook?.type !== 'command' || !commands.includes(hook.command)) continue;
|
|
159
|
+
matchedCommand ??= hook.command;
|
|
160
|
+
matchedMatcher ??= group?.matcher;
|
|
161
|
+
if (group?.matcher !== spec.matcher) requiresMove = true;
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
...state,
|
|
166
|
+
...spec,
|
|
167
|
+
present: Boolean(matchedCommand),
|
|
168
|
+
matchedCommand,
|
|
169
|
+
matchedMatcher,
|
|
170
|
+
requiresMove,
|
|
171
|
+
};
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
function backupName(settingsPath) {
|
|
@@ -227,10 +239,43 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
|
227
239
|
return withMutationRun(settingsPath, async () => {
|
|
228
240
|
const spec = normalizeSpec(specOrPath);
|
|
229
241
|
const state = inspected || await inspectHook(settingsPath, spec);
|
|
230
|
-
if (state.present) return { changed: false, createdGroup: false };
|
|
242
|
+
if (state.present && !state.requiresMove) return { changed: false, createdGroup: false };
|
|
231
243
|
const settings = structuredClone(state.settings);
|
|
232
244
|
settings.hooks ??= {};
|
|
233
245
|
if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
|
|
246
|
+
if (state.present) {
|
|
247
|
+
const eventGroups = settings.hooks[spec.event];
|
|
248
|
+
const commands = [spec.command, ...spec.alternateCommands];
|
|
249
|
+
let hookToMove;
|
|
250
|
+
let keptDeclaredHook = false;
|
|
251
|
+
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
252
|
+
const group = eventGroups[index];
|
|
253
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
254
|
+
const hadOwnHook = hasOwnCommand(group, commands);
|
|
255
|
+
group.hooks = group.hooks.filter((hook) => {
|
|
256
|
+
if (hook?.type !== 'command' || !commands.includes(hook.command)) return true;
|
|
257
|
+
hookToMove ??= hook;
|
|
258
|
+
if (group.matcher === spec.matcher && !keptDeclaredHook) {
|
|
259
|
+
keptDeclaredHook = true;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
});
|
|
264
|
+
if (hadOwnHook && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
265
|
+
}
|
|
266
|
+
let group = eventGroups.find((entry) => entry?.matcher === spec.matcher);
|
|
267
|
+
const createdGroup = !group;
|
|
268
|
+
if (!keptDeclaredHook) {
|
|
269
|
+
if (!group) {
|
|
270
|
+
group = { matcher: spec.matcher, hooks: [] };
|
|
271
|
+
eventGroups.push(group);
|
|
272
|
+
}
|
|
273
|
+
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
274
|
+
group.hooks.push(hookToMove);
|
|
275
|
+
}
|
|
276
|
+
await atomicWrite(settingsPath, settings, state);
|
|
277
|
+
return { changed: true, createdGroup, moved: true };
|
|
278
|
+
}
|
|
234
279
|
let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
|
|
235
280
|
const createdGroup = !group;
|
|
236
281
|
if (!group) {
|
package/cli/stop.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/** Stops a live run and records the same abandoned FAIL artifacts as the normal unlock. */
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { stopCodex } from '../src/runner/codex-cmd.mjs';
|
|
4
|
-
import { git, worktreeSnapshot } from '../src/runner/git-state.mjs';
|
|
3
|
+
import { stopCodex } from '../src/home/lib/runner/codex-cmd.mjs';
|
|
4
|
+
import { git, worktreeSnapshot } from '../src/home/lib/runner/git-state.mjs';
|
|
5
5
|
import {
|
|
6
6
|
IDENTITY_ALIVE,
|
|
7
7
|
IDENTITY_FOREIGN,
|
|
8
8
|
IDENTITY_UNVERIFIED,
|
|
9
9
|
processAlive,
|
|
10
10
|
processIdentity,
|
|
11
|
-
} from '../src/process-identity.mjs';
|
|
12
|
-
import { markAbandoned, readJson } from '../src/write-meta.mjs';
|
|
13
|
-
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
11
|
+
} from '../src/home/lib/process-identity.mjs';
|
|
12
|
+
import { markAbandoned, readJson } from '../src/home/lib/write-meta.mjs';
|
|
13
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
14
14
|
import { resolveRunFolder } from './run-lookup.mjs';
|
|
15
15
|
|
|
16
16
|
const EXIT_POLL_MS = 25;
|
package/cli/unlock.mjs
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
/** Closes dead-pid running records without deleting any run artifacts. */
|
|
3
3
|
import fs from 'node:fs';
|
|
4
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';
|
|
5
|
+
import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/home/lib/heartbeat.mjs';
|
|
6
|
+
import { markAbandoned } from '../src/home/lib/meta/run-state.mjs';
|
|
7
7
|
import {
|
|
8
8
|
IDENTITY_ALIVE,
|
|
9
9
|
IDENTITY_UNVERIFIED,
|
|
10
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';
|
|
11
|
+
} from '../src/home/lib/process-identity.mjs';
|
|
12
|
+
import { git } from '../src/home/lib/runner/git-state.mjs';
|
|
13
|
+
import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
14
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
15
|
+
import { readJson } from '../src/home/lib/write-meta.mjs';
|
|
16
16
|
import { renderTable } from './table.mjs';
|
|
17
17
|
|
|
18
18
|
const CLOSED_COLUMNS = [
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with
|
|
3
|
+
"version": "0.5.3",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 6 guards, 757 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
|
@@ -21,7 +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
|
+
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
25
25
|
|
|
26
26
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
27
27
|
export const REPO_ROOT = path.resolve(HERE, '..');
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -16,21 +16,50 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { spawnSync } from 'node:child_process';
|
|
20
21
|
import { ISOLATED_ROOTS } from './isolated-roots.mjs';
|
|
22
|
+
import { readmeText, suiteCountMismatch } from './suite-count.mjs';
|
|
21
23
|
|
|
22
24
|
const created = ISOLATED_ROOTS.map((name) => [
|
|
23
25
|
name,
|
|
24
26
|
fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
|
|
25
27
|
]);
|
|
26
28
|
const pattern = process.argv[2] || 'tests/**/*.test.mjs';
|
|
29
|
+
// Only a whole-suite run can judge the README's number; a single file legitimately reports two.
|
|
30
|
+
const wholeSuite = !process.argv[2];
|
|
31
|
+
const tapFile = wholeSuite
|
|
32
|
+
? path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-summary-')), 'summary.tap')
|
|
33
|
+
: null;
|
|
34
|
+
const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
35
|
+
|
|
36
|
+
// The spec reporter is named explicitly because the second reporter would otherwise replace it,
|
|
37
|
+
// and the operator would lose the live output this command exists to show.
|
|
38
|
+
const reporters = tapFile
|
|
39
|
+
? ['--test-reporter=spec', '--test-reporter-destination=stdout',
|
|
40
|
+
'--test-reporter=tap', `--test-reporter-destination=${tapFile}`]
|
|
41
|
+
: [];
|
|
27
42
|
|
|
28
43
|
try {
|
|
29
|
-
const result = spawnSync(process.execPath, ['--test', pattern], {
|
|
44
|
+
const result = spawnSync(process.execPath, ['--test', ...reporters, pattern], {
|
|
30
45
|
stdio: 'inherit',
|
|
31
46
|
env: { ...process.env, ...Object.fromEntries(created) },
|
|
32
47
|
});
|
|
33
48
|
process.exitCode = result.status ?? 1;
|
|
49
|
+
if (tapFile && process.exitCode === 0) {
|
|
50
|
+
let tap = null;
|
|
51
|
+
try {
|
|
52
|
+
tap = fs.readFileSync(tapFile, 'utf8');
|
|
53
|
+
} catch {
|
|
54
|
+
tap = null;
|
|
55
|
+
}
|
|
56
|
+
const mismatch = suiteCountMismatch(readmeText(path.join(repositoryRoot, 'README.md')), tap);
|
|
57
|
+
if (mismatch) {
|
|
58
|
+
process.stderr.write(`\nsuite-count: ${mismatch}\n`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
34
62
|
} finally {
|
|
35
63
|
for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
|
|
64
|
+
if (tapFile) fs.rmSync(path.dirname(tapFile), { recursive: true, force: true });
|
|
36
65
|
}
|