sprag-cli 3.62.0 → 3.64.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.
@@ -0,0 +1,97 @@
1
+ import type { EngineInterface, Register } from 'claude-code'
2
+
3
+ /**
4
+ * sprag-doc2md: the mod form of sprag's PreToolUse(Read) hook.
5
+ *
6
+ * The settings hook can only refuse the Read and tell Claude where the
7
+ * converted Markdown is, which costs one more tool call per document. A mod
8
+ * sits in front of the call, so it hands the same call on with the path
9
+ * swapped: Claude asked for deck.pptx and reads deck.md, and the permission
10
+ * check still runs on the path that is actually read.
11
+ *
12
+ * Whenever this mod does not redirect (not a document, conversion refused or
13
+ * failed, sprag not on PATH) the call goes on untouched, and sprag's settings
14
+ * hook beneath it explains the refusal exactly as it does today. After a
15
+ * redirect that hook sees a .md path and stays silent, so the two never
16
+ * answer the same Read twice.
17
+ */
18
+
19
+ // Mirrors TARGET_EXTENSIONS in src/doc2md.cjs. A hooks module has no Node, so
20
+ // it cannot load that file; test/mod-install.test.js holds the two in step.
21
+ const TARGET = /\.(pptx|xlsx|xls|pdf|docx|fig)$/i
22
+
23
+ // The CLI's name, as CLI_NAME in src/cli-name.js spells it. The mod cannot
24
+ // import that file either; test/mod-install.test.js holds the two in step.
25
+ const CLI = 'sprag'
26
+
27
+ // The converter gives up at 120 s on its own; the margin covers CLI start-up.
28
+ // Time spent in $.process.run does not count against the hook's 10 s budget,
29
+ // so the Read waits as long as the conversion does, as it does under the
30
+ // settings hook today. The status line says so while it lasts.
31
+ const CONVERT_TIMEOUT_MS = 150_000
32
+
33
+ // What `sprag doc2md <path>` prints on success. Parsing human output is a
34
+ // prototype shortcut: a `--json` flag on that command should replace it.
35
+ const CONVERTED = /^✓ (cached|converted): (.+)$/m
36
+
37
+ type Converted = { cacheFile: string; isCached: boolean }
38
+
39
+ const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
40
+
41
+ /**
42
+ * Runs the conversion the settings hook would run, ledger entry included.
43
+ * Answers null on any failure so the caller passes the event on unchanged.
44
+ *
45
+ * A first conversion can take a minute, and nothing else on screen says the
46
+ * Read is waiting on it, so the status line under the prompt names the file
47
+ * until the CLI returns. A status line rather than a transcript line: whether
48
+ * the file is cached is unknown until the CLI answers, and a log line on every
49
+ * Read would double the lines for the cached ones too.
50
+ */
51
+ async function convert($: EngineInterface, path: string): Promise<Converted | null> {
52
+ $.ui.status(`doc2md: ${baseName(path)} 변환 중…`)
53
+ try {
54
+ const run = await $.process.run([CLI, 'doc2md', path], { timeoutMs: CONVERT_TIMEOUT_MS })
55
+ if (run.exitCode !== 0) return null
56
+ const match = CONVERTED.exec(run.stdout)
57
+ if (!match) return null
58
+ return { cacheFile: match[2].trim(), isCached: match[1] === 'cached' }
59
+ } catch {
60
+ return null
61
+ } finally {
62
+ $.ui.status(undefined)
63
+ }
64
+ }
65
+
66
+ function logRedirect($: EngineInterface, from: string, doc: Converted) {
67
+ $.ui.log(`doc2md: ${baseName(from)} 대신 변환본을 읽습니다${doc.isCached ? ' (캐시)' : ''}`)
68
+ }
69
+
70
+ export const register: Register = on => {
71
+ // Both hooks fail open, which is the point of the .catch on each: this mod
72
+ // saves a round trip and guards nothing, so a hook that breaks hands the
73
+ // event on as it came and sprag's settings hook beneath answers as before.
74
+ // Where the hook had already called next, next(e) replays that call.
75
+ on('tool.call', { tool: 'Read' }, async ($, e, next) => {
76
+ if (!TARGET.test(e.file_path)) return next(e)
77
+ const doc = await convert($, e.file_path)
78
+ if (!doc) return next(e)
79
+ logRedirect($, e.file_path, doc)
80
+ // Line and page ranges addressed the binary and mean nothing in the
81
+ // Markdown (`pages` applies to PDFs only), so the whole file is read.
82
+ const { offset: _offset, limit: _limit, pages: _pages, ...call } = e
83
+ return next({ ...call, file_path: doc.cacheFile })
84
+ }).catch(($, e, next) => next(e))
85
+
86
+ // An @-mentioned document is attached whole, before any tool call, so the
87
+ // Read hook above never sees it. Redirect the attachment the same way.
88
+ on('prompt.mention', { path: TARGET }, async ($, e, next) => {
89
+ const doc = await convert($, e.path)
90
+ if (!doc) return next(e)
91
+ logRedirect($, e.path, doc)
92
+ // A mention carries no `pages` (PromptMentionInput has mention, path,
93
+ // offset, limit and agentId), so only the line range is dropped here.
94
+ const { offset: _offset, limit: _limit, ...mention } = e
95
+ return next({ ...mention, path: doc.cacheFile })
96
+ }).catch(($, e, next) => next(e))
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.62.0",
3
+ "version": "3.64.0",
4
4
  "description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,11 +15,14 @@
15
15
  "release:notes": "node scripts/release-notes.mjs",
16
16
  "site:docs": "node scripts/build-site-docs.mjs",
17
17
  "test": "node --test",
18
+ "test:mods": "claude plugin test mod/sprag-doc2md && claude plugin test mod/sprag-delegation-meter",
18
19
  "version": "node scripts/sync-site-version.mjs"
19
20
  },
