sprag-cli 3.53.0 → 3.55.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/README.ko.md +54 -3
- package/README.md +63 -4
- package/bin/cli.js +30 -7
- package/docs/CODEX.ko.md +184 -0
- package/docs/CODEX.md +497 -0
- package/package.json +15 -4
- package/presets/ratchet-rules.json +4 -1
- package/src/agent.js +33 -0
- package/src/codex-brief.js +89 -0
- package/src/codex-budget.js +73 -0
- package/src/codex-cache-policy.js +203 -0
- package/src/codex-cache.js +46 -0
- package/src/codex-delegation.js +154 -0
- package/src/codex-doc2md-ledger.js +45 -0
- package/src/codex-doc2md.js +99 -0
- package/src/codex-doctor.js +53 -0
- package/src/codex-harness.js +200 -0
- package/src/codex-hooks.js +138 -0
- package/src/codex-installer.js +79 -0
- package/src/codex-ledger.js +143 -0
- package/src/codex-panel-launcher.js +48 -0
- package/src/codex-panel-runner.js +74 -0
- package/src/codex-panel-session.js +35 -0
- package/src/codex-panel-setup.js +98 -0
- package/src/codex-panel-shell.js +51 -0
- package/src/codex-panel-state.js +40 -0
- package/src/codex-panel.js +448 -0
- package/src/codex-parser.js +397 -0
- package/src/codex-route-scan.js +171 -0
- package/src/commands/codex-delegate.js +59 -0
- package/src/commands/codex.js +579 -0
- package/src/commands/harness.js +13 -2
- package/src/commands/install.js +106 -10
- package/src/commands/route-scan.js +4 -0
- package/src/commands/seed.js +16 -12
- package/src/commands/uninstall.js +23 -0
- package/src/commands/upgrade.js +13 -0
- package/src/doc2md.cjs +72 -28
- package/src/handoff.js +27 -7
- package/src/harness-templates.js +33 -1
- package/src/harness.js +51 -17
- package/src/preset-ratchet.js +133 -0
- package/src/seed-rules.js +88 -39
- package/src/update-check.js +37 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { realpathSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { recordCodexDocument } from './codex-doc2md-ledger.js';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const doc2md = require('./doc2md.cjs');
|
|
9
|
+
const lint = require('./korean-lint.cjs');
|
|
10
|
+
const shellTools = new Set(['Bash', 'exec_command', 'shell_command', 'shell']);
|
|
11
|
+
|
|
12
|
+
function canonicalPath(file) {
|
|
13
|
+
const missing = [];
|
|
14
|
+
for (let current = file; ; current = dirname(current)) {
|
|
15
|
+
try { return resolve(realpathSync(current), ...missing.reverse()); }
|
|
16
|
+
catch (e) { if (!['ENOENT', 'ENOTDIR'].includes(e.code) || dirname(current) === current) return file; }
|
|
17
|
+
missing.push(basename(current));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function codexDocumentNote() {
|
|
22
|
+
return '[Sprag Doc2md]\nBefore reading PDF, Office, or Figma documents, run sprag doc2md <path> --agent codex and read the resulting Markdown. '
|
|
23
|
+
+ 'Conversions are one-way: never edit the cached Markdown. To modify a document, copy the original and edit the copy with a format-aware library, then reconvert it to verify the text. '
|
|
24
|
+
+ 'Text extraction does not verify charts, images, or layout. Report conversion failures and do not claim visual verification from text alone.';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function convertCodexDocument(source, { convert = doc2md.convert, ...opts } = {}) {
|
|
28
|
+
let result;
|
|
29
|
+
try { result = convert(source, { agent: 'codex' }); }
|
|
30
|
+
catch { return { ok: false, reason: 'convert-failed' }; }
|
|
31
|
+
if (!result?.ok) return result || { ok: false, reason: 'convert-failed' };
|
|
32
|
+
let tmp;
|
|
33
|
+
try {
|
|
34
|
+
// Existing shared caches can carry Claude attachment baselines and prices.
|
|
35
|
+
// Keep their body, but give Codex a separate view with neutral provenance.
|
|
36
|
+
const original = readFileSync(result.cacheFile, 'utf8');
|
|
37
|
+
const end = original.startsWith('<!--\nsprag doc2md') ? original.indexOf('\n-->\n') : -1;
|
|
38
|
+
const body = end >= 0 ? original.slice(end + 5) : original;
|
|
39
|
+
const cacheFile = result.cacheFile.replace(/\.md$/, '.codex.md');
|
|
40
|
+
const text = `<!--\nsprag doc2md: generated Markdown; do not edit.\nSource: ${JSON.stringify(resolve(source))}\nText extraction does not verify images or layout.\n-->\n${body}`;
|
|
41
|
+
let previous;
|
|
42
|
+
try { previous = readFileSync(cacheFile, 'utf8'); } catch { /* First Codex use. */ }
|
|
43
|
+
if (previous !== text) {
|
|
44
|
+
tmp = `${cacheFile}.${randomUUID()}.tmp`;
|
|
45
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
46
|
+
renameSync(tmp, cacheFile);
|
|
47
|
+
tmp = null;
|
|
48
|
+
}
|
|
49
|
+
const { savedUsd, baselineTokens, ...meta } = result.meta || {};
|
|
50
|
+
const prepared = { ...result, cacheFile, meta };
|
|
51
|
+
recordCodexDocument(source, prepared, opts);
|
|
52
|
+
return prepared;
|
|
53
|
+
} catch {
|
|
54
|
+
return { ok: false, reason: 'cache-unavailable' };
|
|
55
|
+
} finally {
|
|
56
|
+
if (tmp) { try { rmSync(tmp, { force: true }); } catch { /* Best-effort temporary file cleanup. */ } }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Adapt supported Codex calls to the shared converter and write guard. */
|
|
61
|
+
export function codexDocumentTool(payload, { convert = convertCodexDocument, guard = doc2md.decideForWrite } = {}) {
|
|
62
|
+
const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
|
|
63
|
+
const input = payload?.tool_input;
|
|
64
|
+
const command = typeof input === 'string' ? input : input?.command ?? input?.cmd ?? input?.content ?? input?.patch;
|
|
65
|
+
const cwd = resolve(payload?.cwd || process.cwd(), typeof input?.workdir === 'string' ? input.workdir : '.');
|
|
66
|
+
let writes = [];
|
|
67
|
+
if (tool === 'apply_patch' && typeof command === 'string' && command.startsWith('*** Begin Patch')) {
|
|
68
|
+
writes = [...command.matchAll(/^\*\*\* (?:Add File|Update File|Move to): (.+)\r?$/gm)].map((m) => m[1].trimEnd());
|
|
69
|
+
} else if (['Edit', 'Write'].includes(tool) && typeof input?.file_path === 'string') writes = [input.file_path];
|
|
70
|
+
else if (shellTools.has(tool)) writes = lint.writtenPathsOfBash(command);
|
|
71
|
+
for (const path of writes) {
|
|
72
|
+
const file = resolve(cwd, path);
|
|
73
|
+
const canonical = canonicalPath(file);
|
|
74
|
+
for (const target of new Set([file, canonical])) {
|
|
75
|
+
const decision = guard({ tool_name: 'Write', tool_input: { file_path: target } }, { agent: 'codex' });
|
|
76
|
+
if (decision?.deny) return { hookSpecificOutput: { hookEventName: 'PreToolUse',
|
|
77
|
+
permissionDecision: 'deny', permissionDecisionReason: decision.reason } };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let file;
|
|
82
|
+
if (['Read', 'read_file', 'mcp__filesystem__read_file', 'mcp__filesystem__read_text_file'].includes(tool)) {
|
|
83
|
+
file = input?.file_path ?? input?.path;
|
|
84
|
+
} else if (shellTools.has(tool) && typeof command === 'string') {
|
|
85
|
+
// Only literal, single-file reads. Compound commands and expansions stay untouched.
|
|
86
|
+
const match = /^(?:cat\s+(?:--\s+)?|(?:head|tail)\s+(?:-n\s+\d+\s+)?)(?:"([^"$`\n]+)"|'([^'\n]+)'|([^\s'"`$;&|<>*?(){}\\]+))\s*$/.exec(command.trim());
|
|
87
|
+
if (match) file = match[1] || match[2] || match[3];
|
|
88
|
+
}
|
|
89
|
+
if (typeof file !== 'string' || !doc2md.isTargetPath(file)) return null;
|
|
90
|
+
const result = convert(resolve(cwd, file));
|
|
91
|
+
if (!result.ok) return { hookSpecificOutput: { hookEventName: 'PreToolUse',
|
|
92
|
+
...(result.reason === 'unsafe-archive' ? { permissionDecision: 'deny', permissionDecisionReason: '[doc2md] Unsafe archive; do not extract it.' }
|
|
93
|
+
: { additionalContext: `[doc2md] Conversion failed (${result.reason}). Do not claim this document was converted. ${codexDocumentNote()}` }) } };
|
|
94
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
95
|
+
permissionDecisionReason: `[doc2md] Read the converted Markdown at ${JSON.stringify(result.cacheFile)} instead of extracting ${JSON.stringify(file)} again. `
|
|
96
|
+
+ `Source ${result.meta?.size ?? 'unknown'} bytes; Markdown ${result.meta?.markdownBytes ?? 'unknown'} bytes.`
|
|
97
|
+
+ (result.meta?.clipped ? ' The conversion is clipped; it is not the complete document.'
|
|
98
|
+
: result.meta?.truncated ? ' Text extraction is incomplete; it is not the complete document.' : '') } };
|
|
99
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'smol-toml';
|
|
4
|
+
import { codexUserDir } from './agent.js';
|
|
5
|
+
import { codexHookStatus } from './codex-installer.js';
|
|
6
|
+
import { createPanelReader } from './codex-panel.js';
|
|
7
|
+
import { panelHealth } from './codex-panel-state.js';
|
|
8
|
+
|
|
9
|
+
export const CODEX_CAPABILITIES = [
|
|
10
|
+
{ feature: 'statusline', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Local terminal output; native footer accepts only Codex item identifiers.' },
|
|
11
|
+
{ feature: 'panel', support: 'supported', command: 'sprag panel --agent codex', note: 'Local logs; tmux inline layout; optional macOS window.' },
|
|
12
|
+
{ feature: 'reports', support: 'supported', command: 'sprag --agent codex --format json', note: 'Token usage, cached input, reasoning, session and project filters.' },
|
|
13
|
+
{ feature: 'harness', support: 'supported', command: 'sprag harness check --agent codex', note: 'AGENTS.md, overrides, explicit-scope ratchet rule management.' },
|
|
14
|
+
{ feature: 'brief', support: 'supported', command: 'sprag brief --agent codex', note: 'Fresh context and limit warnings; change-triggered prompt hook.' },
|
|
15
|
+
{ feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only session warning records; last shows the latest warning.' },
|
|
16
|
+
{ feature: 'handoff', support: 'supported', command: 'sprag handoff --agent codex', note: 'Git state and recorded Codex session usage; never reads Claude caps.' },
|
|
17
|
+
{ feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
|
|
18
|
+
{ feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
|
|
19
|
+
{ feature: 'doc2md', support: 'supported', command: 'sprag doc2md status --agent codex', note: 'Shared converter, prompt paths, supported file reads, and recognized document/cache write guards; not arbitrary shell extraction.' },
|
|
20
|
+
{ feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Opt-in native spawn model routing, scoped category rules, and SubagentStart guidance. Explicit models/custom roles preserved; no enforced budget.' },
|
|
21
|
+
{ feature: 'routing-saved', support: 'partial', command: 'sprag route-scan savings --agent codex', note: 'Sprag-routed child usage with matching LiteLLM prices; counterfactual estimate, not billed savings. Unpriced runs stay unknown.' },
|
|
22
|
+
{ feature: 'cache-timer', support: 'partial', command: 'sprag cache status --refresh --agent codex', note: 'Documented minimum retention after recorded cache usage; LiteLLM deployments resolved through model/info. Unknown routes show age; exact expiry is not reported.' },
|
|
23
|
+
{ feature: 'mode', support: 'partial', command: 'sprag mode --agent codex', note: 'Labels, color, and timer apply to panel/statusline; Claude TTL buckets do not.' },
|
|
24
|
+
{ feature: 'upgrade', support: 'manual', command: 'sprag upgrade --print --agent codex', note: 'Skip the Claude npm postinstall; reinstall Codex hooks after upgrade.' },
|
|
25
|
+
{ feature: 'route-scan', support: 'supported', command: 'sprag route-scan --agent codex', note: 'Recurring simple turns grouped by project, provider, and parent model; explicit approval before routing.' },
|
|
26
|
+
{ feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Agent-compatible ratchet presets; separate decisions and explicit scope. No Claude tier presets.' },
|
|
27
|
+
{ feature: 'transcript ratchet', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Recent repeated tool failures become ratchet? candidates; no automatic rule registration.' },
|
|
28
|
+
{ feature: 'compact-window', support: 'native', note: 'Codex uses model_auto_compact_token_limit; no Claude autoCompactWindow heuristic.' },
|
|
29
|
+
{ feature: 'cost / exact cache expiry', support: 'unavailable', note: 'Not measured in rollout logs; never estimated with Anthropic prices.' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export async function codexDoctor({ root = process.cwd(), sessionId, days = 7 } = {}) {
|
|
33
|
+
const home = codexUserDir();
|
|
34
|
+
const file = join(home, 'config.toml');
|
|
35
|
+
const config = { file, present: existsSync(file) };
|
|
36
|
+
try {
|
|
37
|
+
const cfg = config.present ? parse(readFileSync(file, 'utf8')) : {};
|
|
38
|
+
// Only expose an allowlist. Provider auth and arbitrary config may hold secrets.
|
|
39
|
+
config.hooksDisabled = cfg.features?.hooks === false || (cfg.features?.hooks === undefined && cfg.features?.codex_hooks === false);
|
|
40
|
+
config.statusLine = Array.isArray(cfg.tui?.status_line) ? cfg.tui.status_line.filter((item) => typeof item === 'string') : null;
|
|
41
|
+
config.autoCompactTokenLimit = Number.isFinite(cfg.model_auto_compact_token_limit) ? cfg.model_auto_compact_token_limit : null;
|
|
42
|
+
config.note = 'User config only; active profile, project, managed policy, and runtime overrides may differ.';
|
|
43
|
+
} catch { config.error = 'Cannot parse user config.toml; file left unchanged.'; }
|
|
44
|
+
let hooks;
|
|
45
|
+
try { hooks = { file: join(home, 'hooks.json'), events: codexHookStatus(), trust: 'unknown; review in Codex /hooks' }; }
|
|
46
|
+
catch { hooks = { error: 'Cannot parse Codex hooks.json; file left unchanged.', events: [] }; }
|
|
47
|
+
const data = await createPanelReader({ root, sessionId, days })();
|
|
48
|
+
const session = data.session ? { id: data.session.sessionId, model: data.session.model,
|
|
49
|
+
lastActivity: data.session.lastActivity, usageUpdates: data.session.requestCount } : null;
|
|
50
|
+
return { agent: 'codex', home, root, config, hooks, harness: data.harness, rules: data.rules,
|
|
51
|
+
preferences: { korean: data.korean, lint: data.lint, cohesion: data.cohesion, doc2md: data.doc2md, brief: data.brief, delegate: data.delegate },
|
|
52
|
+
session, panel: panelHealth(root, { sessionId }), capabilities: CODEX_CAPABILITIES };
|
|
53
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { codexUserDir } from './agent.js';
|
|
5
|
+
import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines } from './codex-parser.js';
|
|
6
|
+
|
|
7
|
+
export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
|
|
8
|
+
export const CODEX_END = '<!-- sprag:codex:harness:end -->';
|
|
9
|
+
const sections = [
|
|
10
|
+
['Ratchet', 'When a mistake recurs, propose a concise condition/action rule. Ask the user for global or project scope before registering it with `sprag harness promote --agent codex --global|--project "rule"`.'],
|
|
11
|
+
['Evidence', 'Support completion claims with actual test results, command output, a file diff, or a screenshot. State any verification you could not run.'],
|
|
12
|
+
['Plan, Execute, Verify', 'For multi-step work, plan the steps, execute them, and verify the result before reporting completion.'],
|
|
13
|
+
['Structured Task', 'Establish the goal, constraints, verification method, and completion criteria before substantial work.'],
|
|
14
|
+
['Default Safe Path', 'Ask before destructive or external actions unless already authorized. Proceed with reads, local edits, and tests within the granted permissions.'],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function codexHarnessPaths(root, scope = 'project') {
|
|
18
|
+
const dir = scope === 'global' ? codexUserDir() : root;
|
|
19
|
+
// Codex reads the override instead of AGENTS.md when it is non-empty.
|
|
20
|
+
const override = join(dir, 'AGENTS.override.md');
|
|
21
|
+
const file = existsSync(override) && readFileSync(override, 'utf8').trim() ? override : join(dir, 'AGENTS.md');
|
|
22
|
+
return { file, ratchet: scope === 'global' ? join(dir, 'ratchet.md') : join(dir, '.codex', 'ratchet.md') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function codexHarnessBlock() {
|
|
26
|
+
return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments.\n${CODEX_END}\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function range(content) {
|
|
30
|
+
const begin = content.indexOf(CODEX_BEGIN);
|
|
31
|
+
const end = content.indexOf(CODEX_END);
|
|
32
|
+
if ((begin < 0) !== (end < 0) || (begin >= 0 && end < begin) || content.indexOf(CODEX_BEGIN, begin + CODEX_BEGIN.length) >= 0) {
|
|
33
|
+
throw new Error('Malformed Sprag Codex harness markers; repair them before updating this file.');
|
|
34
|
+
}
|
|
35
|
+
return begin < 0 ? null : [begin, end + CODEX_END.length];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function initCodexHarness({ root, scope = 'project' }) {
|
|
39
|
+
const paths = codexHarnessPaths(root, scope);
|
|
40
|
+
const existing = existsSync(paths.file) ? readFileSync(paths.file, 'utf8') : '';
|
|
41
|
+
const span = range(existing);
|
|
42
|
+
const block = codexHarnessBlock();
|
|
43
|
+
const next = span ? existing.slice(0, span[0]) + block.trimEnd() + existing.slice(span[1])
|
|
44
|
+
: existing + (existing ? '\n\n' : '') + block;
|
|
45
|
+
mkdirSync(dirname(paths.file), { recursive: true });
|
|
46
|
+
if (next !== existing) {
|
|
47
|
+
if (existing) writeFileSync(`${paths.file}.bak-${Date.now()}`, existing);
|
|
48
|
+
writeFileSync(paths.file, next);
|
|
49
|
+
}
|
|
50
|
+
if (!existsSync(paths.ratchet)) {
|
|
51
|
+
mkdirSync(dirname(paths.ratchet), { recursive: true });
|
|
52
|
+
writeFileSync(paths.ratchet, '# Codex Ratchet Rules\n\n## Rules\n\n');
|
|
53
|
+
}
|
|
54
|
+
return paths;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function uninitCodexHarness({ root, scope = 'project' }) {
|
|
58
|
+
const { file } = codexHarnessPaths(root, scope);
|
|
59
|
+
if (!existsSync(file)) return { file, removed: false };
|
|
60
|
+
const existing = readFileSync(file, 'utf8');
|
|
61
|
+
const span = range(existing);
|
|
62
|
+
if (!span) return { file, removed: false };
|
|
63
|
+
writeFileSync(`${file}.bak-${Date.now()}`, existing);
|
|
64
|
+
writeFileSync(file, existing.slice(0, span[0]) + existing.slice(span[1]));
|
|
65
|
+
return { file, removed: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function codexHarnessStatus({ root, scope = 'project' }) {
|
|
69
|
+
const { file } = codexHarnessPaths(root, scope);
|
|
70
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
71
|
+
const span = range(text);
|
|
72
|
+
const block = span ? text.slice(...span) : '';
|
|
73
|
+
const missing = sections.filter(([name], i) => !block.includes(`### ${i + 1}. ${name}`)).map(([name]) => name);
|
|
74
|
+
return { file, configured: sections.length - missing.length, total: sections.length, missing };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
78
|
+
const LOOKUP = new Set(['read', 'search', 'list_files']);
|
|
79
|
+
|
|
80
|
+
/** The script a `zsh -lc` argv runs, or the argv itself. */
|
|
81
|
+
function commandLine(argv) {
|
|
82
|
+
const i = argv.findIndex((a) => a === '-lc' || a === '-c');
|
|
83
|
+
return (i >= 0 ? argv[i + 1] : argv.join(' ')) || '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Shell words of a script's first command, honoring simple quotes. */
|
|
87
|
+
function firstWords(script, n) {
|
|
88
|
+
const words = [];
|
|
89
|
+
for (const m of String(script).matchAll(/'([^']*)'|"([^"]*)"|([^\s'"|;&]+)|([|;&])/g)) {
|
|
90
|
+
if (m[4]) break;
|
|
91
|
+
words.push(m[1] ?? m[2] ?? m[3]);
|
|
92
|
+
if (words.length === n) break;
|
|
93
|
+
}
|
|
94
|
+
return words;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Tools whose exit 1 is an answer, not an error: grep-style "no match" and
|
|
98
|
+
// diff/cmp-style "the inputs differ". Only exit 1 is exempt; 2+ still counts.
|
|
99
|
+
const ANSWER_EXIT_1 = /^(?:diff|cmp|grep|egrep|fgrep|rg|ag|test|\[|comm)$|^git (?:diff|grep)$/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A failure's identity across retries: the command's first two words plus the
|
|
103
|
+
* start of its output, with run-specific noise (paths, ids, positions) removed
|
|
104
|
+
* so the same failure in another temp dir still matches. Other numbers stay,
|
|
105
|
+
* since exit codes and counts carry meaning.
|
|
106
|
+
*/
|
|
107
|
+
export function codexErrorSignature(ev, { cwd, home = homedir() } = {}) {
|
|
108
|
+
let text = String(ev.output || '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
109
|
+
if (cwd) text = text.replace(new RegExp(escapeRe(cwd), 'g'), '.');
|
|
110
|
+
if (home) text = text.replace(new RegExp(escapeRe(home), 'g'), '~');
|
|
111
|
+
text = text.replace(/(?:\/private)?\/(?:tmp|var\/folders)\/[^\s:'"]+/g, '<tmp>')
|
|
112
|
+
.replace(/\b[0-9a-f]{8,}(?:-[0-9a-f]{4,}){0,4}\b/gi, '<id>')
|
|
113
|
+
.replace(/:\d+:\d+/g, ':N:N')
|
|
114
|
+
.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
115
|
+
const head = ev.kind === 'patch' ? 'apply_patch'
|
|
116
|
+
: firstWords(commandLine(ev.argv || []), 2).map((w) => w.split('/').pop()).join(' ');
|
|
117
|
+
return `${head}: ${text}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether a failure is worth remembering, rather than a probe or Sprag's own feedback. */
|
|
121
|
+
function countable(ev) {
|
|
122
|
+
if (!ev.failed) return false;
|
|
123
|
+
// Sprag's write lint rewrites the tool output; that is feedback, not a mistake to learn from.
|
|
124
|
+
if (/^\[(?:korean-style|claude-token-saver|sprag)/.test(String(ev.output || '').trim())) return false;
|
|
125
|
+
if (ev.kind === 'patch') return true;
|
|
126
|
+
// `rg` finding nothing and `ls` on an absent path are how exploration works.
|
|
127
|
+
const types = (ev.parsed || []).map((c) => c?.type);
|
|
128
|
+
if (types.length && types.every((t) => LOOKUP.has(t))) return false;
|
|
129
|
+
if (ev.exit === 1) {
|
|
130
|
+
const words = firstWords(commandLine(ev.argv || []), 2).map((w) => w.split('/').pop());
|
|
131
|
+
if (ANSWER_EXIT_1.test(words[0]) || ANSWER_EXIT_1.test(words.join(' '))) return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Repeated failures in a rollout's recent turns, newest-first by count. Reads
|
|
138
|
+
* only the file tail, which is where the current session's recent work is.
|
|
139
|
+
*/
|
|
140
|
+
export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home } = {}) {
|
|
141
|
+
const lines = readCodexTailLines(filePath, tailBytes);
|
|
142
|
+
if (!lines) return [];
|
|
143
|
+
const order = [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const failures = [];
|
|
146
|
+
const calls = new Map();
|
|
147
|
+
const outputs = [];
|
|
148
|
+
let cwd = null;
|
|
149
|
+
let current = null;
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
// Cheap prefilter: these candidate lines are a small share of a rollout.
|
|
152
|
+
if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
|
|
153
|
+
let e;
|
|
154
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
155
|
+
const p = e?.payload;
|
|
156
|
+
if (e?.type === 'session_meta' && typeof p?.cwd === 'string') cwd ??= p.cwd;
|
|
157
|
+
if (e?.type === 'turn_context' && typeof p?.cwd === 'string') cwd = p.cwd;
|
|
158
|
+
if (e?.type === 'event_msg' && p?.type === 'task_started') current = p.turn_id || current;
|
|
159
|
+
for (const ev of codexToolEvents(e)) {
|
|
160
|
+
if (ev.kind === 'call') { if (ev.callId) calls.set(ev.callId, ev); continue; }
|
|
161
|
+
if (ev.kind === 'output') {
|
|
162
|
+
if (ev.exit !== null && ev.callId) outputs.push({ ...ev, turn: current, cwd });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (ev.kind !== 'command' && ev.kind !== 'patch') continue;
|
|
166
|
+
if (ev.callId) { if (seen.has(ev.callId)) continue; seen.add(ev.callId); }
|
|
167
|
+
const turn = ev.turnId || current;
|
|
168
|
+
if (turn && !order.includes(turn)) order.push(turn);
|
|
169
|
+
if (countable(ev)) failures.push({ ...ev, turn, cwd: ev.cwd || cwd });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const out of outputs) {
|
|
173
|
+
if (seen.has(out.callId)) continue;
|
|
174
|
+
const ev = codexLegacyToolEvent(calls.get(out.callId), out);
|
|
175
|
+
if (!ev) continue;
|
|
176
|
+
seen.add(out.callId);
|
|
177
|
+
if (ev.turn && !order.includes(ev.turn)) order.push(ev.turn);
|
|
178
|
+
if (countable(ev)) failures.push(ev);
|
|
179
|
+
}
|
|
180
|
+
const recent = new Set(order.slice(-window));
|
|
181
|
+
const groups = new Map();
|
|
182
|
+
for (const ev of failures) {
|
|
183
|
+
if (!recent.has(ev.turn) || !Number.isFinite(ev.at) || now - ev.at > ttlMs) continue;
|
|
184
|
+
const pattern = codexErrorSignature(ev, { cwd: ev.cwd, home });
|
|
185
|
+
const g = groups.get(pattern) || { pattern, count: 0, lastAt: 0 };
|
|
186
|
+
g.count++;
|
|
187
|
+
g.lastAt = Math.max(g.lastAt, ev.at);
|
|
188
|
+
groups.set(pattern, g);
|
|
189
|
+
}
|
|
190
|
+
return [...groups.values()].filter((g) => g.count >= 2)
|
|
191
|
+
.sort((a, b) => b.count - a.count || b.lastAt - a.lastAt).slice(0, 5)
|
|
192
|
+
.map((g, i) => ({ id: i + 1, ...g }));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The ratchet text a candidate promotes to; the user replaces the TODO. */
|
|
196
|
+
export function codexCandidateRule(cand, lang) {
|
|
197
|
+
return lang === 'ko'
|
|
198
|
+
? `반복 감지 ×${cand.count}: ${cand.pattern} · TODO: 원인과 예방책을 한 줄로 적습니다`
|
|
199
|
+
: `Repeated failure x${cand.count}: ${cand.pattern} · TODO: write the cause and prevention in one line`;
|
|
200
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { loadConfig, userLanguage } from './config.js';
|
|
6
|
+
import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
|
|
7
|
+
import { cohesionInjection } from './cohesion.js';
|
|
8
|
+
import { codexHarnessPaths } from './codex-harness.js';
|
|
9
|
+
import { findProjectRoot } from './harness.js';
|
|
10
|
+
import { runCodexBrief } from './codex-brief.js';
|
|
11
|
+
import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
|
|
12
|
+
import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
|
|
13
|
+
import { seedOfferBlock } from './seed-rules.js';
|
|
14
|
+
import { readCodexRouteScan, openCodexCandidates, shouldRescanCodex } from './codex-route-scan.js';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const lint = require('./korean-lint.cjs');
|
|
18
|
+
const doc2md = require('./doc2md.cjs');
|
|
19
|
+
|
|
20
|
+
function context(event, text) {
|
|
21
|
+
return text ? { hookSpecificOutput: { hookEventName: event, additionalContext: text } } : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Project route candidates from the last scan. Never scans inline: session
|
|
26
|
+
* start must stay fast, so a stale scan is refreshed in a detached process
|
|
27
|
+
* and its results show up next session.
|
|
28
|
+
*/
|
|
29
|
+
async function routeNotice(root, { spawnRescan = defaultRescan } = {}) {
|
|
30
|
+
const cache = readCodexRouteScan();
|
|
31
|
+
if (await shouldRescanCodex(cache)) spawnRescan();
|
|
32
|
+
const open = openCodexCandidates(cache, { root });
|
|
33
|
+
if (!open.length) return null;
|
|
34
|
+
return [`[Sprag route-scan] ${open.length} recurring delegable pattern(s) in this project. Mention them once; do not register without the user's scope decision.`,
|
|
35
|
+
...open.slice(0, 3).map((c) => ` R${c.id} ${c.category} x${c.count}: ${c.from} -> ${c.suggestedModel || '(choose a model)'}`),
|
|
36
|
+
' Review: sprag route-scan --agent codex | approve: sprag delegate rules add R<N> --global|--project --agent codex'].join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultRescan() {
|
|
40
|
+
if (process.env.CTS_NO_ROUTE_SCAN === '1') return;
|
|
41
|
+
spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet', '--agent', 'codex'],
|
|
42
|
+
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function lintCodexTool(payload, { scope = 'all' } = {}) {
|
|
46
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
47
|
+
const input = payload.tool_input;
|
|
48
|
+
const command = typeof input === 'string' ? input : input?.command ?? input?.cmd ?? input?.content ?? input?.patch;
|
|
49
|
+
const tool = payload.tool_name?.replace(/^(?:functions|tools)\./, '');
|
|
50
|
+
let paths = [];
|
|
51
|
+
if (['Bash', 'exec_command', 'shell_command', 'shell'].includes(tool)) paths = lint.writtenPathsOfBash(command);
|
|
52
|
+
else if (['apply_patch', 'Edit', 'Write'].includes(tool) && typeof command === 'string' && command.startsWith('*** Begin Patch')) {
|
|
53
|
+
// Patch headers are the path-bearing records of Codex's patch grammar.
|
|
54
|
+
// Check the final file, including move destinations, not diff markers.
|
|
55
|
+
paths = [...command.matchAll(/^\*\*\* (?:Add File|Update File|Move to): (.+)\r?$/gm)].map((m) => m[1].trimEnd());
|
|
56
|
+
} else return lint.lintToolUse(payload, { scope });
|
|
57
|
+
const messages = [];
|
|
58
|
+
for (const path of new Set(paths)) {
|
|
59
|
+
const file = resolve(payload.cwd || process.cwd(), typeof input?.workdir === 'string' ? input.workdir : '.', path);
|
|
60
|
+
if (!lint.isLintTarget(file, scope)) continue;
|
|
61
|
+
try {
|
|
62
|
+
const s = statSync(file);
|
|
63
|
+
if (!s.isFile() || s.size > 512 * 1024) continue;
|
|
64
|
+
let text = readFileSync(file, 'utf8');
|
|
65
|
+
if (lint.isHtmlFile(file)) text = lint.stripHtml(text);
|
|
66
|
+
const findings = lint.lintKoreanText(text, { code: !lint.isProseFile(file) });
|
|
67
|
+
if (findings.length) messages.push(lint.formatFindings(file, findings));
|
|
68
|
+
} catch { /* Deleted, moved, or unreadable files need no feedback. */ }
|
|
69
|
+
}
|
|
70
|
+
return messages.length ? messages.join('\n\n') : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {}) {
|
|
74
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
75
|
+
if (event === 'session-start' || event === 'subagent-start') {
|
|
76
|
+
if (event === 'subagent-start' && cfg?.codex?.delegate !== true) return null;
|
|
77
|
+
const root = findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' });
|
|
78
|
+
const parts = [];
|
|
79
|
+
// Keep the preset file current with this version's wording before it is read.
|
|
80
|
+
if (event === 'session-start') {
|
|
81
|
+
try { (await import('./preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch { /* Presets are optional. */ }
|
|
82
|
+
}
|
|
83
|
+
const { presetRatchetPath } = await import('./preset-ratchet.js');
|
|
84
|
+
for (const [scope, ratchet] of [['global', codexHarnessPaths(root, 'global').ratchet],
|
|
85
|
+
['preset', presetRatchetPath({ agent: 'codex' })], ['project', codexHarnessPaths(root, 'project').ratchet]]) {
|
|
86
|
+
try {
|
|
87
|
+
if (statSync(ratchet).size > 32 * 1024) {
|
|
88
|
+
parts.push(`Read the ${scope} Sprag ratchet rules at ${JSON.stringify(ratchet)} before working; they exceed the automatic injection limit.`);
|
|
89
|
+
} else {
|
|
90
|
+
const text = readFileSync(ratchet, 'utf8').trim();
|
|
91
|
+
// An empty preset file is only its header; injecting it would spend
|
|
92
|
+
// tokens every session on no rules.
|
|
93
|
+
if (text && (scope !== 'preset' || /^- /m.test(text))) parts.push(`[Sprag Codex ratchet: ${scope}]\n${text}`);
|
|
94
|
+
}
|
|
95
|
+
} catch { /* No rules registered in this scope. */ }
|
|
96
|
+
}
|
|
97
|
+
parts.push(koreanStyleInjection({ cfg }), await cohesionInjection({ cfg }));
|
|
98
|
+
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push(codexDocumentNote());
|
|
99
|
+
if (event === 'session-start' && payload.source !== 'compact') {
|
|
100
|
+
try { parts.push(seedOfferBlock({ root, agent: 'codex' })); } catch { /* Offers are optional. */ }
|
|
101
|
+
try { parts.push(await routeNotice(root)); } catch { /* A missing scan only delays candidates. */ }
|
|
102
|
+
}
|
|
103
|
+
if (event === 'subagent-start') parts.push(
|
|
104
|
+
'[Sprag Codex delegation]\nStay within the assigned task and any caller-provided budget. Return findings with file references and actual verification results; state unfinished work. Use only available Codex tools and configured roles. Do not infer a model tier or invent a tool-call budget.');
|
|
105
|
+
return context(event === 'subagent-start' ? 'SubagentStart' : 'SessionStart', parts.filter(Boolean).join('\n\n'));
|
|
106
|
+
}
|
|
107
|
+
if (event === 'prompt') {
|
|
108
|
+
const parts = [];
|
|
109
|
+
if (cfg?.codex?.delegate === true) {
|
|
110
|
+
try { parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) })); }
|
|
111
|
+
catch { /* Invalid routing state must not suppress briefing or conversion. */ }
|
|
112
|
+
}
|
|
113
|
+
if (cfg?.codex?.brief !== false) {
|
|
114
|
+
try { parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd })); }
|
|
115
|
+
catch { /* Local telemetry must not prevent document conversion. */ }
|
|
116
|
+
}
|
|
117
|
+
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
|
|
118
|
+
parts.push(doc2md.contextForPrompt(payload, { lang: userLanguage(), agent: 'codex', convert: convertCodexDocument }));
|
|
119
|
+
}
|
|
120
|
+
return context('UserPromptSubmit', parts.filter(Boolean).join('\n\n'));
|
|
121
|
+
}
|
|
122
|
+
if (event === 'pre-tool') {
|
|
123
|
+
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
|
|
124
|
+
const result = codexDocumentTool(payload);
|
|
125
|
+
if (result) return result;
|
|
126
|
+
}
|
|
127
|
+
return codexDelegationTool(payload, { cfg, root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) });
|
|
128
|
+
}
|
|
129
|
+
if (event === 'post-tool') {
|
|
130
|
+
if (!koreanStyleEnabled(cfg) || cfg?.koreanStyle?.lint === 'off') return null;
|
|
131
|
+
const result = lintCodexTool(payload, { scope: cfg?.koreanStyle?.lintScope === 'prose' ? 'prose' : 'all' });
|
|
132
|
+
const text = typeof result === 'string' ? result : result ? lint.formatFindings(result.filePath, result.findings) : null;
|
|
133
|
+
if (!text) return null;
|
|
134
|
+
if (cfg?.koreanStyle?.lint === 'warn') return context('PostToolUse', text);
|
|
135
|
+
return { decision: 'block', reason: text };
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Unknown Codex hook event: ${event}`);
|
|
138
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { codexUserDir } from './agent.js';
|
|
4
|
+
|
|
5
|
+
export const CODEX_HANDLERS = {
|
|
6
|
+
SessionStart: { matcher: 'startup|resume|clear|compact', command: 'sprag codex-hook --agent codex --event session-start', timeout: 15 },
|
|
7
|
+
UserPromptSubmit: { command: 'sprag codex-hook --agent codex --event prompt', timeout: 600 },
|
|
8
|
+
PreToolUse: { matcher: '^(Bash|Read|read_file|Edit|Write|Agent|spawn_agent|mcp__filesystem__read_(?:text_)?file|(?:functions\\.|tools\\.)?(?:exec_command|shell_command|shell|apply_patch|spawn_agent))$', command: 'sprag codex-hook --agent codex --event pre-tool', timeout: 600 },
|
|
9
|
+
PostToolUse: { matcher: '^(Bash|apply_patch|Edit|Write|(?:functions\\.|tools\\.)?(?:exec_command|shell_command|shell|apply_patch))$', command: 'sprag codex-hook --agent codex --event post-tool', timeout: 15 },
|
|
10
|
+
SubagentStart: { command: 'sprag codex-hook --agent codex --event subagent-start', timeout: 15 },
|
|
11
|
+
};
|
|
12
|
+
const ownCommands = new Set(Object.values(CODEX_HANDLERS).map((h) => h.command));
|
|
13
|
+
const PANEL_COMMAND = 'sprag codex-hook --agent codex --event panel-start';
|
|
14
|
+
ownCommands.add(PANEL_COMMAND);
|
|
15
|
+
|
|
16
|
+
export function codexPanelAutoEnabled() {
|
|
17
|
+
const data = readHooks(join(codexUserDir(), 'hooks.json'));
|
|
18
|
+
return (data.hooks?.SessionStart || []).some((g) => g.hooks.some((h) => h?.command === PANEL_COMMAND));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readCodexHooks(file = join(codexUserDir(), 'hooks.json')) {
|
|
22
|
+
if (!existsSync(file)) return {};
|
|
23
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
24
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) ||
|
|
25
|
+
(data.hooks !== undefined && (!data.hooks || typeof data.hooks !== 'object' || Array.isArray(data.hooks)))) {
|
|
26
|
+
throw new Error(`Invalid Codex hooks configuration: ${file}`);
|
|
27
|
+
}
|
|
28
|
+
for (const groups of Object.values(data.hooks || {})) {
|
|
29
|
+
if (!Array.isArray(groups) || groups.some((g) => !g || !Array.isArray(g.hooks))) {
|
|
30
|
+
throw new Error(`Invalid Codex hook groups: ${file}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const readHooks = readCodexHooks;
|
|
37
|
+
|
|
38
|
+
export function codexHookStatus() {
|
|
39
|
+
const data = readCodexHooks();
|
|
40
|
+
return Object.entries(CODEX_HANDLERS).map(([event, expected]) => {
|
|
41
|
+
const groups = data.hooks?.[event] || [];
|
|
42
|
+
// Async hooks cannot enforce write guards or lint feedback. Conversion hooks
|
|
43
|
+
// also need time for interpreter probes and multiple bounded conversions.
|
|
44
|
+
const registered = groups.some((group) => group.matcher === expected.matcher && group.hooks.some((hook) =>
|
|
45
|
+
hook?.type === 'command' && hook.command === expected.command && hook.enabled !== false && hook.async !== true &&
|
|
46
|
+
Number.isFinite(hook.timeout ?? 600) && (hook.timeout ?? 600) >= expected.timeout));
|
|
47
|
+
return { event, registered };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function configureCodexHooks({ remove = false, panelAuto } = {}) {
|
|
52
|
+
const file = join(codexUserDir(), 'hooks.json');
|
|
53
|
+
const data = readHooks(file);
|
|
54
|
+
const before = JSON.stringify(data);
|
|
55
|
+
const auto = panelAuto ?? (data.hooks?.SessionStart || []).some((g) => g.hooks.some((h) => h?.command === PANEL_COMMAND));
|
|
56
|
+
data.hooks ||= {};
|
|
57
|
+
for (const [event, groups] of Object.entries(data.hooks)) {
|
|
58
|
+
data.hooks[event] = groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !ownCommands.has(h?.command)) }))
|
|
59
|
+
.filter((g) => g.hooks.length);
|
|
60
|
+
if (!data.hooks[event].length) delete data.hooks[event];
|
|
61
|
+
}
|
|
62
|
+
if (!remove) {
|
|
63
|
+
for (const [event, { matcher, command, timeout }] of Object.entries(CODEX_HANDLERS)) {
|
|
64
|
+
const group = { hooks: [{ type: 'command', command, timeout }] };
|
|
65
|
+
if (matcher) group.matcher = matcher;
|
|
66
|
+
(data.hooks[event] ||= []).push(group);
|
|
67
|
+
}
|
|
68
|
+
if (auto) data.hooks.SessionStart.push({ matcher: 'startup|resume', hooks: [{ type: 'command', command: PANEL_COMMAND, timeout: 10 }] });
|
|
69
|
+
}
|
|
70
|
+
if (remove && !Object.keys(data.hooks).length) delete data.hooks;
|
|
71
|
+
if (JSON.stringify(data) !== before) {
|
|
72
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
73
|
+
if (existsSync(file)) writeFileSync(`${file}.bak-${Date.now()}`, readFileSync(file));
|
|
74
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
75
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
76
|
+
renameSync(tmp, file);
|
|
77
|
+
}
|
|
78
|
+
return { file, action: remove ? 'removed' : 'configured' };
|
|
79
|
+
}
|