@codedology/code-forge 0.1.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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated sessions: the spawner (plan §5.2, §5.6, §6.1, V3, V9; block B9a).
|
|
3
|
+
*
|
|
4
|
+
* `spawnSession(opts, deps)` resolves a level (`resolveLevel`, B1), builds the provider argv
|
|
5
|
+
* (`buildArgv`, B4), and runs it through B0.1's `exec` — argv only, never a shell, with a timeout
|
|
6
|
+
* that kills the child's process group, and a pid-registry entry for as long as the child lives.
|
|
7
|
+
*
|
|
8
|
+
* Builder contract (V3): when the build carries `stdinFile`, its CONTENT (bytes, unchanged) goes to
|
|
9
|
+
* the child's stdin through `exec`'s `input` — closed-book prompts never sit in argv. Grok is the
|
|
10
|
+
* exception by construction: its builder puts the packet in `--prompt-file` and returns no
|
|
11
|
+
* `stdinFile`, so nothing is piped. When the build carries `outPath` (Codex, always), the answer is
|
|
12
|
+
* read from it and the file is deleted. When the build carries `env` (the Codex coder's
|
|
13
|
+
* `CODEX_HOME`, B4.1), it is merged over the child's environment; a foreground session removes
|
|
14
|
+
* that home when the child exits.
|
|
15
|
+
*
|
|
16
|
+
* Retry ladder (§5.6, V9): step 0 is the level's own model; step `k + 1` is `fallback[k]`. For a
|
|
17
|
+
* Claude coder, `fallback[0]` (same provider) rides on `--fallback-model` inside step 0 and is not
|
|
18
|
+
* spawned again. A step that reports unavailability (CLI missing, login expired, HTTP 402, or
|
|
19
|
+
* rate-limited twice in a row) hands over to the next step; any other failure is final.
|
|
20
|
+
*
|
|
21
|
+
* Every spawn prints `level=<Lx> provider=<p> model=<id> effort=<e> fallback_step=<n>` on stderr,
|
|
22
|
+
* and every attempt writes one ledger row (`event: session`) with `tokens_source` `reported` when
|
|
23
|
+
* the CLI's JSON carried usage, else `estimated` (bytes / 4).
|
|
24
|
+
*
|
|
25
|
+
* A coder argv that `isForbidden` matches is refused before anything spawns (§8.4).
|
|
26
|
+
*
|
|
27
|
+
* `background: true` (coders, §5.2): the child is started detached with stdout/stderr going to
|
|
28
|
+
* `<run-root>/sessions/<id>/session.log`, its pid written to `<run-root>/sessions/<id>/pid.json`
|
|
29
|
+
* and to the run root's pid registry; completion is the process exit plus the sentinel in the log.
|
|
30
|
+
* `exec` cannot outlive its caller (it waits, and SIGTERMs live groups on the parent's exit), so
|
|
31
|
+
* this one path uses `node:child_process.spawn` directly — argv array, `shell: false`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
36
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
39
|
+
import { resolveLevel } from '../config/known-ids.mjs';
|
|
40
|
+
import { compileSchema } from '../config/schema-compile.mjs';
|
|
41
|
+
import { buildArgv } from '../engines/builders/index.mjs';
|
|
42
|
+
import { removeCodexHome } from '../engines/codex-home.mjs';
|
|
43
|
+
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
44
|
+
import { parseSentinel } from '../engines/sentinel.mjs';
|
|
45
|
+
import { parseUsage } from '../engines/usage-parse.mjs';
|
|
46
|
+
import { appendRow } from '../ledger/write.mjs';
|
|
47
|
+
import { exec } from '../util/exec.mjs';
|
|
48
|
+
import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
|
|
49
|
+
import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
50
|
+
import { redact, writeSafe } from '../util/redact.mjs';
|
|
51
|
+
import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
|
|
52
|
+
|
|
53
|
+
export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts']);
|
|
54
|
+
export const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
|
|
55
|
+
|
|
56
|
+
/** Default wall-clock cap of one foreground session. */
|
|
57
|
+
export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
58
|
+
|
|
59
|
+
/** Unavailability reasons that move the ladder to the next step (§5.6). */
|
|
60
|
+
export const UNAVAILABLE_REASONS = Object.freeze(['cli-missing', 'login-expired', 'http-402', 'rate-limited']);
|
|
61
|
+
|
|
62
|
+
/** An error the spawner raises before anything runs (`code`: `usage`, `forbidden`). */
|
|
63
|
+
export class SessionError extends Error {
|
|
64
|
+
/** @param {string} code @param {string} message */
|
|
65
|
+
constructor(code, message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'SessionError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The temp root of run `runId`: an existing trusted root is reused as it is (its owner record is
|
|
74
|
+
* the run's, not this short-lived verb's); a missing one is created by `runRoot`.
|
|
75
|
+
* @param {string} runId @param {string} [root] - the `tmp.root` config value.
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function runRootFor(runId, root) {
|
|
79
|
+
if (typeof runId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(runId)) {
|
|
80
|
+
throw new SessionError('usage', 'a run id is one path segment of letters, digits, ".", "_" or "-"');
|
|
81
|
+
}
|
|
82
|
+
const dir = path.join(tmpBase(root), runId);
|
|
83
|
+
if (existsSync(dir) && untrustedReason(dir) === null) return realpathSync(dir);
|
|
84
|
+
return runRoot(runId, { root });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {number} bytes */
|
|
88
|
+
export const estimateTokens = (bytes) => Math.ceil(bytes / 4);
|
|
89
|
+
|
|
90
|
+
/** @param {string} file @returns {number} its size in bytes, 0 when unreadable. */
|
|
91
|
+
function fileSize(file) {
|
|
92
|
+
try {
|
|
93
|
+
return statSync(file).size;
|
|
94
|
+
} catch {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @param {unknown} text @returns {any} the parsed JSON value, or undefined. */
|
|
100
|
+
function tryJSON(text) {
|
|
101
|
+
if (typeof text !== 'string' || text.trim().length === 0) return undefined;
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(text);
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The spawn steps of a level: step 0 = the level itself, step k+1 = `fallback[k]`. For a Claude
|
|
111
|
+
* coder whose `fallback[0]` is a different Claude model, that entry rides on `--fallback-model`
|
|
112
|
+
* (V9) and is not a step of its own.
|
|
113
|
+
* @param {{provider: string, model: string, effort?: string, fallback: ReadonlyArray<{provider: string, model: string, effort?: string}>}} resolved
|
|
114
|
+
* @param {string} role
|
|
115
|
+
* @returns {Array<{provider: string, model: string, effort?: string, fallback_step: number, flagFallback?: {provider: string, model: string}}>}
|
|
116
|
+
*/
|
|
117
|
+
export function ladderFor(resolved, role) {
|
|
118
|
+
const [first] = resolved.fallback;
|
|
119
|
+
const onFlag = role === 'coder' && resolved.provider === 'anthropic' && first?.provider === 'anthropic' && first.model !== resolved.model;
|
|
120
|
+
const steps = [{ provider: resolved.provider, model: resolved.model, effort: resolved.effort, fallback_step: 0, ...(onFlag ? { flagFallback: first } : {}) }];
|
|
121
|
+
resolved.fallback.forEach((entry, k) => {
|
|
122
|
+
if (onFlag && k === 0) return;
|
|
123
|
+
steps.push({ provider: entry.provider, model: entry.model, effort: entry.effort, fallback_step: k + 1 });
|
|
124
|
+
});
|
|
125
|
+
return steps;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** HTTP statuses that mean "this model is unavailable" (§5.6). */
|
|
129
|
+
const STATUS_REASONS = Object.freeze({ 401: 'login-expired', 402: 'http-402', 403: 'login-expired', 429: 'rate-limited' });
|
|
130
|
+
|
|
131
|
+
/** @param {unknown} code @returns {string | null} */
|
|
132
|
+
function reasonForStatus(code) {
|
|
133
|
+
const n = typeof code === 'string' && /^\d{3}$/.test(code) ? Number(code) : code;
|
|
134
|
+
return Number.isInteger(n) && Object.hasOwn(STATUS_REASONS, /** @type {number} */ (n)) ? STATUS_REASONS[/** @type {401|402|403|429} */ (n)] : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Why a failed attempt counts as "unavailable" (§5.6), or null. Structured fields first: Claude's
|
|
139
|
+
* `api_error_status`, an `error.status`, a Codex error event's `unexpected status <code>` message.
|
|
140
|
+
* The text fallback reads stderr only, and only lines that start with the CLI's own error prefix
|
|
141
|
+
* (`Error:` / `API Error:`) — never stdout, where a bare `402` can be ordinary content.
|
|
142
|
+
* @param {import('../util/exec.mjs').ExecResult} res
|
|
143
|
+
* @param {any} parsed - the CLI's parsed JSON result object (Codex: its last error event), if any.
|
|
144
|
+
* @returns {string | null}
|
|
145
|
+
*/
|
|
146
|
+
export function classifyUnavailable(res, parsed) {
|
|
147
|
+
if (res.error && /ENOENT/.test(res.error)) return 'cli-missing';
|
|
148
|
+
if (res.result === 'ok' && parsed?.is_error !== true) return null;
|
|
149
|
+
for (const status of [parsed?.api_error_status, parsed?.error?.status]) {
|
|
150
|
+
const reason = reasonForStatus(status);
|
|
151
|
+
if (reason) return reason;
|
|
152
|
+
}
|
|
153
|
+
const message = typeof parsed?.message === 'string' ? parsed.message : typeof parsed?.error?.message === 'string' ? parsed.error.message : '';
|
|
154
|
+
const codex = /^unexpected status (\d{3})\b/.exec(message);
|
|
155
|
+
if (codex && reasonForStatus(codex[1])) return reasonForStatus(codex[1]);
|
|
156
|
+
for (const raw of res.stderr.split('\n')) {
|
|
157
|
+
const line = raw.trim();
|
|
158
|
+
const coded = /^(?:API )?Error:\s*(?:HTTP\s+)?(\d{3})\b/.exec(line);
|
|
159
|
+
if (coded && reasonForStatus(coded[1])) return reasonForStatus(coded[1]);
|
|
160
|
+
if (/^(?:API )?Error:.*\b(?:not logged in|login (?:expired|required))/i.test(line)) return 'login-expired';
|
|
161
|
+
if (/^(?:API )?Error:.*\brate.?limit/i.test(line)) return 'rate-limited';
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read one CLI's answer and usage from its stdout (and `outPath` text for Codex).
|
|
168
|
+
* @param {string} cli @param {string} stdout @param {string | null} outText
|
|
169
|
+
* @returns {{parsed: any, text: string, answer: any, usage: import('../engines/usage-parse.mjs').UsageResult}}
|
|
170
|
+
*/
|
|
171
|
+
export function readAnswer(cli, stdout, outText) {
|
|
172
|
+
if (cli === 'codex') {
|
|
173
|
+
const events = stdout.split('\n').map(tryJSON).filter((e) => e && typeof e === 'object');
|
|
174
|
+
const usageEvent = events.filter((e) => e.usage && typeof e.usage === 'object').at(-1);
|
|
175
|
+
const errorEvent = events.filter((e) => e.type === 'error' || e.type === 'turn.failed').at(-1);
|
|
176
|
+
const text = outText ?? '';
|
|
177
|
+
return { parsed: errorEvent ?? null, text, answer: tryJSON(text), usage: parseUsage('codex', usageEvent ?? null) };
|
|
178
|
+
}
|
|
179
|
+
const parsed = tryJSON(stdout.trim());
|
|
180
|
+
const text = typeof parsed?.result === 'string' ? parsed.result : stdout;
|
|
181
|
+
const answer = parsed?.structured_output ?? tryJSON(text);
|
|
182
|
+
return { parsed: parsed ?? null, text, answer, usage: parseUsage(/** @type {"claude"|"grok"} */ (cli), parsed ?? null) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @typedef {'strict' | 'plain'} SchemaVariant - `strict` is the OpenAI strict-mode compilation
|
|
187
|
+
* (every property required, optional ones nullable); `plain` is the source schema as given.
|
|
188
|
+
*/
|
|
189
|
+
|
|
190
|
+
/** @param {string} provider @returns {SchemaVariant} */
|
|
191
|
+
function variantFor(provider) {
|
|
192
|
+
return provider === 'openai' ? 'strict' : 'plain';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One compiled schema per (SOURCE schema object, variant), for the life of the source object, so
|
|
197
|
+
* every attempt of every session hands the provider — and the validator — the same object.
|
|
198
|
+
* @type {WeakMap<object, Map<SchemaVariant, Record<string, any>>>}
|
|
199
|
+
*/
|
|
200
|
+
const compiledSchemas = new WeakMap();
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {Record<string, any>} source @param {"anthropic" | "openai" | "xai"} provider
|
|
204
|
+
* @returns {Record<string, any>} the cached compiled schema for `provider`'s variant.
|
|
205
|
+
*/
|
|
206
|
+
function compiledFor(source, provider) {
|
|
207
|
+
const variant = variantFor(provider);
|
|
208
|
+
let byVariant = compiledSchemas.get(source);
|
|
209
|
+
if (byVariant === undefined) {
|
|
210
|
+
byVariant = new Map();
|
|
211
|
+
compiledSchemas.set(source, byVariant);
|
|
212
|
+
}
|
|
213
|
+
let compiled = byVariant.get(variant);
|
|
214
|
+
if (compiled === undefined) {
|
|
215
|
+
compiled = compileSchema(source, provider, { strict: variant === 'strict' });
|
|
216
|
+
byVariant.set(variant, compiled);
|
|
217
|
+
}
|
|
218
|
+
return compiled;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One validator per schema object, for the life of that object. Ajv caches by schema object and
|
|
223
|
+
* registers `$id` process-wide, so a shared instance fed a fresh clone per attempt either throws
|
|
224
|
+
* "schema with key or id … already exists" on the second session or grows without bound; each
|
|
225
|
+
* schema object instead gets its own Ajv, compiled once from a copy without `$id`.
|
|
226
|
+
* @type {WeakMap<object, import('ajv').ValidateFunction>}
|
|
227
|
+
*/
|
|
228
|
+
const validators = new WeakMap();
|
|
229
|
+
|
|
230
|
+
/** @param {Record<string, any>} schema @returns {import('ajv').ValidateFunction} */
|
|
231
|
+
function validatorFor(schema) {
|
|
232
|
+
let validate = validators.get(schema);
|
|
233
|
+
if (validate === undefined) {
|
|
234
|
+
const body = { ...schema };
|
|
235
|
+
delete body.$id;
|
|
236
|
+
validate = new Ajv2020({ allErrors: true, strict: false }).compile(body);
|
|
237
|
+
validators.set(schema, validate);
|
|
238
|
+
}
|
|
239
|
+
return validate;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {Record<string, any>} schema - a stable object: the caller's source schema, or the
|
|
244
|
+
* cached compiled variant the provider was actually given — never a fresh clone per call.
|
|
245
|
+
* @param {unknown} value
|
|
246
|
+
* @returns {boolean} whether `value` is an object that validates against `schema`.
|
|
247
|
+
*/
|
|
248
|
+
export function matchesSchema(schema, value) {
|
|
249
|
+
return value !== null && typeof value === 'object' && validatorFor(schema)(value) === true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A strict-variant answer carries `null` for every property the SOURCE schema left optional
|
|
254
|
+
* (OpenAI strict mode has no optional fields, see `schema-compile.mjs`); restore the source shape
|
|
255
|
+
* by dropping those nulls, recursively through `properties` and `items`. Mutates and returns
|
|
256
|
+
* `value`; a `null` on a property the source requires is kept as the answer gave it.
|
|
257
|
+
* @param {unknown} value @param {Record<string, any> | undefined} node - the source schema node.
|
|
258
|
+
*/
|
|
259
|
+
function dropStrictNulls(value, node) {
|
|
260
|
+
if (!node || typeof node !== 'object' || value === null || typeof value !== 'object') return value;
|
|
261
|
+
if (Array.isArray(value)) {
|
|
262
|
+
if (node.items && typeof node.items === 'object' && !Array.isArray(node.items)) {
|
|
263
|
+
for (const item of value) dropStrictNulls(item, node.items);
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
const props = node.properties;
|
|
268
|
+
if (!props || typeof props !== 'object') return value;
|
|
269
|
+
const required = new Set(Array.isArray(node.required) ? node.required : []);
|
|
270
|
+
const obj = /** @type {Record<string, unknown>} */ (value);
|
|
271
|
+
for (const key of Object.keys(obj)) {
|
|
272
|
+
if (!Object.hasOwn(props, key)) continue;
|
|
273
|
+
if (obj[key] === null && !required.has(key)) delete obj[key];
|
|
274
|
+
else dropStrictNulls(obj[key], props[key]);
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The child's environment: the caller's, minus code-forge's own and Jev variables (§8.2). */
|
|
280
|
+
function childEnv(/** @type {NodeJS.ProcessEnv} */ env) {
|
|
281
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
282
|
+
const out = {};
|
|
283
|
+
for (const [name, value] of Object.entries(env)) {
|
|
284
|
+
if (/^(CODE_FORGE_|JEV_)/.test(name)) continue;
|
|
285
|
+
out[name] = value;
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @typedef {object} SessionOpts
|
|
292
|
+
* @property {Record<string, any>} cfg - the loaded project config (levels, provider).
|
|
293
|
+
* @property {"L0"|"L1"|"L2"|"L3"} level
|
|
294
|
+
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
295
|
+
* @property {string} promptPath - the brief (coder) or the packet (closed-book).
|
|
296
|
+
* @property {string} [cwd] - coder: the project tree (default `process.cwd()`); facts (B9b.1): an
|
|
297
|
+
* existing read-only snapshot of the project to run in, inside `<runRoot>/facts/` or the
|
|
298
|
+
* `facts/` area of {@link factsTmpRoot}, and under no git work tree (anything else, `''`
|
|
299
|
+
* included, is a `usage` SessionError; default: a fresh empty directory); every
|
|
300
|
+
* other closed-book role ignores it and always runs in a fresh empty directory.
|
|
301
|
+
* @property {Record<string, any>} [schema] - source answer schema; compiled per provider.
|
|
302
|
+
* @property {string} [systemPromptText]
|
|
303
|
+
* @property {number} [maxBudgetUsd]
|
|
304
|
+
* @property {number} [timeoutMs]
|
|
305
|
+
* @property {boolean} [background]
|
|
306
|
+
* @property {string} [runRoot] - default `currentRunRoot()`.
|
|
307
|
+
* @property {string} [run] @property {string} [block] - copied to the ledger row.
|
|
308
|
+
* @property {string} [slug] - ledger project slug; no slug and no `writeRow` ⇒ no row.
|
|
309
|
+
* @property {Record<string, any>} [rowExtra] - extra ledger fields (e.g. S2's `source`).
|
|
310
|
+
* @property {ReadonlyArray<any>} [forbidden] - the list checked for coders (default `mergeForbidden()`:
|
|
311
|
+
* FORBIDDEN plus the coder-only entries, B4.2).
|
|
312
|
+
*/
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @typedef {object} SessionDeps
|
|
316
|
+
* @property {Partial<Record<"claude"|"codex"|"grok", string>>} [bins] - replaces argv[0] (tests: the fakes).
|
|
317
|
+
* @property {typeof exec} [exec]
|
|
318
|
+
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
|
|
319
|
+
* @property {{write: (s: string) => unknown}} [stderr]
|
|
320
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
321
|
+
*/
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* @typedef {{status: string, reason?: string | null, answer?: any, text?: string} & Record<string, any>} SessionResult -
|
|
325
|
+
* `status`: `ok`, `failed`, `timeout`, `invalid-output`, `unavailable` or `started` (background).
|
|
326
|
+
*/
|
|
327
|
+
|
|
328
|
+
/** @param {string} p @returns {string} `p` realpath'd when it exists, else resolved as given. */
|
|
329
|
+
function realOrResolved(p) {
|
|
330
|
+
try {
|
|
331
|
+
return realpathSync(p);
|
|
332
|
+
} catch {
|
|
333
|
+
return path.resolve(p);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @param {string} p @returns {boolean} true when anything (a symlink included) is at `p`. */
|
|
338
|
+
function entryExists(p) {
|
|
339
|
+
try {
|
|
340
|
+
lstatSync(p);
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* B9b.1: the nearest directory at or above `dir` (`dir` itself included, up to `/`) holding a
|
|
349
|
+
* `.git` entry — a directory (a work tree), a file (a linked work tree or submodule) or any other
|
|
350
|
+
* entry of that name — or null when no ancestor has one. Git and the harness's CLAUDE.md /
|
|
351
|
+
* `.claude` discovery walk up exactly this way, so a facts snapshot below such a directory would
|
|
352
|
+
* see the live repo.
|
|
353
|
+
* @param {string} dir - an absolute, realpath'd directory.
|
|
354
|
+
* @returns {string | null}
|
|
355
|
+
*/
|
|
356
|
+
export function gitWorkTreeAncestor(dir) {
|
|
357
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
358
|
+
if (entryExists(path.join(d, '.git'))) return d;
|
|
359
|
+
if (path.dirname(d) === d) return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* B9b.1: the private root a facts snapshot goes to when the run root itself sits inside a git work
|
|
365
|
+
* tree (`tmp.root` configured inside the project): `<os.tmpdir()>/code-forge/facts-<hash of the
|
|
366
|
+
* run root>`, a B0.1 run root (0700, owned by this process, `owner.json`, swept once it dies).
|
|
367
|
+
* `create: true` creates it through `runRoot` (trust checks included); otherwise it is only named.
|
|
368
|
+
* @param {string} runRootDir
|
|
369
|
+
* @param {{create?: boolean}} [opts]
|
|
370
|
+
* @returns {string}
|
|
371
|
+
*/
|
|
372
|
+
export function factsTmpRoot(runRootDir, opts = {}) {
|
|
373
|
+
const name = `facts-${createHash('sha256').update(realOrResolved(runRootDir)).digest('hex').slice(0, 16)}`;
|
|
374
|
+
return opts.create ? runRoot(name) : path.join(tmpBase(), name);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* B9b.1, fail closed: the facts role's `cwd` override must be an existing directory inside a facts
|
|
379
|
+
* snapshot area — `<runRoot>/facts/`, or the `facts/` area of the run root's private tmp root
|
|
380
|
+
* ({@link factsTmpRoot}) when that root exists and is trusted (all realpath'd) — never the live
|
|
381
|
+
* tree (`process.cwd()`) and never inside a git work tree: every ancestor up to `/` is walked and
|
|
382
|
+
* any `.git` entry (a file or a directory) refuses, because git and the harness's CLAUDE.md /
|
|
383
|
+
* `.claude` discovery would walk up into the real project.
|
|
384
|
+
* @param {unknown} cwd @param {string} runRootDir
|
|
385
|
+
*/
|
|
386
|
+
function assertFactsCwd(cwd, runRootDir) {
|
|
387
|
+
const refuse = (/** @type {string} */ why) => new SessionError('usage', `facts cwd refused: ${why}`);
|
|
388
|
+
if (typeof cwd !== 'string' || cwd.length === 0) throw refuse('it must be a non-empty path');
|
|
389
|
+
let real;
|
|
390
|
+
try {
|
|
391
|
+
real = realpathSync(path.resolve(cwd));
|
|
392
|
+
if (!statSync(real).isDirectory()) throw refuse('it is not a directory');
|
|
393
|
+
} catch (err) {
|
|
394
|
+
if (err instanceof SessionError) throw err;
|
|
395
|
+
throw refuse('it does not exist');
|
|
396
|
+
}
|
|
397
|
+
/** @type {string[]} */
|
|
398
|
+
const areas = [];
|
|
399
|
+
const tmpRoot = factsTmpRoot(runRootDir);
|
|
400
|
+
for (const area of [path.join(runRootDir, 'facts'), ...(untrustedReason(tmpRoot) === null ? [path.join(tmpRoot, 'facts')] : [])]) {
|
|
401
|
+
try {
|
|
402
|
+
areas.push(realpathSync(area));
|
|
403
|
+
} catch {
|
|
404
|
+
// not built (yet): not an area
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (areas.length === 0) throw refuse('the run root has no facts snapshot area');
|
|
408
|
+
if (!areas.some((area) => real.startsWith(`${area}${path.sep}`))) throw refuse('it is outside the run root facts snapshot area');
|
|
409
|
+
let live = null;
|
|
410
|
+
try {
|
|
411
|
+
live = realpathSync(process.cwd());
|
|
412
|
+
} catch {
|
|
413
|
+
// the live cwd is gone: nothing can equal it
|
|
414
|
+
}
|
|
415
|
+
if (real === live) throw refuse('it is a live work tree');
|
|
416
|
+
const workTree = gitWorkTreeAncestor(real);
|
|
417
|
+
if (workTree !== null) throw refuse(`inside a git work tree (${workTree})`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* @param {SessionOpts} opts
|
|
422
|
+
* @param {SessionDeps} [deps]
|
|
423
|
+
* @returns {Promise<SessionResult>}
|
|
424
|
+
*/
|
|
425
|
+
export async function spawnSession(opts, deps = {}) {
|
|
426
|
+
const { cfg, level, role, promptPath } = opts;
|
|
427
|
+
if (!LEVELS.includes(level)) throw new SessionError('usage', `level must be one of ${LEVELS.join(', ')}`);
|
|
428
|
+
if (!ROLES.includes(role)) throw new SessionError('usage', `role must be one of ${ROLES.join(', ')}`);
|
|
429
|
+
if (typeof promptPath !== 'string' || !path.isAbsolute(promptPath)) throw new SessionError('usage', 'promptPath must be an absolute path');
|
|
430
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
431
|
+
const runRootDir = opts.runRoot ?? currentRunRoot();
|
|
432
|
+
if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
|
|
433
|
+
const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
|
|
434
|
+
const steps = ladderFor(resolveLevel(cfg, level), role);
|
|
435
|
+
if (opts.background) steps.length = 1;
|
|
436
|
+
|
|
437
|
+
const attempts = [];
|
|
438
|
+
let rateLimitedOnce = false;
|
|
439
|
+
for (let i = 0; i < steps.length; i += 1) {
|
|
440
|
+
const step = steps[i];
|
|
441
|
+
const sessionDir = path.join(runRootDir, 'sessions', `${role}-${Date.now()}-${randomBytes(4).toString('hex')}`);
|
|
442
|
+
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
443
|
+
let keep = false;
|
|
444
|
+
try {
|
|
445
|
+
const built = buildStep(opts, step, sessionDir);
|
|
446
|
+
const cli = built.cli;
|
|
447
|
+
built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
|
|
448
|
+
if (role === 'coder') {
|
|
449
|
+
const hit = isForbidden(built.argv, opts.forbidden ?? mergeForbidden());
|
|
450
|
+
if (hit) throw new SessionError('forbidden', `coder argv refused before spawn: forbidden entry ${hit.id}`);
|
|
451
|
+
}
|
|
452
|
+
writeSafe(stderr, `level=${level} provider=${step.provider} model=${step.model} effort=${step.effort ?? 'none'} fallback_step=${step.fallback_step}\n`);
|
|
453
|
+
const base = { role, level, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, run: opts.run ?? null, block: opts.block ?? null, ...(role === 's2' ? { source: step.fallback_step > 0 ? 's2-fallback' : 's2' } : {}), ...(opts.rowExtra ?? {}) };
|
|
454
|
+
|
|
455
|
+
if (opts.background) {
|
|
456
|
+
keep = true;
|
|
457
|
+
// B4.1: the builder's env (the Codex coder's CODEX_HOME) wins over the caller's; a
|
|
458
|
+
// background session's home stays until the run root is swept.
|
|
459
|
+
const bg = startBackground(built, sessionDir, runRootDir, { ...childEnv(deps.env ?? process.env), ...(built.env ?? {}) });
|
|
460
|
+
if (writeRow) await writeRow({ event: 'session.background', ...base, pid: bg.pid, status: 'started' });
|
|
461
|
+
return { status: 'started', ...bg, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, attempts };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const attempt = await runForeground(built, opts, deps);
|
|
465
|
+
const row = {
|
|
466
|
+
event: 'session',
|
|
467
|
+
...base,
|
|
468
|
+
status: attempt.status,
|
|
469
|
+
...(attempt.reason ? { reason: attempt.reason } : {}),
|
|
470
|
+
tokens_in: attempt.usage.tokens_in,
|
|
471
|
+
tokens_out: attempt.usage.tokens_out,
|
|
472
|
+
tokens_source: attempt.usage.tokens_source,
|
|
473
|
+
duration_ms: attempt.duration_ms,
|
|
474
|
+
...(level === 'L3' && step.fallback_step > 0 ? { l3_fallback: true } : {}),
|
|
475
|
+
};
|
|
476
|
+
if (writeRow) await writeRow(row);
|
|
477
|
+
attempts.push({ fallback_step: step.fallback_step, provider: step.provider, model: step.model, status: attempt.status, reason: attempt.reason ?? null });
|
|
478
|
+
if (attempt.status === 'unavailable' && attempt.reason === 'rate-limited' && !rateLimitedOnce) {
|
|
479
|
+
rateLimitedOnce = true; // rate-limited once: the same step gets one more try (§5.6 "twice in a row")
|
|
480
|
+
i -= 1;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
rateLimitedOnce = false;
|
|
484
|
+
if (attempt.status === 'unavailable' && i + 1 < steps.length) continue;
|
|
485
|
+
return { ...attempt, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, row, attempts };
|
|
486
|
+
} finally {
|
|
487
|
+
if (!keep) rmSync(sessionDir, { recursive: true, force: true });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
throw new Error('spawnSession: unreachable — the ladder always has a step');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Build one step's argv; closed-book roles get a fresh empty cwd inside the session dir (the facts
|
|
495
|
+
* role: the caller's snapshot when `opts.cwd` names one), and Codex
|
|
496
|
+
* its `-o` file and schema file next to it (never inside the cwd).
|
|
497
|
+
* @param {SessionOpts} opts
|
|
498
|
+
* @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
|
|
499
|
+
* @param {string} sessionDir
|
|
500
|
+
*/
|
|
501
|
+
function buildStep(opts, step, sessionDir) {
|
|
502
|
+
const closedBook = opts.role !== 'coder';
|
|
503
|
+
// B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
|
|
504
|
+
const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
|
|
505
|
+
const cwd = snapshot ?? (closedBook ? path.join(sessionDir, 'cwd') : path.resolve(opts.cwd ?? process.cwd()));
|
|
506
|
+
if (closedBook && snapshot === null) mkdirSync(cwd, { mode: 0o700 });
|
|
507
|
+
const cli = cliNameForProvider(step.provider);
|
|
508
|
+
if (cli === undefined) throw new SessionError('usage', `provider "${step.provider}" has no CLI mapping`);
|
|
509
|
+
/** @type {Record<string, any>} */
|
|
510
|
+
const params = { provider: step.provider, role: opts.role, model: step.model, promptPath: opts.promptPath, cwd };
|
|
511
|
+
if (step.effort) params.effort = step.effort;
|
|
512
|
+
if (step.flagFallback) params.fallback = [step.flagFallback];
|
|
513
|
+
if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
|
|
514
|
+
if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
|
|
515
|
+
if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
|
|
516
|
+
/** @type {Record<string, any> | null} */
|
|
517
|
+
let compiled = null;
|
|
518
|
+
const strict = variantFor(step.provider) === 'strict';
|
|
519
|
+
if (closedBook && opts.schema) {
|
|
520
|
+
compiled = compiledFor(opts.schema, /** @type {"anthropic"|"openai"|"xai"} */ (step.provider));
|
|
521
|
+
if (cli === 'codex') {
|
|
522
|
+
params.schemaPath = path.join(sessionDir, 'schema.json');
|
|
523
|
+
writeFileSync(params.schemaPath, JSON.stringify(compiled), { mode: 0o600 });
|
|
524
|
+
} else {
|
|
525
|
+
params.schema = compiled;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const built = buildArgv(/** @type {any} */ (params));
|
|
529
|
+
return { ...built, argv: [...built.argv], compiled, strict };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* @param {{cli: string, argv: string[], cwd: string, stdinFile?: string, outPath?: string, env?: Record<string, string>, compiled: Record<string, any> | null, strict: boolean}} built
|
|
534
|
+
* @param {SessionOpts} opts
|
|
535
|
+
* @param {SessionDeps} deps
|
|
536
|
+
* @returns {Promise<SessionResult & {usage: {tokens_in: number, tokens_out: number, tokens_source: string}, duration_ms: number}>}
|
|
537
|
+
*/
|
|
538
|
+
async function runForeground(built, opts, deps) {
|
|
539
|
+
const input = built.stdinFile ? readFileSync(built.stdinFile) : undefined;
|
|
540
|
+
const started = Date.now();
|
|
541
|
+
const run = deps.exec ?? exec;
|
|
542
|
+
/** @type {import('../util/exec.mjs').ExecResult} */
|
|
543
|
+
let res;
|
|
544
|
+
try {
|
|
545
|
+
res = await run(built.argv, {
|
|
546
|
+
cwd: built.cwd,
|
|
547
|
+
env: { ...childEnv(deps.env ?? process.env), ...(built.env ?? {}) },
|
|
548
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
549
|
+
...(input !== undefined ? { input } : {}),
|
|
550
|
+
});
|
|
551
|
+
} finally {
|
|
552
|
+
// B4.1: the Codex coder's per-session CODEX_HOME (rules + auth copy) dies with the session.
|
|
553
|
+
if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME);
|
|
554
|
+
}
|
|
555
|
+
const duration_ms = Date.now() - started;
|
|
556
|
+
/** @type {string | null} */
|
|
557
|
+
let outText = null;
|
|
558
|
+
if (built.outPath) {
|
|
559
|
+
outText = existsSync(built.outPath) ? readFileSync(built.outPath, 'utf8') : null;
|
|
560
|
+
rmSync(built.outPath, { force: true });
|
|
561
|
+
}
|
|
562
|
+
const read = readAnswer(built.cli, res.stdout, outText);
|
|
563
|
+
const inputBytes = input?.length ?? fileSize(opts.promptPath);
|
|
564
|
+
const usage =
|
|
565
|
+
read.usage.tokensSource === 'reported'
|
|
566
|
+
? { tokens_in: read.usage.tokensIn, tokens_out: read.usage.tokensOut, tokens_source: 'reported' }
|
|
567
|
+
: { tokens_in: estimateTokens(inputBytes), tokens_out: estimateTokens(Buffer.byteLength(res.stdout) + Buffer.byteLength(outText ?? '')), tokens_source: 'estimated' };
|
|
568
|
+
const common = { usage, duration_ms, text: read.text, exit_code: res.code, stderr: redact(res.stderr) };
|
|
569
|
+
|
|
570
|
+
const unavailable = classifyUnavailable(res, read.parsed);
|
|
571
|
+
if (unavailable) return { status: 'unavailable', reason: unavailable, answer: null, ...common };
|
|
572
|
+
if (res.timedOut) return { status: 'timeout', reason: `killed after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS} ms`, answer: null, ...common };
|
|
573
|
+
if (res.result !== 'ok' || read.parsed?.is_error === true) return { status: 'failed', reason: `exit ${res.code}`, answer: null, ...common };
|
|
574
|
+
if (built.compiled) {
|
|
575
|
+
// validate against the schema the provider was actually given (the cached variant, stable across attempts)
|
|
576
|
+
if (!matchesSchema(built.compiled, read.answer)) {
|
|
577
|
+
return { status: 'invalid-output', reason: 'answer does not match the schema', answer: null, ...common };
|
|
578
|
+
}
|
|
579
|
+
// a strict answer says `null` where the source schema says "optional"; callers get the source shape
|
|
580
|
+
return { status: 'ok', answer: built.strict ? dropStrictNulls(read.answer, opts.schema) : read.answer, ...common };
|
|
581
|
+
}
|
|
582
|
+
return { status: 'ok', answer: read.answer ?? read.text, ...(opts.role === 'coder' ? { sentinel: parseSentinel(read.text) } : {}), ...common };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Start a detached child writing to a log; record its pid file and registry entry.
|
|
587
|
+
* @param {{cli: string, argv: string[], cwd: string, stdinFile?: string, outPath?: string}} built
|
|
588
|
+
* @param {string} sessionDir @param {string} runRootDir @param {NodeJS.ProcessEnv} env
|
|
589
|
+
*/
|
|
590
|
+
function startBackground(built, sessionDir, runRootDir, env) {
|
|
591
|
+
const logPath = path.join(sessionDir, 'session.log');
|
|
592
|
+
const pidFile = path.join(sessionDir, 'pid.json');
|
|
593
|
+
const logFd = openSync(logPath, 'a', 0o600);
|
|
594
|
+
const inFd = built.stdinFile ? openSync(built.stdinFile, 'r') : null;
|
|
595
|
+
try {
|
|
596
|
+
const [command, ...args] = built.argv;
|
|
597
|
+
const child = spawn(command, args, { cwd: built.cwd, env, shell: false, detached: true, stdio: [inFd ?? 'ignore', logFd, logFd] });
|
|
598
|
+
child.on('error', () => {}); // a missing CLI surfaces as a dead pid + an empty log, not a crash
|
|
599
|
+
const pid = child.pid;
|
|
600
|
+
if (typeof pid !== 'number') throw new Error(`spawn: could not start ${built.cli}`);
|
|
601
|
+
registerPid(pidsDir(runRootDir), pid, command);
|
|
602
|
+
const start_time = readStartTime(pid) ?? UNKNOWN_START_TIME;
|
|
603
|
+
writeFileSync(pidFile, `${JSON.stringify({ pid, start_time, argv0: command, log: logPath, ...(built.outPath ? { outPath: built.outPath } : {}) })}\n`, { mode: 0o600 });
|
|
604
|
+
child.unref();
|
|
605
|
+
return { pid, pidFile, logPath, sessionDir };
|
|
606
|
+
} finally {
|
|
607
|
+
closeSync(logFd);
|
|
608
|
+
if (inFd !== null) closeSync(inFd);
|
|
609
|
+
}
|
|
610
|
+
}
|