20
21
  "files": [
21
22
  "bin/",
22
23
  "src/",
24
+ "mod/*/.claude-plugin/plugin.json",
25
+ "mod/*/hooks/**",
23
26
  "presets/",
24
27
  "examples/",
25
28
  "README.md",
@@ -7,7 +7,7 @@ import { writeViaTmp } from './state-file.js';
7
7
  import { readCodexSnapshot } from './codex-parser.js';
8
8
  import { loadCodexModelRules } from './codex-delegation.js';
9
9
  import { loadCodexLedger } from './codex-ledger.js';
10
- import { codexRulesInReview } from './codex-rule-health.js';
10
+ import { codexRulesInReview, codexSharedRuleHealth } from './codex-rule-health.js';
11
11
 
12
12
  const RESET_TOLERANCE_S = 300;
13
13
  const hash = (value) => createHash('sha256').update(value).digest('hex');
@@ -88,6 +88,15 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, root: projectRoo
88
88
  }
89
89
  }
90
90
  } catch { /* An unreadable rule file or ledger only skips this line. */ }
91
+ try {
92
+ for (const { rule, health, from, model, provider } of codexSharedRuleHealth({ root: cwd || projectRoot || snapshot.projectDir, dir })) {
93
+ if (health.status !== 'review') continue;
94
+ issues.push({ key: 'rule-health', tier: 1,
95
+ signature: `shared-rule-health:${JSON.stringify([rule.sharedSignature, rule.scope, rule.targetRoot, provider, from, model])}`,
96
+ level: 'warning', message: `Codex shared policy ${rule.tier} ${rule.category} (${provider}: ${from} -> ${model}) failed ${health.errs} of ${health.runs} measured runs.`,
97
+ advice: 'Review: sprag delegate rules --agent codex. Pause shared routing: sprag delegate shared off --agent codex. Common policy removal affects both agents.' });
98
+ }
99
+ } catch { /* Shared health is independent of Codex-only rule parsing. */ }
91
100
  const next = issues.filter((issue) => !active.includes(issue.signature) &&
92
101
  !(issue.key === 'context' && issue.tier === 1 && active.includes('context:2')));
