sprag-cli 3.59.0 → 3.61.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 +17 -0
- package/README.md +23 -0
- package/bin/cli.js +51 -3
- package/docs/CODEX.ko.md +69 -6
- package/docs/CODEX.md +84 -12
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/caps-cache.js +3 -3
- package/src/codex-brief.js +4 -5
- package/src/codex-cache-policy.js +3 -3
- package/src/codex-delegation.js +114 -15
- package/src/codex-doc2md-ledger.js +3 -3
- package/src/codex-doctor.js +2 -2
- package/src/codex-hooks.js +4 -5
- package/src/codex-installer.js +3 -3
- package/src/codex-ledger.js +25 -10
- package/src/codex-panel-session.js +3 -3
- package/src/codex-panel-state.js +51 -5
- package/src/codex-route-scan.js +12 -7
- package/src/commands/brief.js +1 -1
- package/src/commands/codex-delegate.js +81 -6
- package/src/commands/codex.js +8 -2
- package/src/commands/compact-window.js +6 -2
- package/src/commands/install.js +4 -1
- package/src/commands/korean.js +69 -3
- package/src/commands/route-scan.js +6 -4
- package/src/commands/saved.js +40 -1
- package/src/ctx-window-cache.js +14 -0
- package/src/formatters/statusline.js +167 -18
- package/src/harness-analyzer.cjs +8 -2
- package/src/harness.js +32 -23
- package/src/installer.js +52 -0
- package/src/model-alias.js +3 -3
- package/src/model-rules.js +14 -9
- package/src/preset-ratchet.js +3 -3
- package/src/reply-language.cjs +167 -0
- package/src/route-inject.js +57 -8
- package/src/route-scan.js +51 -11
- package/src/savings-ledger.js +3 -2
- package/src/seed-rules.js +32 -23
- package/src/session-cache.js +12 -4
- package/src/shared-model-rules.js +73 -0
- package/src/state-file.js +99 -2
- package/src/statusline-data.js +97 -0
- package/src/update-check.js +46 -6
package/src/codex-delegation.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, mkdirSync
|
|
1
|
+
import { readFileSync, readdirSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { parse } from 'smol-toml';
|
|
5
5
|
import { userDataDir } from './paths.js';
|
|
6
|
+
import { writeViaTmp } from './state-file.js';
|
|
6
7
|
import { codexUserDir, codexProviderName } from './agent.js';
|
|
7
8
|
import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
|
|
8
9
|
import { looksPasted } from './route-inject.js';
|
|
9
10
|
import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
|
|
10
11
|
import { readCodexTailLines } from './codex-parser.js';
|
|
12
|
+
import { loadConfig } from './config.js';
|
|
13
|
+
import { loadSharedModelRules, sharedModelRulesForProject, sharedRuleBounds, SHARED_RULE_TIERS } from './shared-model-rules.js';
|
|
11
14
|
|
|
12
15
|
export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
|
|
13
16
|
const MARKER = '<!-- sprag:codex:delegation -->';
|
|
@@ -22,6 +25,32 @@ export function validateCodexTarget(model, effort) {
|
|
|
22
25
|
return { model, ...(effort === undefined ? {} : { effort }) };
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
export function validateCodexSharedTarget({ tier, from, model, effort, provider }) {
|
|
29
|
+
validateCodexTarget(model, effort);
|
|
30
|
+
if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
|
|
31
|
+
if (!safeId(from) || from === model) throw new Error('--from must name a different parent model.');
|
|
32
|
+
if (!safeId(provider)) throw new Error('A shared model mapping requires a Codex provider ID.');
|
|
33
|
+
return { tier, from, model, ...(effort === undefined ? {} : { effort }), provider };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function codexSharedTargets(cfg) {
|
|
37
|
+
const targets = cfg?.codex?.sharedRules?.targets;
|
|
38
|
+
if (!Array.isArray(targets)) return [];
|
|
39
|
+
return targets.flatMap((target) => {
|
|
40
|
+
try { return [validateCodexSharedTarget(target)]; } catch { return []; }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sharedCodexModelRules({ cfg = loadConfig(), dir = userDataDir(), root, start = root,
|
|
45
|
+
model, provider = codexProviderName() } = {}) {
|
|
46
|
+
if (cfg.codex?.sharedRules?.enabled === false) return [];
|
|
47
|
+
const targets = codexSharedTargets(cfg).filter((t) => t.from === model && t.provider === provider);
|
|
48
|
+
return sharedModelRulesForProject(loadSharedModelRules({ dir }), start).flatMap((rule) => {
|
|
49
|
+
const target = targets.find((t) => t.tier === rule.tier);
|
|
50
|
+
return target ? [{ ...rule, ...target, source: 'shared', status: 'active' }] : [];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
const rulesFile = (dir) => join(dir, 'codex-model-rules.json');
|
|
26
55
|
export function loadCodexModelRules({ dir = userDataDir() } = {}) {
|
|
27
56
|
try {
|
|
@@ -41,8 +70,7 @@ export function loadCodexModelRules({ dir = userDataDir() } = {}) {
|
|
|
41
70
|
function saveRules(rules, dir) {
|
|
42
71
|
mkdirSync(dir, { recursive: true });
|
|
43
72
|
const file = rulesFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
44
|
-
|
|
45
|
-
renameSync(tmp, file);
|
|
73
|
+
writeViaTmp(tmp, file, JSON.stringify({ version: 1, rules }, null, 2) + '\n', { mode: 0o600 });
|
|
46
74
|
}
|
|
47
75
|
|
|
48
76
|
export function addCodexModelRule({ category, model, effort, from, scope, root, provider }, { dir = userDataDir() } = {}) {
|
|
@@ -92,6 +120,16 @@ export function matchCodexModelRule(text, { model, root, provider = codexProvide
|
|
|
92
120
|
return usable.find((r) => r.scope === 'project') || usable[0] || null;
|
|
93
121
|
}
|
|
94
122
|
|
|
123
|
+
/** A Codex-only rule wins; otherwise the shared policies mapped for this parent, T2 first. */
|
|
124
|
+
function matchingRoutes(text, opts) {
|
|
125
|
+
const own = matchCodexModelRule(text, { ...opts, rules: opts.rules ?? loadCodexModelRules({ dir: opts.dir }) });
|
|
126
|
+
if (own) return [own];
|
|
127
|
+
const category = codexPromptCategory(text);
|
|
128
|
+
if (!category) return [];
|
|
129
|
+
return sharedCodexModelRules(opts).filter((r) => r.category === category.id)
|
|
130
|
+
.sort((a, b) => SHARED_RULE_TIERS.indexOf(a.tier) - SHARED_RULE_TIERS.indexOf(b.tier));
|
|
131
|
+
}
|
|
132
|
+
|
|
95
133
|
/**
|
|
96
134
|
* Input tokens from the rollout's last token_count record, or 0 when the
|
|
97
135
|
* transcript is missing or unreadable. Only the tail is read: the newest
|
|
@@ -111,32 +149,85 @@ export function codexContextTokens(transcriptPath) {
|
|
|
111
149
|
return 0;
|
|
112
150
|
}
|
|
113
151
|
|
|
152
|
+
export const DEFAULT_DELEGATE_MIN_CONTEXT = 60000;
|
|
153
|
+
|
|
154
|
+
/** Parent input tokens below which the prompt hint stays silent. */
|
|
155
|
+
export function codexDelegateMinContext(cfg) {
|
|
156
|
+
const value = cfg?.codex?.delegateMinContext;
|
|
157
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_DELEGATE_MIN_CONTEXT;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Codex tells the model to set spawn_agent's model only on the user's request,
|
|
161
|
+
// so the shared hint says where this request came from.
|
|
162
|
+
const SHARED_APPROVAL = 'The user approved this policy and its model mapping, so setting model here follows the user\'s request.';
|
|
163
|
+
const SHARED_TIER_GUIDANCE = 'Choose T2 only for a simple bounded lookup or command. Use T1 for multi-step tool chains or work spanning multiple files. If the required tier is not mapped, keep the task on the main agent. Do not delegate design judgement, diagnosis, or irreversible actions.';
|
|
164
|
+
const WAIT_TEXT = 'Wait for it to finish (call wait_agent with timeout_ms of at least 120000, and again if it times out) and do not do the delegated task yourself while it runs; then verify its result and report unfinished work. Do not spawn another CLI process. '
|
|
165
|
+
+ 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
|
|
166
|
+
|
|
167
|
+
function sharedTargetText(rule) {
|
|
168
|
+
return `${rule.tier}: model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''}. ${sharedRuleBounds(rule)}`.trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sharedDenyReason(routes) {
|
|
172
|
+
return `[Sprag model routing] This spawn matches the approved shared ${routes[0].category} policy but names no model. ${SHARED_APPROVAL} `
|
|
173
|
+
// A full-history fork inherits the parent model and refuses overrides.
|
|
174
|
+
+ 'Spawn again with fork_turns "none" and the model and reasoning_effort of the tier you choose, keeping the route line the routing hint gave for that tier:\n'
|
|
175
|
+
+ routes.map(sharedTargetText).join('\n')
|
|
176
|
+
+ `\n${SHARED_TIER_GUIDANCE} To keep the task on your own model, pass that model explicitly. If a model cannot be set, do the task on the main agent instead of spawning again.`;
|
|
177
|
+
}
|
|
178
|
+
|
|
114
179
|
/**
|
|
115
180
|
* A prompt-time routing suggestion for Codex 0.159.2, where `spawn_agent`
|
|
116
181
|
* never reaches PreToolUse (see codexDelegationTool below). Gated on parent
|
|
117
182
|
* context size: a small parent's own coordination overhead outweighs the
|
|
118
183
|
* savings from a delegated child (measured 2026-09-30, see docs/CODEX.md).
|
|
184
|
+
*
|
|
185
|
+
* A shared policy offers both mapped tiers and records one route per tier;
|
|
186
|
+
* SubagentStart binds the child to the route whose model it runs, and the
|
|
187
|
+
* next prompt closes the tier that was not chosen.
|
|
119
188
|
*/
|
|
120
189
|
export function codexRouteHint(payload, opts = {}) {
|
|
190
|
+
// The hook passes its loaded config. Without one there are no shared
|
|
191
|
+
// mappings and the default gate applies, so a caller never routes from the
|
|
192
|
+
// user's real config by accident.
|
|
193
|
+
const cfg = opts.cfg ?? {};
|
|
121
194
|
const provider = payload?.model_provider || codexProviderName();
|
|
122
|
-
const
|
|
195
|
+
const routes = matchingRoutes(payload?.prompt, { cfg, provider, ...opts, model: payload?.model,
|
|
196
|
+
root: opts.root || payload?.cwd, start: payload?.cwd });
|
|
197
|
+
const rule = routes[0];
|
|
123
198
|
if (!rule) return null;
|
|
124
|
-
const minContext = opts.minContext ??
|
|
199
|
+
const minContext = opts.minContext ?? codexDelegateMinContext(cfg);
|
|
125
200
|
const ctx = opts.contextTokens ?? codexContextTokens(payload?.transcript_path);
|
|
126
201
|
if (ctx < minContext) return null;
|
|
127
|
-
const id = newRouteId();
|
|
128
202
|
const record = opts.record ?? recordCodexDelegation;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
203
|
+
// Tiers offered by one hint are alternatives. SubagentStart binds by model
|
|
204
|
+
// alone, so it must know which records compete for the same child.
|
|
205
|
+
const offerId = routes.length > 1 ? newRouteId() : null;
|
|
206
|
+
const recordRoute = (route) => {
|
|
207
|
+
const id = newRouteId();
|
|
208
|
+
const shared = route.source === 'shared';
|
|
209
|
+
try {
|
|
210
|
+
record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
|
|
211
|
+
source: shared ? 'shared' : 'rule', category: route.category, scope: route.scope, from: payload?.model, to: route.model,
|
|
212
|
+
provider: route.provider || provider, effort: route.effort ?? null, contextTokens: ctx, targetRoot: route.targetRoot ?? null,
|
|
213
|
+
...(shared ? { tier: route.tier, sharedSignature: route.sharedSignature, ...(offerId ? { offerId } : {}) }
|
|
214
|
+
: { ruleCreatedAt: route.createdAt ?? null }) }, { dir: opts.dir });
|
|
215
|
+
} catch { /* Accounting is optional; the hint still applies. */ }
|
|
216
|
+
return id;
|
|
217
|
+
};
|
|
218
|
+
if (rule.source === 'shared') {
|
|
219
|
+
const targets = routes.map((route) => `${sharedTargetText(route)} Route line: <!-- sprag:codex:route id=${recordRoute(route)} -->`);
|
|
220
|
+
return `[Sprag model routing] This request matches the approved shared ${rule.category} policy. ${SHARED_APPROVAL} ${SHARED_TIER_GUIDANCE}\n`
|
|
221
|
+
+ `${targets.join('\n')}\n`
|
|
222
|
+
+ 'Spawn at most one sub-agent with the selected model, reasoning_effort when specified, and fork_turns "none"; a full-history fork ignores the model. '
|
|
223
|
+
+ 'Give it a self-contained task message with the selected cap that ends with that tier\'s route line only. '
|
|
224
|
+
+ WAIT_TEXT;
|
|
225
|
+
}
|
|
226
|
+
const id = recordRoute(rule);
|
|
135
227
|
return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
|
|
136
228
|
+ `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
|
|
137
229
|
+ `Give it a self-contained task message that ends with the line <!-- sprag:codex:route id=${id} -->. `
|
|
138
|
-
+
|
|
139
|
-
+ 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
|
|
230
|
+
+ WAIT_TEXT;
|
|
140
231
|
}
|
|
141
232
|
|
|
142
233
|
function customRole(role, root, home) {
|
|
@@ -177,7 +268,15 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
|
|
|
177
268
|
let rules = given;
|
|
178
269
|
if (!rules) { try { rules = loadCodexModelRules({ dir }); } catch { rules = []; } }
|
|
179
270
|
const provider = payload.model_provider || codexProviderName({ home });
|
|
180
|
-
const
|
|
271
|
+
const routes = explicit ? [] : matchingRoutes(input.message, { model: payload.model, provider, root, start: payload.cwd, rules, cfg, dir });
|
|
272
|
+
// Only the caller can choose a shared policy's tier. A shared match without a
|
|
273
|
+
// model goes back to the caller with the mapped choices instead of a guessed
|
|
274
|
+
// downgrade, or a default target that ignores the tier.
|
|
275
|
+
if (routes[0]?.source === 'shared') {
|
|
276
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
277
|
+
permissionDecisionReason: sharedDenyReason(routes) } };
|
|
278
|
+
}
|
|
279
|
+
const rule = routes[0] ?? null;
|
|
181
280
|
const target = rule || (!explicit && cfg.codex?.delegateTarget);
|
|
182
281
|
const updatedInput = { ...input };
|
|
183
282
|
let routeLine = '';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readdirSync, readFileSync,
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { extname, join, resolve } from 'node:path';
|
|
4
4
|
import { codexUserDir } from './agent.js';
|
|
5
5
|
import { userDataDir } from './paths.js';
|
|
6
|
+
import { writeViaTmp } from './state-file.js';
|
|
6
7
|
import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
|
|
7
8
|
|
|
8
9
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
@@ -22,8 +23,7 @@ export function recordCodexDocument(source, result, opts) {
|
|
|
22
23
|
clipped: !!(meta.clipped || meta.truncated) };
|
|
23
24
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
25
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
25
|
-
|
|
26
|
-
renameSync(tmp, file);
|
|
26
|
+
writeViaTmp(tmp, file, JSON.stringify(event) + '\n', { mode: 0o600 });
|
|
27
27
|
} catch { /* Accounting must not prevent document conversion. */ }
|
|
28
28
|
}
|
|
29
29
|
|
package/src/codex-doctor.js
CHANGED
|
@@ -17,13 +17,13 @@ export const CODEX_CAPABILITIES = [
|
|
|
17
17
|
{ feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
|
|
18
18
|
{ feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
|
|
19
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: '
|
|
20
|
+
{ feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Native routing and shared delegation policies with explicit Codex tier mappings. Explicit models/custom roles preserved; bounds are guidance, not enforced limits.' },
|
|
21
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
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
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
24
|
{ feature: 'upgrade', support: 'manual', command: 'sprag upgrade --print --agent codex', note: 'Skip the Claude npm postinstall; reinstall Codex hooks after upgrade.' },
|
|
25
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: '
|
|
26
|
+
{ feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Shared delegation policies plus agent-compatible ratchet presets. Explicit scope; Codex model mappings required. No Claude installation needed.' },
|
|
27
27
|
{ feature: 'transcript ratchet', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Recent repeated tool failures become ratchet? candidates; no automatic rule registration.' },
|
|
28
28
|
{ feature: 'compact-window', support: 'native', note: 'Codex uses model_auto_compact_token_limit; no Claude autoCompactWindow heuristic.' },
|
|
29
29
|
{ feature: 'cost / exact cache expiry', support: 'unavailable', note: 'Not measured in rollout logs; never estimated with Anthropic prices.' },
|
package/src/codex-hooks.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { mkdirSync, openSync, readFileSync, readSync, closeSync,
|
|
4
|
+
import { mkdirSync, openSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
6
|
import { loadConfig, userLanguage } from './config.js';
|
|
7
7
|
import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
|
|
8
8
|
import { cohesionInjection } from './cohesion.js';
|
|
9
9
|
import { codexHarnessPaths } from './codex-harness.js';
|
|
10
|
+
import { writeViaTmp } from './state-file.js';
|
|
10
11
|
import { findProjectRoot } from './harness.js';
|
|
11
12
|
import { runCodexBrief } from './codex-brief.js';
|
|
12
13
|
import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
|
|
@@ -69,8 +70,7 @@ export function fitCodexContext(parts, { budget = CODEX_CONTEXT_TOKEN_BUDGET, ov
|
|
|
69
70
|
function writeOverflow(file, text) {
|
|
70
71
|
mkdirSync(join(file, '..'), { recursive: true, mode: 0o700 });
|
|
71
72
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
72
|
-
|
|
73
|
-
renameSync(tmp, file);
|
|
73
|
+
writeViaTmp(tmp, file, text, { mode: 0o600 });
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function overflowFileFor(root) {
|
|
@@ -212,8 +212,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
|
|
|
212
212
|
try { closeStaleCodexRoutes(payload); } catch (e) { debug('codex:close-routes', e); }
|
|
213
213
|
try { if (unsettledCodexRoutes() > 0) refreshLedger(); } catch (e) { debug('codex:ledger-refresh', e); }
|
|
214
214
|
try {
|
|
215
|
-
parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' })
|
|
216
|
-
minContext: Number.isFinite(cfg?.codex?.delegateMinContext) ? cfg.codex.delegateMinContext : undefined }));
|
|
215
|
+
parts.push(codexRouteHint(payload, { cfg, root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) }));
|
|
217
216
|
} catch (e) { debug('codex:route-hint', e); }
|
|
218
217
|
}
|
|
219
218
|
if (cfg?.codex?.brief !== false) {
|
package/src/codex-installer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { codexUserDir } from './agent.js';
|
|
4
|
+
import { writeViaTmp } from './state-file.js';
|
|
4
5
|
|
|
5
6
|
export const CODEX_HANDLERS = {
|
|
6
7
|
SessionStart: { matcher: 'startup|resume|clear|compact', command: 'sprag codex-hook --agent codex --event session-start', timeout: 15 },
|
|
@@ -72,8 +73,7 @@ export function configureCodexHooks({ remove = false, panelAuto } = {}) {
|
|
|
72
73
|
mkdirSync(dirname(file), { recursive: true });
|
|
73
74
|
if (existsSync(file)) writeFileSync(`${file}.bak-${Date.now()}`, readFileSync(file));
|
|
74
75
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
75
|
-
|
|
76
|
-
renameSync(tmp, file);
|
|
76
|
+
writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n');
|
|
77
77
|
}
|
|
78
78
|
return { file, action: remove ? 'removed' : 'configured' };
|
|
79
79
|
}
|
package/src/codex-ledger.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
4
|
import { userDataDir } from './paths.js';
|
|
5
|
+
import { writeViaTmp } from './state-file.js';
|
|
5
6
|
import { roundUsd } from './savings-ledger.js';
|
|
6
7
|
import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
|
|
7
8
|
import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
@@ -52,8 +53,7 @@ export function pruneCodexDelegations({ dir = userDataDir(), now = Date.now() }
|
|
|
52
53
|
try { all = readFileSync(pendingFile(dir), 'utf8').split('\n').filter(Boolean).length; } catch { return; }
|
|
53
54
|
if (keep.length === all) return;
|
|
54
55
|
const file = pendingFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
55
|
-
|
|
56
|
-
renameSync(tmp, file);
|
|
56
|
+
writeViaTmp(tmp, file, keep.map((r) => JSON.stringify(r) + '\n').join(''), { mode: 0o600 });
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function readCodexDelegations({ dir = userDataDir() } = {}) {
|
|
@@ -88,20 +88,24 @@ export function readCodexBindings({ dir = userDataDir() } = {}) {
|
|
|
88
88
|
return out;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/**
|
|
92
|
-
function
|
|
91
|
+
/** When a later prompt closed each route no child had claimed. Kept in the binds file as `{ id, closed: true, at }` lines. */
|
|
92
|
+
function closedCodexRouteTimes({ dir = userDataDir() } = {}) {
|
|
93
93
|
let text;
|
|
94
|
-
try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new
|
|
95
|
-
const out = new
|
|
94
|
+
try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new Map(); }
|
|
95
|
+
const out = new Map();
|
|
96
96
|
for (const line of text.split('\n')) {
|
|
97
97
|
try {
|
|
98
98
|
const b = JSON.parse(line);
|
|
99
|
-
if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id)) out.
|
|
99
|
+
if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id) && !out.has(b.id)) out.set(b.id, Number.isFinite(b.at) ? b.at : -Infinity);
|
|
100
100
|
} catch { /* Partial trailing line. */ }
|
|
101
101
|
}
|
|
102
102
|
return out;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function closedCodexRouteIds(opts) {
|
|
106
|
+
return new Set(closedCodexRouteTimes(opts).keys());
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
/**
|
|
106
110
|
* Close the routes this parent was hinted in an earlier turn and never spawned.
|
|
107
111
|
*
|
|
@@ -142,6 +146,9 @@ export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now
|
|
|
142
146
|
if (!pending.length) return null;
|
|
143
147
|
// Oldest first: spawns are recorded in order, so the earliest unbound record belongs to the earliest child.
|
|
144
148
|
const chosen = pending.reduce((a, b) => (b.at < a.at ? b : a));
|
|
149
|
+
// Tiers one hint offered together can map to the same model; the model then
|
|
150
|
+
// cannot say which tier this child is. Leave it to the child's route marker.
|
|
151
|
+
if (chosen.offerId && pending.some((r) => r !== chosen && r.offerId === chosen.offerId)) return null;
|
|
145
152
|
recordCodexBinding({ id: chosen.id, childSessionId: payload.agent_id, at: now }, { dir });
|
|
146
153
|
return chosen.id;
|
|
147
154
|
}
|
|
@@ -173,8 +180,7 @@ export function loadCodexLedger({ dir = userDataDir() } = {}) {
|
|
|
173
180
|
function saveLedger(data, dir) {
|
|
174
181
|
mkdirSync(dir, { recursive: true });
|
|
175
182
|
const file = ledgerFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
176
|
-
|
|
177
|
-
renameSync(tmp, file);
|
|
183
|
+
writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
/**
|
|
@@ -221,6 +227,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
221
227
|
if (!pending.length) return ledger;
|
|
222
228
|
const byId = new Map(pending.map((r) => [r.id, r]));
|
|
223
229
|
const bindMap = new Map(readCodexBindings({ dir }).filter((b) => byId.has(b.id)).map((b) => [b.childSessionId, b.id]));
|
|
230
|
+
const closedAt = closedCodexRouteTimes({ dir });
|
|
224
231
|
const oldest = Math.min(...pending.map((r) => r.at));
|
|
225
232
|
const files = await discoverCodexSessionFiles({ home, days: Math.max(1, (now - oldest) / 86400000 + 1) });
|
|
226
233
|
let changed = false;
|
|
@@ -233,7 +240,14 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
233
240
|
// The id must come from a spawn this parent made, after the record was written.
|
|
234
241
|
if (!route || (route.parentSessionId && run.parentThreadId && route.parentSessionId !== run.parentThreadId)) continue;
|
|
235
242
|
if (route.provider && route.provider !== run.provider) continue;
|
|
243
|
+
// A shared policy names its model only through the caller's choice, so
|
|
244
|
+
// credit it only for a child of that parent that ran the mapped model.
|
|
245
|
+
if (route.source === 'shared' && (run.model !== route.to || !route.parentSessionId ||
|
|
246
|
+
run.parentThreadId !== route.parentSessionId)) continue;
|
|
236
247
|
if (Number.isFinite(run.startedAt) && run.startedAt < route.at - 5000) continue;
|
|
248
|
+
// A closed route ended with its hinted turn. Only a child that started
|
|
249
|
+
// before the close can be its run; a later marker is a copy, not a spawn.
|
|
250
|
+
if (closedAt.has(route.id) && !(Number.isFinite(run.startedAt) && run.startedAt < closedAt.get(route.id))) continue;
|
|
237
251
|
if (seen.has(route.id)) continue;
|
|
238
252
|
seen.add(route.id);
|
|
239
253
|
const to = run.model || route.to;
|
|
@@ -250,6 +264,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
250
264
|
const usd = actual === null || counterfactual === null ? null : roundUsd(run.aborted ? -actual : counterfactual - actual);
|
|
251
265
|
const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
|
|
252
266
|
childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
|
|
267
|
+
...(route.source === 'shared' ? { tier: route.tier, sharedSignature: route.sharedSignature } : {}),
|
|
253
268
|
from: route.from, to, provider,
|
|
254
269
|
...(route.targetRoot !== undefined ? { targetRoot: route.targetRoot } : {}),
|
|
255
270
|
...(route.ruleCreatedAt !== undefined ? { ruleCreatedAt: route.ruleCreatedAt } : {}), tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { writeViaTmp } from './state-file.js';
|
|
3
4
|
|
|
4
5
|
export function createPanelBinding(file) {
|
|
5
6
|
writeFileSync(file, JSON.stringify({ version: 1, sessionId: null, transcriptPath: null }), { mode: 0o600 });
|
|
@@ -27,9 +28,8 @@ export function bindPanelSession(event, payload, { file = process.env.SPRAG_CODE
|
|
|
27
28
|
if (current.version !== 1) return false;
|
|
28
29
|
if (payload.source === 'compact' && current.sessionId && current.sessionId !== payload.session_id) return false;
|
|
29
30
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
30
|
-
|
|
31
|
+
writeViaTmp(tmp, file, JSON.stringify({ version: 1, sessionId: payload.session_id,
|
|
31
32
|
transcriptPath: typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) ? payload.transcript_path : null }), { mode: 0o600 });
|
|
32
|
-
renameSync(tmp, file);
|
|
33
33
|
return true;
|
|
34
34
|
} catch { return false; }
|
|
35
35
|
}
|
package/src/codex-panel-state.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFileSync,
|
|
2
|
+
import { readFileSync, mkdirSync, readdirSync, lstatSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { userDataDir } from './paths.js';
|
|
5
5
|
import { codexUserDir } from './agent.js';
|
|
6
|
+
import { writeViaTmp, runHousekeeping, sweepStaleTmp } from './state-file.js';
|
|
7
|
+
|
|
8
|
+
// Every panel session leaves a `-frame` and a `-hook` file behind, keyed by a
|
|
9
|
+
// hash that is not reused once the session is gone. They are diagnostics for a
|
|
10
|
+
// live session, so a file untouched for a week describes one that is over.
|
|
11
|
+
const PANEL_STATE_FILE = /^codex-panel-[0-9a-f]{64}-(?:frame|hook)\.json$/;
|
|
12
|
+
export const PANEL_STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
13
|
|
|
7
14
|
export function panelStatePath(root, kind, dir = userDataDir(), sessionId) {
|
|
8
15
|
const key = createHash('sha256').update(codexUserDir() + '\0' + resolve(root) + (sessionId ? `\0${sessionId}` : '')).digest('hex');
|
|
@@ -15,17 +22,56 @@ export function recordPanelState(root, kind, value, dir = userDataDir()) {
|
|
|
15
22
|
const file = panelStatePath(root, kind, dir);
|
|
16
23
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
17
24
|
const text = JSON.stringify({ ...value, at: new Date().toISOString() });
|
|
18
|
-
|
|
19
|
-
renameSync(tmp, file);
|
|
25
|
+
writeViaTmp(tmp, file, text);
|
|
20
26
|
if (value.sessionId) {
|
|
21
27
|
const sessionFile = panelStatePath(root, kind, dir, value.sessionId);
|
|
22
28
|
const sessionTmp = `${sessionFile}.${process.pid}.tmp`;
|
|
23
|
-
|
|
24
|
-
renameSync(sessionTmp, sessionFile);
|
|
29
|
+
writeViaTmp(sessionTmp, sessionFile, text);
|
|
25
30
|
}
|
|
31
|
+
// A live panel records a frame every couple of seconds, so the clean-up is
|
|
32
|
+
// rate-limited to once a day (see runHousekeeping) rather than listing the
|
|
33
|
+
// directory on every write. This session's own files, of both kinds, are
|
|
34
|
+
// never candidates: a session that has run for over a week keeps writing
|
|
35
|
+
// frames while its hook record is just as old.
|
|
36
|
+
runHousekeeping(dir, {
|
|
37
|
+
'stale-tmp': () => sweepStaleTmp(dir),
|
|
38
|
+
'codex-panel': () => sweepPanelState({
|
|
39
|
+
dir,
|
|
40
|
+
keep: ['frame', 'hook'].flatMap((k) => [
|
|
41
|
+
panelStatePath(root, k, dir),
|
|
42
|
+
...(value.sessionId ? [panelStatePath(root, k, dir, value.sessionId)] : []),
|
|
43
|
+
]),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
26
46
|
} catch { /* Diagnostics must not stop a session or panel. */ }
|
|
27
47
|
}
|
|
28
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Remove panel state files untouched for `maxAgeMs`. Only the exact
|
|
51
|
+
* `codex-panel-<64 hex>-(frame|hook).json` shape is considered, and anything
|
|
52
|
+
* in `keep` (full paths) is left alone whatever its mtime.
|
|
53
|
+
*
|
|
54
|
+
* @returns {number} how many files were removed
|
|
55
|
+
*/
|
|
56
|
+
export function sweepPanelState({ dir = userDataDir(), now = Date.now(), maxAgeMs = PANEL_STATE_MAX_AGE_MS, keep = [] } = {}) {
|
|
57
|
+
let names;
|
|
58
|
+
try { names = readdirSync(dir); } catch { return 0; }
|
|
59
|
+
const protectedPaths = new Set(keep);
|
|
60
|
+
let removed = 0;
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
if (!PANEL_STATE_FILE.test(name)) continue;
|
|
63
|
+
const path = join(dir, name);
|
|
64
|
+
if (protectedPaths.has(path)) continue;
|
|
65
|
+
try {
|
|
66
|
+
const st = lstatSync(path);
|
|
67
|
+
if (!st.isFile() || now - st.mtimeMs <= maxAgeMs) continue;
|
|
68
|
+
unlinkSync(path);
|
|
69
|
+
removed += 1;
|
|
70
|
+
} catch { /* already gone, or not ours to remove */ }
|
|
71
|
+
}
|
|
72
|
+
return removed;
|
|
73
|
+
}
|
|
74
|
+
|
|
29
75
|
export function readPanelState(root, kind, dir = userDataDir(), sessionId) {
|
|
30
76
|
try { return JSON.parse(readFileSync(panelStatePath(root, kind, dir, sessionId), 'utf8')); }
|
|
31
77
|
catch { return null; }
|
package/src/codex-route-scan.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { userDataDir } from './paths.js';
|
|
5
|
+
import { writeViaTmp } from './state-file.js';
|
|
5
6
|
import { loadConfig } from './config.js';
|
|
6
7
|
import { findProjectRoot } from './harness.js';
|
|
7
8
|
import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
8
9
|
import { categorize, isSkippable, tierOf, calibrateThresholds, MIN_RECURRENCE,
|
|
9
10
|
RESCAN_MIN_INTERVAL_MS, RESCAN_BIG_DELTA_BYTES, RESCAN_MAX_AGE_MS } from './route-scan.js';
|
|
10
|
-
import { codexPromptCategory, loadCodexModelRules } from './codex-delegation.js';
|
|
11
|
+
import { codexPromptCategory, loadCodexModelRules, sharedCodexModelRules, codexSharedTargets } from './codex-delegation.js';
|
|
11
12
|
import { codexRunCost, codexPriceBook, isDirectOpenAI, readCodexPrices } from './codex-cache-policy.js';
|
|
12
13
|
import { OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
|
|
13
14
|
import { refreshCodexLedger } from './codex-ledger.js';
|
|
@@ -32,8 +33,7 @@ export function readCodexRouteScan({ dir = userDataDir() } = {}) {
|
|
|
32
33
|
function writeCache(data, dir) {
|
|
33
34
|
mkdirSync(dir, { recursive: true });
|
|
34
35
|
const file = cacheFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
35
|
-
|
|
36
|
-
renameSync(tmp, file);
|
|
36
|
+
writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** Candidates not yet approved or dismissed; with `root`, only that project's. */
|
|
@@ -149,15 +149,20 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
149
149
|
}
|
|
150
150
|
let rules = [];
|
|
151
151
|
try { rules = loadCodexModelRules({ dir }); } catch { /* An unreadable rule file only means candidates may repeat. */ }
|
|
152
|
-
const covered = (g) =>
|
|
153
|
-
(
|
|
154
|
-
|
|
152
|
+
const covered = (g) => {
|
|
153
|
+
if (rules.some((r) => r.category === g.category && r.from === g.from &&
|
|
154
|
+
(!r.provider || r.provider === g.provider) &&
|
|
155
|
+
(r.scope === 'global' || r.targetRoot === g.projectRoot))) return true;
|
|
156
|
+
const shared = sharedCodexModelRules({ cfg, dir, root: g.projectRoot, model: g.from, provider: g.provider });
|
|
157
|
+
return ['T1', 'T2'].every((tier) => !g.tiers[tier] || shared.some((r) => r.category === g.category && r.tier === tier));
|
|
158
|
+
};
|
|
155
159
|
const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
|
|
156
160
|
const target = cfg?.codex?.delegateTarget?.model;
|
|
157
161
|
if (target) {
|
|
158
162
|
if (prices?.provider) known.add(`${prices.provider}|${target}`);
|
|
159
163
|
if (isDirectOpenAI('openai', { home })) known.add(`openai|${target}`);
|
|
160
164
|
}
|
|
165
|
+
for (const target of codexSharedTargets(cfg)) known.add(`${target.provider}|${target.model}`);
|
|
161
166
|
const prev = readCodexRouteScan({ dir });
|
|
162
167
|
const resolved = new Set(prev?.resolved || []);
|
|
163
168
|
const candidates = [...groups.values()]
|
package/src/commands/brief.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function run({ hasFlag }) {
|
|
|
49
49
|
// differ. Claude Code sends `cwd` for that reason and the PreToolUse hook
|
|
50
50
|
// already reads it (src/delegation-guard.js). A payload without the field
|
|
51
51
|
// leaves root undefined, which is the behaviour this had before.
|
|
52
|
-
const match = routeMatch(ctx.prompt, { sessionRank });
|
|
52
|
+
const match = routeMatch(ctx.prompt, { sessionRank, root: ctx.cwd });
|
|
53
53
|
const hint = routeHint(ctx.prompt, { sessionRank, root: ctx.cwd, match });
|
|
54
54
|
if (hint) parts.push(hint);
|
|
55
55
|
// The delegation guard repeats these caps to the subagent, so the limit it
|