93
102
  // Missing or stale measurements do not resolve an earlier warning. A fresh
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parse } from 'smol-toml';
5
+ import { codexUserDir, codexModelName } from './agent.js';
6
+ import { codexDelegationStatus } from './codex-ledger.js';
7
+
8
+ /** Local introspection only. A CLI feature flag cannot prove a model received a tool. */
9
+ export function codexDelegationDiagnostics({ home = codexUserDir(), model = codexModelName({ home }), sessionId, dir,
10
+ exec = execFileSync } = {}) {
11
+ const result = { model, version: null, flags: {}, configuredDisabled: false,
12
+ catalogAgentVersion: null, activity: codexDelegationStatus({ dir, sessionId }),
13
+ tools: 'unverified in the active session', warnings: [], errors: [] };
14
+ try {
15
+ const cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8'));
16
+ const profile = cfg.profiles?.[cfg.profile];
17
+ result.configuredDisabled = (profile?.agents?.enabled ?? cfg.agents?.enabled) === false ||
18
+ (profile?.features?.multi_agent ?? cfg.features?.multi_agent) === false;
19
+ } catch (e) { if (e.code !== 'ENOENT') result.errors.push('Cannot parse user config.'); }
20
+ const run = (args) => exec('codex', args, { encoding: 'utf8', timeout: 5000, maxBuffer: 20 * 1024 * 1024,
21
+ env: { ...process.env, CODEX_HOME: home }, stdio: ['ignore', 'pipe', 'pipe'] });
22
+ try { result.version = run(['--version']).trim().slice(0, 100); } catch { result.errors.push('Codex version unavailable.'); }
23
+ try {
24
+ for (const line of run(['features', 'list']).split('\n')) {
25
+ const m = /^(multi_agent(?:_v2(?:_dynamic_tools)?)?)\s+.+?\s+(true|false)\s*$/.exec(line);
26
+ if (m) result.flags[m[1]] = m[2] === 'true';
27
+ }
28
+ } catch { result.errors.push('Codex feature list unavailable.'); }
29
+ try {
30
+ const data = JSON.parse(run(['debug', 'models', '--bundled']));
31
+ const models = Array.isArray(data) ? data : data.models;
32
+ const found = models?.find((m) => m.slug === model);
33
+ if (['v1', 'v2'].includes(found?.multi_agent_version)) result.catalogAgentVersion = found.multi_agent_version;
34
+ } catch { result.errors.push('Bundled model catalog unavailable.'); }
35
+ if (result.configuredDisabled || result.flags.multi_agent === false) result.warnings.push('Agent tools are disabled in the inspected configuration.');
36
+ if (result.catalogAgentVersion === 'v2' && result.flags.multi_agent_v2 === false) {
37
+ result.warnings.push('The bundled model catalog names v2 while the v2 feature is off. This is a compatibility clue, not proof of the cause; no setting was changed.');
38
+ }
39
+ if (result.activity.offers && !result.activity.bound && !result.activity.settled) result.warnings.push('Sprag emitted offers but no child execution is connected.');
40
+ result.warnings.push('Confirm spawn_agent and wait_agent in the running session. If absent, continue on the main agent and report the limitation; do not launch another CLI as a substitute.');
41
+ return result;
42
+ }
@@ -19,6 +19,13 @@ const MARKER = '<!-- sprag:codex:delegation -->';
19
19
  const safeId = (value) => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,255}$/.test(value);
20
20
  const validEffort = (value) => value === undefined || ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(value);
21
21
 
22
+ /**
23
+ * Delegation is on unless the user turned it off. A fresh install writes no
24
+ * setting, so requiring `delegate === true` meant nothing ever delegated; only
25
+ * `sprag delegate off --agent codex` stores `false`.
26
+ */
27
+ export function codexDelegateEnabled(cfg) { return cfg?.codex?.delegate !== false; }
28
+
22
29
  export function validateCodexTarget(model, effort) {
23
30
  if (!safeId(model)) throw new Error('Provide a literal Codex model ID.');
24
31
  if (!validEffort(effort)) {
@@ -56,13 +63,30 @@ function observedCodexModels(dir, provider) {
56
63
  } catch { return []; }
57
64
  }
58
65
 
66
+ /**
67
+ * Model slugs Codex's own catalog lists for this account
68
+ * (`<codex home>/models_cache.json`, entries with visibility "list"). A new
69
+ * user has no session history, so the catalog is the only evidence of what
70
+ * Codex can run. Unreadable or malformed files give [].
71
+ */
72
+ function catalogCodexModels(home) {
73
+ try {
74
+ const data = JSON.parse(readFileSync(join(home, 'models_cache.json'), 'utf8'));
75
+ return (Array.isArray(data?.models) ? data.models : [])
76
+ .filter((m) => m && m.visibility === 'list' && typeof m.slug === 'string').map((m) => m.slug);
77
+ } catch { return []; }
78
+ }
79
+
59
80
  /**
60
81
  * The models Codex resolves T2 and T1 to when the user mapped none, the way
61
- * Claude Code resolves `haiku` and `sonnet`. Only a model that is priced on the
62
- * parent's provider, costs less than the parent, and has already run in this
63
- * user's Codex sessions qualifies, so the hint never names a model the gateway
64
- * cannot serve. The cheapest is T2 and the next one up is T1; with a single
65
- * candidate T1 stays on the main agent. Returns { targets, reason }.
82
+ * Claude Code resolves `haiku` and `sonnet`. Candidates are the models that
83
+ * already ran in this user's Codex sessions plus, for the built-in OpenAI
84
+ * provider only, the models Codex's catalog lists for the account (a gateway
85
+ * serves its own set, which the catalog does not describe). A candidate
86
+ * qualifies only if it is priced on the parent's provider and costs less than
87
+ * the parent, so the hint never names a model the gateway cannot serve. The
88
+ * cheapest is T2 and the next one up is T1; with a single candidate T1 stays
89
+ * on the main agent. Returns { targets, reason }.
66
90
  */
67
91
  export function autoCodexSharedTargets({ dir = userDataDir(), home = codexUserDir(), model, provider, now = Date.now() } = {}) {
68
92
  if (!safeId(model) || !safeId(provider)) return { targets: [], reason: 'unknown parent model or provider' };
@@ -71,10 +95,15 @@ export function autoCodexSharedTargets({ dir = userDataDir(), home = codexUserDi
71
95
  const cost = (m) => codexRunCost(RANKING_USAGE, book.prices[m]);
72
96
  const parent = cost(model);
73
97
  if (parent === null) return { targets: [], reason: `${model} is not priced on ${provider}` };
74
- const cheaper = [...new Set(observedCodexModels(dir, provider))]
98
+ const available = [...new Set([...observedCodexModels(dir, provider), ...(provider === 'openai' ? catalogCodexModels(home) : [])])];
99
+ const cheaper = available
75
100
  .filter((m) => m !== model && safeId(m)).map((m) => ({ model: m, usd: cost(m) }))
76
101
  .filter((x) => x.usd !== null && x.usd < parent).sort((a, b) => a.usd - b.usd || a.model.localeCompare(b.model));
77
- if (!cheaper.length) return { targets: [], reason: `no cheaper priced model has run in your Codex sessions on ${provider}` };
102
+ if (!cheaper.length) {
103
+ return { targets: [], reason: available.length
104
+ ? `${model} is already the cheapest available model on ${provider}`
105
+ : `no cheaper priced model has run in your Codex sessions on ${provider}` };
106
+ }
78
107
  const targets = cheaper.slice(0, 2).map((x, i) => ({ tier: i === 0 ? 'T2' : 'T1', from: model, model: x.model, provider, auto: true }));
79
108
  return { targets, reason: null };
80
109
  }
@@ -86,7 +115,7 @@ export function autoCodexSharedTargets({ dir = userDataDir(), home = codexUserDi
86
115
  */
87
116
  export function codexTierTargets({ cfg = loadConfig(), dir = userDataDir(), home = codexUserDir(), model, provider = codexProviderName() } = {}) {
88
117
  const explicit = codexSharedTargets(cfg).filter((t) => t.from === model && t.provider === provider);
89
- if (cfg.codex?.delegate !== true || cfg.codex?.sharedRules?.auto === false) return explicit;
118
+ if (!codexDelegateEnabled(cfg) || cfg.codex?.sharedRules?.auto === false) return explicit;
90
119
  const auto = autoCodexSharedTargets({ dir, home, model, provider }).targets
91
120
  .filter((t) => !explicit.some((e) => e.tier === t.tier));
92
121
  return [...explicit, ...auto];
@@ -215,6 +244,23 @@ const SHARED_TIER_GUIDANCE = 'Choose T2 only for a simple bounded lookup or comm
215
244
  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. '
216
245
  + 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
217
246
 
247
+ /** Called explicitly by the parent after design, never by prompt classification. */
248
+ export function codexImplementationHint({ model, provider, sessionId, cfg = loadConfig(), dir = userDataDir(),
249
+ home = codexUserDir(), record = recordCodexDelegation } = {}) {
250
+ if (!safeId(model) || !safeId(provider) || !safeId(sessionId)) throw new Error('Implementation delegation requires --from, --provider and --session for the active parent.');
251
+ if (!codexDelegateEnabled(cfg) || cfg.codex?.sharedRules?.enabled === false) return 'Implementation delegation is off. Keep the implementation on the main agent.';
252
+ const target = codexTierTargets({ cfg, dir, home, model, provider }).find((t) => t.tier === 'T1');
253
+ if (!target) return 'T1 is unresolved for this parent/provider. Keep the implementation on the main agent; do not fall back to T2.';
254
+ const id = newRouteId();
255
+ record({ id, parentSessionId: sessionId, via: 'prompt', source: 'implementation', tier: 'T1',
256
+ from: model, to: target.model, provider, effort: target.effort ?? null }, { dir });
257
+ return `[Sprag implementation delegation] The user-approved implementation-split policy keeps design and specification on the main agent. `
258
+ + `Delegate only the implementation of that completed design to model ${target.model}${target.effort ? `, reasoning_effort ${target.effort}` : ''}, with fork_turns "none". `
259
+ + 'Never delegate design judgement or irreversible actions. Give one child a self-contained specification, owned files, constraints, test commands, completion criteria and the reason for this model choice. '
260
+ + 'Cap: 8000 output tokens. Stop and report partial work before exceeding it. Do not recursively delegate. '
261
+ + WAIT_TEXT + `\nEnd the child task message with this route line only:\n<!-- sprag:codex:route id=${id} -->`;
262
+ }
263
+
218
264
  function sharedTargetText(rule) {
219
265
  return `${rule.tier}: model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''}. ${sharedRuleBounds(rule)}`.trim();
220
266
  }
@@ -307,7 +353,7 @@ function customRole(role, root, home) {
307
353
  // Kept for Codex versions that do send spawn_agent through PreToolUse; on
308
354
  // 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
309
355
  export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules: given, dir = userDataDir(), record = recordCodexDelegation } = {}) {
310
- if (cfg.codex?.delegate !== true) return null;
356
+ if (!codexDelegateEnabled(cfg)) return null;
311
357
  const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
312
358
  if (!['spawn_agent', 'Agent'].includes(tool)) return null;
313
359
  const input = payload.tool_input;
@@ -5,43 +5,88 @@ import { codexUserDir } from './agent.js';
5
5
  import { userDataDir } from './paths.js';
6
6
  import { writeViaTmp } from './state-file.js';
7
7
  import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
8
+ import { codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
8
9
 
9
10
  const hash = (value) => createHash('sha256').update(value).digest('hex');
10
11
  const ledgerDir = ({ home = codexUserDir(), dir = userDataDir() } = {}) => join(dir, 'codex-doc2md', hash(resolve(home)));
11
12
  const finite = (n) => Number.isSafeInteger(n) && n >= 0;
12
13
 
14
+ /** Office XML versus the prepared Markdown, not native attachment billing. */
15
+ export function estimateCodexDocument(source, meta, { model, provider, home, dir, now = Date.now(), prices } = {}) {
16
+ const unknown = (reason) => ({ usd: null, reason });
17
+ if (meta.clipped || meta.truncated) return unknown('incomplete-text');
18
+ if (!['.pptx', '.docx', '.xlsx'].includes(extname(source).toLowerCase()) || !finite(meta.markupBytes) || meta.markupBytes === 0) {
19
+ return unknown('no-comparable-baseline');
20
+ }
21
+ if (!finite(meta.markdownBytes)) return unknown('missing-markdown-size');
22
+ if (typeof model !== 'string' || !model || typeof provider !== 'string' || !provider) return unknown('unknown-model-provider');
23
+ const book = codexPriceBook(provider, prices === undefined ? readCodexPrices({ home, dir, now }) : prices, { home });
24
+ const rate = book?.prices?.[model]?.input;
25
+ if (!Number.isFinite(rate) || rate < 0) return unknown('unpriced-model');
26
+ const baseline = Math.ceil(meta.markupBytes / 4), tokens = Math.ceil(meta.markdownBytes / 4);
27
+ return { usd: (baseline - tokens) * rate, baseline, tokens, savedTokens: baseline - tokens,
28
+ method: 'office-xml-vs-markdown-bytes/4', model, provider, inputRate: rate,
29
+ priceSource: book.source, priceCheckedAt: book.checkedAt };
30
+ }
31
+
13
32
  // One file per source avoids lost updates when parallel hooks prepare documents.
14
- export function recordCodexDocument(source, result, opts) {
33
+ export function recordCodexDocument(source, result, opts = {}) {
15
34
  if (!result?.ok) return;
16
35
  try {
17
36
  const dir = ledgerDir(opts);
18
37
  const file = join(dir, `${hash(realpathSync(source))}.json`);
19
38
  const meta = result.meta || {};
20
- const event = { version: 1, ext: extname(source).slice(1).toLowerCase(),
39
+ const now = opts.now ?? Date.now();
40
+ const st = statSync(source);
41
+ const fingerprint = hash(JSON.stringify([st.size, st.mtimeMs, meta.markupBytes, meta.markdownBytes, !!(meta.clipped || meta.truncated)]));
42
+ let previous;
43
+ try { previous = JSON.parse(readFileSync(file, 'utf8')); } catch { /* First use. */ }
44
+ const same = previous?.version === 2 && previous.fingerprint === fingerprint;
45
+ const since = readResetMarks(opts.dir ?? userDataDir())['codex-docs'];
46
+ const estimate = estimateCodexDocument(source, meta, opts);
47
+ // Reusing a cache without session identity must not erase a priced snapshot.
48
+ const saving = same && !isBeforeReset(previous.at, since) && Number.isFinite(previous.saving?.usd) ? previous.saving : estimate;
49
+ const event = { version: 2, ext: extname(source).slice(1).toLowerCase(),
50
+ fingerprint, at: same && !isBeforeReset(previous.at, since) ? previous.at : now, saving,
21
51
  sourceBytes: finite(meta.size) ? meta.size : null,
22
52
  markdownBytes: finite(meta.markdownBytes) ? meta.markdownBytes : null,
23
53
  clipped: !!(meta.clipped || meta.truncated) };
24
54
  mkdirSync(dir, { recursive: true, mode: 0o700 });
25
55
  const tmp = `${file}.${randomUUID()}.tmp`;
26
56
  writeViaTmp(tmp, file, JSON.stringify(event) + '\n', { mode: 0o600 });
57
+ return saving;
27
58
  } catch { /* Accounting must not prevent document conversion. */ }
28
59
  }
29
60
 
30
- // Records carry no timestamp field, so the reset mark is compared with the
31
- // file's mtime. A source converted again rewrites its record and counts as new.
32
- export function codexDocumentTotals(opts) {
61
+ // Legacy records use mtime; new records retain their first priced observation.
62
+ export function codexDocumentTotals(opts = {}) {
33
63
  const since = readResetMarks(opts?.dir ?? userDataDir())['codex-docs'];
34
- const total = { docs: 0, byExt: [], scope: 'codex-total', since };
64
+ const now = opts.now ?? Date.now();
65
+ const savings = { week: 0, month: 0, total: 0, priced: 0, unpriced: 0, savedTokens: 0, reasons: {} };
66
+ const total = { docs: 0, byExt: [], scope: 'codex-total', since, savings };
35
67
  const byExt = new Map();
36
68
  try {
37
69
  const dir = ledgerDir(opts);
38
70
  for (const file of readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name))) {
39
71
  try {
40
- if (since !== null && isBeforeReset(statSync(join(dir, file)).mtimeMs, since)) continue;
41
72
  const event = JSON.parse(readFileSync(join(dir, file), 'utf8'));
42
- if (event?.version !== 1 || !['pdf', 'pptx', 'xlsx', 'xls', 'docx', 'fig'].includes(event.ext)) continue;
73
+ if (![1, 2].includes(event?.version) || !['pdf', 'pptx', 'xlsx', 'xls', 'docx', 'fig'].includes(event.ext)) continue;
74
+ const at = event.version === 2 ? event.at : statSync(join(dir, file)).mtimeMs;
75
+ if (isBeforeReset(at, since)) continue;
43
76
  total.docs++;
44
77
  byExt.set(event.ext, (byExt.get(event.ext) || 0) + 1);
78
+ const s = event.saving;
79
+ if (event.version === 2 && s?.method === 'office-xml-vs-markdown-bytes/4' && Number.isFinite(s.usd)) {
80
+ savings.priced++;
81
+ savings.total += s.usd;
82
+ savings.savedTokens += Number.isFinite(s.savedTokens) ? s.savedTokens : 0;
83
+ if (at <= now && now - at < 7 * 86400000) savings.week += s.usd;
84
+ if (at <= now && now - at < 30 * 86400000) savings.month += s.usd;
85
+ } else {
86
+ savings.unpriced++;
87
+ const reason = event.version === 1 ? 'legacy-record' : s?.reason || 'unknown';
88
+ savings.reasons[reason] = (savings.reasons[reason] || 0) + 1;
89
+ }
45
90
  } catch { /* Ignore only the damaged record. */ }
46
91
  }
47
92
  } catch { /* No Codex document history yet. */ }
@@ -1,14 +1,31 @@
1
1
  import { createRequire } from 'node:module';
2
- import { realpathSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
2
+ import { realpathSync, readFileSync, writeFileSync, renameSync, rmSync, openSync, readSync, closeSync } from 'node:fs';
3
3
  import { basename, dirname, resolve } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { recordCodexDocument } from './codex-doc2md-ledger.js';
6
+ import { readCodexSnapshot } from './codex-parser.js';
6
7
 
7
8
  const require = createRequire(import.meta.url);
8
9
  const doc2md = require('./doc2md.cjs');
9
10
  const lint = require('./korean-lint.cjs');
10
11
  const shellTools = new Set(['Bash', 'exec_command', 'shell_command', 'shell']);
11
12
 
13
+ /** Hook identity, never the user's default model or another project's session. */
14
+ export function codexDocumentContext(payload = {}) {
15
+ const snapshot = readCodexSnapshot(payload.transcript_path);
16
+ let provider, id, fd;
17
+ try {
18
+ fd = openSync(payload.transcript_path, 'r');
19
+ const buf = Buffer.alloc(65536);
20
+ const n = readSync(fd, buf, 0, buf.length, 0);
21
+ const first = JSON.parse(buf.subarray(0, n).toString('utf8').split('\n')[0]);
22
+ if (first.type === 'session_meta') { provider = first.payload?.model_provider; id = first.payload?.id || first.payload?.session_id; }
23
+ } catch { /* Payload identity may still be complete. */ }
24
+ finally { if (fd !== undefined) closeSync(fd); }
25
+ if (id && payload.session_id && id !== payload.session_id) return {};
26
+ return { model: payload.model || snapshot?.model, provider: payload.model_provider || provider || snapshot?.provider };
27
+ }
28
+
12
29
  function canonicalPath(file) {
13
30
  const missing = [];
14
31
  for (let current = file; ; current = dirname(current)) {
@@ -47,8 +64,8 @@ export function convertCodexDocument(source, { convert = doc2md.convert, ...opts
47
64
  tmp = null;
48
65
  }
49
66
  const { savedUsd, baselineTokens, ...meta } = result.meta || {};
50
- const prepared = { ...result, cacheFile, meta };
51
- recordCodexDocument(source, prepared, opts);
67
+ const prepared = { ...result, cacheFile, meta: { ...meta, markdownBytes: Buffer.byteLength(text, 'utf8') } };
68
+ prepared.meta.codexSaving = recordCodexDocument(source, prepared, opts);
52
69
  return prepared;
53
70
  } catch {
54
71
  return { ok: false, reason: 'cache-unavailable' };
@@ -87,7 +104,7 @@ export function codexDocumentTool(payload, { convert = convertCodexDocument, gua
87
104
  if (match) file = match[1] || match[2] || match[3];
88
105
  }
89
106
  if (typeof file !== 'string' || !doc2md.isTargetPath(file)) return null;
90
- const result = convert(resolve(cwd, file));
107
+ const result = convert(resolve(cwd, file), codexDocumentContext(payload));
91
108
  if (!result.ok) return { hookSpecificOutput: { hookEventName: 'PreToolUse',
92
109
  ...(result.reason === 'unsafe-archive' ? { permissionDecision: 'deny', permissionDecisionReason: '[doc2md] Unsafe archive; do not extract it.' }
93
110
  : { additionalContext: `[doc2md] Conversion failed (${result.reason}). Do not claim this document was converted. ${codexDocumentNote()}` }) } };
@@ -15,13 +15,18 @@ export const CODEX_CAPABILITIES = [
15
15
  { feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only records of context (80%/95%) and 5h/weekly rate-limit (90%) warnings, plus handoffs. Cache and TTL chips are not recorded; last shows the latest event.' },
16
16
  { feature: 'handoff', support: 'supported', command: 'sprag handoff --agent codex', note: 'Git state and recorded Codex session usage; never reads Claude caps.' },
17
17
  { feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
18
+ { feature: 'reply-language', support: 'supported', command: 'sprag korean reply on --agent codex', note: 'Trusted Stop hook checks the current Korean prompt and final reply; continuation, opt-out and explicit English requests are excluded.' },
18
19
  { feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
19
20
  { 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.' },
21
+ { feature: 'doc2md-saved', support: 'partial', command: 'sprag doc2md savings --agent codex', note: 'Office XML versus Markdown bytes/4 at the recorded Codex input price. Estimate only; incomplete or unsupported baselines and missing prices stay unknown.' },
22
+ { feature: 'delegation-diagnostics', support: 'supported', command: 'sprag delegate doctor --agent codex', note: 'Local feature/catalog and matched-child evidence. Never claims that an enabled preference proves native tools are available.' },
20
23
  { feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Native routing and shared delegation policies; tier models resolve from priced models already run in Codex, or from explicit mappings. Explicit models/custom roles preserved; bounds are guidance, not enforced limits.' },
21
24
  { 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
25
  { 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
26
  { feature: 'mode', support: 'partial', command: 'sprag mode --agent codex', note: 'Labels, color, and timer apply to panel/statusline; Claude TTL buckets do not.' },
24
27
  { feature: 'upgrade', support: 'manual', command: 'sprag upgrade --print --agent codex', note: 'Skip the Claude npm postinstall; reinstall Codex hooks after upgrade.' },
28
+ { feature: 'update-notice', support: 'supported', command: 'sprag update-check --agent codex', note: 'Live panel refreshes in the background; one-shot statusline reads cached state; interactive SessionStart offers upgrades with consent.' },
29
+ { feature: 'implementation-delegation', support: 'supported', command: 'sprag delegate implementation --agent codex --from MODEL --provider PROVIDER --session ID', note: 'Main agent designs and verifies; implementation uses resolved T1 only. Requires native subagent tools and records only matched child runs.' },
25
30
  { 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
31
  { feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Shared delegation policies plus agent-compatible ratchet presets. Explicit scope; tier models resolve automatically or by mapping. No Claude installation needed.' },
27
32
  { feature: 'transcript ratchet', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Recent repeated tool failures become ratchet? candidates; no automatic rule registration.' },
@@ -37,6 +42,8 @@ export async function codexDoctor({ root = process.cwd(), sessionId, days = 7 }
37
42
  const cfg = config.present ? parse(readFileSync(file, 'utf8')) : {};
38
43
  // Only expose an allowlist. Provider auth and arbitrary config may hold secrets.
39
44
  config.hooksDisabled = cfg.features?.hooks === false || (cfg.features?.hooks === undefined && cfg.features?.codex_hooks === false);
45
+ config.agentsDisabled = cfg.agents?.enabled === false || cfg.features?.multi_agent === false;
46
+ config.agentsMaxThreads = cfg.agents?.max_concurrent_threads_per_session ?? cfg.agents?.max_threads ?? null;
40
47
  config.statusLine = Array.isArray(cfg.tui?.status_line) ? cfg.tui.status_line.filter((item) => typeof item === 'string') : null;
41
48
  config.autoCompactTokenLimit = Number.isFinite(cfg.model_auto_compact_token_limit) ? cfg.model_auto_compact_token_limit : null;
42
49
  config.note = 'User config only; active profile, project, managed policy, and runtime overrides may differ.';
@@ -49,5 +56,6 @@ export async function codexDoctor({ root = process.cwd(), sessionId, days = 7 }
49
56
  lastActivity: data.session.lastActivity, usageUpdates: data.session.requestCount } : null;
50
57
  return { agent: 'codex', home, root, config, hooks, harness: data.harness, rules: data.rules,
51
58
  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 };
59
+ session, delegation: { ...data.delegation, tools: 'unknown; only the running agent can confirm spawn_agent and wait_agent availability' },
60
+ documents: data.documents, routingSaved: data.routingSaved, panel: panelHealth(root, { sessionId }), capabilities: CODEX_CAPABILITIES };
53
61
  }