@magnusekdahl/parallix 1.1.0 → 1.2.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/config/integration-pipelines.json +9 -0
- package/config/workflow.config.schema.json +12 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
- package/docs/adr/0041-integration-pipeline-gates.md +1 -1
- package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
- package/docs/adr/index.md +1 -0
- package/docs/use-cases.md +44 -0
- package/lib/agents/agents.js +149 -100
- package/lib/agents/claude-telemetry.js +14 -10
- package/lib/agents/claude.js +32 -11
- package/lib/agents/codex-telemetry.js +32 -19
- package/lib/agents/codex.js +36 -11
- package/lib/agents/limit-hit.js +42 -25
- package/lib/agents/mistral-telemetry.js +1 -1
- package/lib/agents/mistral.js +13 -3
- package/lib/agents/opencode-export.js +11 -5
- package/lib/agents/opencode-telemetry.js +75 -90
- package/lib/agents/opencode.js +86 -29
- package/lib/agents/stage-telemetry.js +3 -7
- package/lib/commands/active.js +139 -94
- package/lib/commands/checkpoint.js +3 -1
- package/lib/commands/config.js +5 -3
- package/lib/commands/coverage-gate.js +17 -10
- package/lib/commands/diff.js +11 -5
- package/lib/commands/draft.js +95 -55
- package/lib/commands/handoff.js +253 -65
- package/lib/commands/integrate.js +288 -188
- package/lib/commands/mission-start.js +39 -34
- package/lib/commands/rebase.js +32 -21
- package/lib/commands/repair-handoff.js +21 -18
- package/lib/commands/resolve-conflict.js +5 -2
- package/lib/commands/review.js +1 -0
- package/lib/commands/setup-review.js +1 -0
- package/lib/commands/stats-backfill.js +50 -41
- package/lib/commands/stats.js +653 -204
- package/lib/commands/status.js +33 -28
- package/lib/core/fmt.js +58 -15
- package/lib/core/git.js +7 -5
- package/lib/core/gitignore.js +6 -4
- package/lib/core/mission-utils.js +161 -88
- package/lib/core/nels.js +199 -0
- package/lib/core/persistent-data-migration.js +55 -23
- package/lib/core/product-config.js +44 -23
- package/lib/core/runtime-matrix.js +15 -2
- package/lib/core/spawn-tee.js +40 -13
- package/lib/core/state-map.js +32 -13
- package/lib/core/storage.js +19 -5
- package/lib/core/subagent-limit.js +28 -0
- package/lib/core/verification.js +29 -12
- package/lib/review/rebase.js +11 -3
- package/lib/review/review-adapter.js +93 -6
- package/lib/review/review-artifacts.js +89 -23
- package/lib/review/review-commands.js +139 -45
- package/lib/review/review-events.js +103 -52
- package/lib/review/review-loop.js +117 -87
- package/lib/review/review-polling.js +25 -5
- package/lib/review/review-prompts.js +36 -5
- package/lib/review/review-state.js +39 -15
- package/lib/tools/backlog.js +162 -71
- package/lib/tools/forgejo.js +290 -129
- package/lib/tools/gatekeeper.js +15 -0
- package/lib/tools/redgreen.js +220 -0
- package/lib/tools/sessions.js +12 -5
- package/lib/tools/setup-review.js +170 -70
- package/package.json +11 -3
- package/prompts/draft.md +6 -0
- package/templates/mission-scaffold.md +1 -1
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
const PROVIDER = 'anthropic';
|
|
38
38
|
|
|
39
|
+
/** @param {*} value */
|
|
39
40
|
function num(value) {
|
|
40
41
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
41
42
|
}
|
|
@@ -45,6 +46,7 @@ function num(value) {
|
|
|
45
46
|
* `--include-partial-messages` form nests the real event under `stream_event`;
|
|
46
47
|
* the bare form has it at the top level. Returns the inner event object.
|
|
47
48
|
*/
|
|
49
|
+
/** @param {any} evt */
|
|
48
50
|
function unwrapEvent(evt) {
|
|
49
51
|
if (evt && evt.type === 'stream_event' && evt.event && typeof evt.event === 'object') {
|
|
50
52
|
return evt.event;
|
|
@@ -66,8 +68,9 @@ function unwrapEvent(evt) {
|
|
|
66
68
|
* resultUsage: { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens } | null
|
|
67
69
|
* }
|
|
68
70
|
*/
|
|
71
|
+
/** @param {string} content */
|
|
69
72
|
function parseClaudeStreamJson(content) {
|
|
70
|
-
if (!content) return null;
|
|
73
|
+
if (!content) {return null;}
|
|
71
74
|
|
|
72
75
|
let sessionId = null;
|
|
73
76
|
let model = null;
|
|
@@ -79,19 +82,19 @@ function parseClaudeStreamJson(content) {
|
|
|
79
82
|
|
|
80
83
|
for (const line of String(content).split('\n')) {
|
|
81
84
|
const trimmed = line.trim();
|
|
82
|
-
if (!trimmed.startsWith('{')) continue;
|
|
85
|
+
if (!trimmed.startsWith('{')) {continue;}
|
|
83
86
|
let outer;
|
|
84
87
|
try {
|
|
85
88
|
outer = JSON.parse(trimmed);
|
|
86
89
|
} catch (_) {
|
|
87
90
|
continue;
|
|
88
91
|
}
|
|
89
|
-
if (!outer || typeof outer !== 'object') continue;
|
|
92
|
+
if (!outer || typeof outer !== 'object') {continue;}
|
|
90
93
|
|
|
91
94
|
// Top-level CLI envelope fields (present on system/result and sometimes the
|
|
92
95
|
// wrapper) carry session/model metadata.
|
|
93
|
-
if (outer.session_id) sessionId = outer.session_id;
|
|
94
|
-
if (outer.type === 'system' && outer.model) model = outer.model;
|
|
96
|
+
if (outer.session_id) {sessionId = outer.session_id;}
|
|
97
|
+
if (outer.type === 'system' && outer.model) {model = outer.model;}
|
|
95
98
|
|
|
96
99
|
// The final `result` event carries `total_cost_usd` (direct from the CLI)
|
|
97
100
|
// and an aggregate usage object (fallback when partial events were evicted).
|
|
@@ -110,13 +113,13 @@ function parseClaudeStreamJson(content) {
|
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
const evt = unwrapEvent(outer);
|
|
113
|
-
if (!evt || typeof evt !== 'object') continue;
|
|
116
|
+
if (!evt || typeof evt !== 'object') {continue;}
|
|
114
117
|
|
|
115
118
|
switch (evt.type) {
|
|
116
119
|
case 'message_start': {
|
|
117
120
|
const message = evt.message || {};
|
|
118
121
|
const usage = message.usage || {};
|
|
119
|
-
if (message.model) model = message.model;
|
|
122
|
+
if (message.model) {model = message.model;}
|
|
120
123
|
messageStarts.push({
|
|
121
124
|
inputTokens: num(usage.input_tokens),
|
|
122
125
|
outputTokens: num(usage.output_tokens),
|
|
@@ -133,7 +136,7 @@ function parseClaudeStreamJson(content) {
|
|
|
133
136
|
}
|
|
134
137
|
case 'content_block_start': {
|
|
135
138
|
const block = evt.content_block || {};
|
|
136
|
-
if (block.type === 'tool_use') toolCalls += 1;
|
|
139
|
+
if (block.type === 'tool_use') {toolCalls += 1;}
|
|
137
140
|
break;
|
|
138
141
|
}
|
|
139
142
|
default:
|
|
@@ -142,7 +145,7 @@ function parseClaudeStreamJson(content) {
|
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
const hasSignal = messageStarts.length > 0 || messageDeltas.length > 0 || resultUsage || model || sessionId;
|
|
145
|
-
if (!hasSignal) return null;
|
|
148
|
+
if (!hasSignal) {return null;}
|
|
146
149
|
|
|
147
150
|
return {
|
|
148
151
|
sessionId,
|
|
@@ -174,9 +177,10 @@ function parseClaudeStreamJson(content) {
|
|
|
174
177
|
*
|
|
175
178
|
* Returns null when the stream yields no usable token signal.
|
|
176
179
|
*/
|
|
180
|
+
/** @param {string} stdout */
|
|
177
181
|
function extractClaudeTelemetryFromStdout(stdout) {
|
|
178
182
|
const parsed = parseClaudeStreamJson(stdout);
|
|
179
|
-
if (!parsed) return null;
|
|
183
|
+
if (!parsed) {return null;}
|
|
180
184
|
|
|
181
185
|
const { messageStarts, messageDeltas, resultUsage, resultCostUsd } = parsed;
|
|
182
186
|
|
package/lib/agents/claude.js
CHANGED
|
@@ -3,11 +3,15 @@ const { extractClaudeTelemetryFromStdout } = require('./claude-telemetry');
|
|
|
3
3
|
const sessions = require('../tools/sessions');
|
|
4
4
|
|
|
5
5
|
// Injectable I/O for tests. Production uses the real spawn-tee / export capture.
|
|
6
|
+
/** @type {Function} */
|
|
6
7
|
let _spawnAndTee = spawnAndTee;
|
|
8
|
+
/** @type {object} */
|
|
7
9
|
let _sessions = sessions;
|
|
8
10
|
|
|
9
11
|
// Test hooks: override the launcher's I/O without touching the public signature.
|
|
12
|
+
/** @param {Function} fn */
|
|
10
13
|
function __setSpawnAndTeeForTest(fn) { _spawnAndTee = fn || spawnAndTee; }
|
|
14
|
+
/** @param {object} mod */
|
|
11
15
|
function __setSessionsForTest(mod) { _sessions = mod || sessions; }
|
|
12
16
|
|
|
13
17
|
// Claude usage telemetry is parsed from the `stream-json` stdout stream, which
|
|
@@ -20,16 +24,17 @@ const CLAUDE_TELEMETRY_TAIL_BYTES = 8 * 1024 * 1024;
|
|
|
20
24
|
// Claude outputs "Resume this session with: claude --resume <id>" at the end.
|
|
21
25
|
const CLAUDE_SESSION_ID_RE = /claude\s+--resume\s+([0-9a-f-]+)/i;
|
|
22
26
|
|
|
27
|
+
/** @param {string} stdout */
|
|
23
28
|
function extractClaudeSessionIdFromStreamJson(stdout) {
|
|
24
|
-
if (!stdout) return null;
|
|
29
|
+
if (!stdout) {return null;}
|
|
25
30
|
const lines = stdout.split('\n');
|
|
26
31
|
for (const line of lines) {
|
|
27
32
|
const trimmed = line.trim();
|
|
28
|
-
if (!trimmed.startsWith('{')) continue;
|
|
33
|
+
if (!trimmed.startsWith('{')) {continue;}
|
|
29
34
|
try {
|
|
30
35
|
const evt = JSON.parse(trimmed);
|
|
31
36
|
// stream-json 'result' events carry session_id at the top level.
|
|
32
|
-
if (evt.type === 'result' && evt.session_id) return evt.session_id;
|
|
37
|
+
if (evt.type === 'result' && evt.session_id) {return evt.session_id;}
|
|
33
38
|
} catch {
|
|
34
39
|
/* not JSON — skip */
|
|
35
40
|
}
|
|
@@ -37,11 +42,12 @@ function extractClaudeSessionIdFromStreamJson(stdout) {
|
|
|
37
42
|
return null;
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/** @param {string} stdout */
|
|
40
46
|
function extractClaudeSessionId(stdout) {
|
|
41
|
-
if (!stdout) return null;
|
|
47
|
+
if (!stdout) {return null;}
|
|
42
48
|
// Try stream-json result event first (new default format).
|
|
43
49
|
const fromStream = extractClaudeSessionIdFromStreamJson(stdout);
|
|
44
|
-
if (fromStream) return fromStream;
|
|
50
|
+
if (fromStream) {return fromStream;}
|
|
45
51
|
// Fall back to legacy plain-text regex.
|
|
46
52
|
const m = CLAUDE_SESSION_ID_RE.exec(stdout);
|
|
47
53
|
return m ? m[1] : null;
|
|
@@ -51,9 +57,12 @@ function resolveClaudeCommand() {
|
|
|
51
57
|
return 'claude';
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* @param {{ prompt: string, worktree: string, env?: object, resume?: boolean, sessionId?: string | null, model?: string | null }} opts
|
|
62
|
+
*/
|
|
54
63
|
function buildClaudeInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null }) {
|
|
55
64
|
const args = ['--dangerously-skip-permissions'];
|
|
56
|
-
if (model) args.push('--model', model);
|
|
65
|
+
if (model) {args.push('--model', model);}
|
|
57
66
|
// Only resume when the marker explicitly says so (resume=true).
|
|
58
67
|
// sessionId from a stale cross-family marker must be ignored when resume=false.
|
|
59
68
|
if (resume && sessionId) {
|
|
@@ -80,14 +89,23 @@ function buildClaudeInvocation({ prompt, worktree, env, resume = false, sessionI
|
|
|
80
89
|
};
|
|
81
90
|
}
|
|
82
91
|
|
|
92
|
+
/**
|
|
93
|
+
* @param {{ prompt: string, worktree: string, env?: object, resume?: boolean, sessionId?: string | null, model?: string | null, teeOptions?: object, slug?: string | null, role?: string | null }} opts
|
|
94
|
+
*/
|
|
83
95
|
function startClaudeAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {}, slug = null, role = null }) {
|
|
96
|
+
/**
|
|
97
|
+
* @param {{stdout?: string, stderr?: string}} result
|
|
98
|
+
*/
|
|
84
99
|
function isStaleSessionResult(result) {
|
|
85
|
-
if (!result) return false;
|
|
100
|
+
if (!result) {return false;}
|
|
86
101
|
const stderr = result.stderr || '';
|
|
87
102
|
const stdout = result.stdout || '';
|
|
88
103
|
return (stderr.includes('Session not found') || stdout.includes('Session not found'));
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
/**
|
|
107
|
+
* @param {{stdout?: string, sessionId?: string, telemetry?: object, model?: string, provider?: string}} result
|
|
108
|
+
*/
|
|
91
109
|
function processResult(result) {
|
|
92
110
|
if (result && result.stdout) {
|
|
93
111
|
result.sessionId = extractClaudeSessionId(result.stdout);
|
|
@@ -95,8 +113,8 @@ function startClaudeAgent({ prompt, worktree, env, resume = false, sessionId = n
|
|
|
95
113
|
const telemetry = extractClaudeTelemetryFromStdout(result.stdout);
|
|
96
114
|
if (telemetry) {
|
|
97
115
|
result.telemetry = telemetry;
|
|
98
|
-
if (telemetry.model) result.model = telemetry.model;
|
|
99
|
-
if (telemetry.provider) result.provider = telemetry.provider;
|
|
116
|
+
if (telemetry.model) {result.model = telemetry.model;}
|
|
117
|
+
if (telemetry.provider) {result.provider = telemetry.provider;}
|
|
100
118
|
}
|
|
101
119
|
} catch (_) {
|
|
102
120
|
// Telemetry is best-effort; never let it break the launch result.
|
|
@@ -105,13 +123,16 @@ function startClaudeAgent({ prompt, worktree, env, resume = false, sessionId = n
|
|
|
105
123
|
return result;
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
/**
|
|
127
|
+
* @param {{command: string, args: string[], options?: object}} invocation
|
|
128
|
+
*/
|
|
108
129
|
function staleSessionHandler(invocation) {
|
|
109
130
|
const teeWithTail = { maxTailBytes: CLAUDE_TELEMETRY_TAIL_BYTES, ...invocation.options, ...teeOptions };
|
|
110
131
|
return _spawnAndTee(invocation.command, invocation.args, teeWithTail)
|
|
111
|
-
.then(result => {
|
|
132
|
+
.then(/** @param {object} result */ (result) => {
|
|
112
133
|
if (isStaleSessionResult(result) && worktree && resume) {
|
|
113
134
|
try {
|
|
114
|
-
_sessions.clearSession(worktree, slug, role);
|
|
135
|
+
/** @type {any} */(_sessions).clearSession(worktree, slug, role);
|
|
115
136
|
} catch (_) { /* best-effort */ }
|
|
116
137
|
const freshInv = buildClaudeInvocation({ prompt, worktree, env, resume: false, sessionId: null, model });
|
|
117
138
|
const freshTee = { maxTailBytes: CLAUDE_TELEMETRY_TAIL_BYTES, ...freshInv.options, ...teeOptions };
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const fs = require('fs');
|
|
17
17
|
const path = require('path');
|
|
18
18
|
|
|
19
|
+
/** @param {string} codexHome */
|
|
19
20
|
function codexSessionsDir(codexHome) {
|
|
20
21
|
return path.join(codexHome, '.codex', 'sessions');
|
|
21
22
|
}
|
|
@@ -24,8 +25,9 @@ function codexSessionsDir(codexHome) {
|
|
|
24
25
|
* Parse a rollout JSONL string into a telemetry object. Returns null when the
|
|
25
26
|
* content yields no usable signal (e.g. a failed turn with no token_count).
|
|
26
27
|
*/
|
|
28
|
+
/** @param {string} content */
|
|
27
29
|
function parseCodexRollout(content) {
|
|
28
|
-
if (!content) return null;
|
|
30
|
+
if (!content) {return null;}
|
|
29
31
|
|
|
30
32
|
let sessionId = null;
|
|
31
33
|
let provider = null;
|
|
@@ -38,7 +40,7 @@ function parseCodexRollout(content) {
|
|
|
38
40
|
|
|
39
41
|
for (const line of String(content).split('\n')) {
|
|
40
42
|
const trimmed = line.trim();
|
|
41
|
-
if (!trimmed.startsWith('{')) continue;
|
|
43
|
+
if (!trimmed.startsWith('{')) {continue;}
|
|
42
44
|
let evt;
|
|
43
45
|
try {
|
|
44
46
|
evt = JSON.parse(trimmed);
|
|
@@ -46,21 +48,21 @@ function parseCodexRollout(content) {
|
|
|
46
48
|
continue;
|
|
47
49
|
}
|
|
48
50
|
const payload = evt && evt.payload;
|
|
49
|
-
if (!payload) continue;
|
|
51
|
+
if (!payload) {continue;}
|
|
50
52
|
|
|
51
53
|
switch (evt.type) {
|
|
52
54
|
case 'session_meta':
|
|
53
55
|
sessionId = payload.id || sessionId;
|
|
54
56
|
provider = payload.model_provider || provider;
|
|
55
|
-
if (payload.model) model = payload.model;
|
|
57
|
+
if (payload.model) {model = payload.model;}
|
|
56
58
|
break;
|
|
57
59
|
case 'turn_context':
|
|
58
60
|
// The latest turn_context reflects the model/effort actually used.
|
|
59
|
-
if (payload.model) model = payload.model;
|
|
60
|
-
if (payload.effort) effort = payload.effort;
|
|
61
|
+
if (payload.model) {model = payload.model;}
|
|
62
|
+
if (payload.effort) {effort = payload.effort;}
|
|
61
63
|
break;
|
|
62
64
|
case 'response_item':
|
|
63
|
-
if (payload.type === 'function_call') toolCalls += 1;
|
|
65
|
+
if (payload.type === 'function_call') {toolCalls += 1;}
|
|
64
66
|
break;
|
|
65
67
|
case 'event_msg':
|
|
66
68
|
if (payload.type === 'token_count') {
|
|
@@ -73,7 +75,7 @@ function parseCodexRollout(content) {
|
|
|
73
75
|
const pct = payload.rate_limits
|
|
74
76
|
&& payload.rate_limits.primary
|
|
75
77
|
&& payload.rate_limits.primary.used_percent;
|
|
76
|
-
if (typeof pct === 'number') usagePercent = pct;
|
|
78
|
+
if (typeof pct === 'number') {usagePercent = pct;}
|
|
77
79
|
}
|
|
78
80
|
break;
|
|
79
81
|
default:
|
|
@@ -81,7 +83,7 @@ function parseCodexRollout(content) {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
if (!lastUsage && !model && !sessionId) return null;
|
|
86
|
+
if (!lastUsage && !model && !sessionId) {return null;}
|
|
85
87
|
|
|
86
88
|
const usage = lastUsage || {};
|
|
87
89
|
return {
|
|
@@ -107,8 +109,12 @@ function parseCodexRollout(content) {
|
|
|
107
109
|
* `total_token_usage` counter, so a multi-round stage produces several files
|
|
108
110
|
* whose totals must be summed to get the stage's real quota consumption.
|
|
109
111
|
*/
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} sessionsDir
|
|
114
|
+
* @param {{ sinceMs?: number }} [opts]
|
|
115
|
+
*/
|
|
110
116
|
function collectRolloutFiles(sessionsDir, { sinceMs = 0 } = {}) {
|
|
111
|
-
if (!sessionsDir || !fs.existsSync(sessionsDir)) return [];
|
|
117
|
+
if (!sessionsDir || !fs.existsSync(sessionsDir)) {return [];}
|
|
112
118
|
|
|
113
119
|
const found = [];
|
|
114
120
|
const stack = [sessionsDir];
|
|
@@ -116,12 +122,14 @@ function collectRolloutFiles(sessionsDir, { sinceMs = 0 } = {}) {
|
|
|
116
122
|
const dir = stack.pop();
|
|
117
123
|
let entries;
|
|
118
124
|
try {
|
|
119
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
125
|
+
entries = /** @type {Array<import('fs').Dirent>} */(/** @type {any} */(fs).readdirSync(dir, { withFileTypes: true }));
|
|
120
126
|
} catch (_) {
|
|
121
127
|
continue;
|
|
122
128
|
}
|
|
123
|
-
for (const entry of entries) {
|
|
124
|
-
|
|
129
|
+
for (const entry of /** @type {Array<{name: string, isDirectory: () => boolean, isFile: () => boolean}>} */(entries)) {
|
|
130
|
+
if (!entry.name) {continue;}
|
|
131
|
+
const /** @type {string} */ name = entry.name ?? '';
|
|
132
|
+
const full = path.join(dir || '', name);
|
|
125
133
|
if (entry.isDirectory()) {
|
|
126
134
|
stack.push(full);
|
|
127
135
|
} else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) {
|
|
@@ -132,7 +140,7 @@ function collectRolloutFiles(sessionsDir, { sinceMs = 0 } = {}) {
|
|
|
132
140
|
continue;
|
|
133
141
|
}
|
|
134
142
|
const mtime = stat.mtimeMs;
|
|
135
|
-
if (mtime + 1000 < sinceMs) continue; // 1s grace for clock skew
|
|
143
|
+
if (mtime + 1000 < sinceMs) {continue;} // 1s grace for clock skew
|
|
136
144
|
found.push({ full, mtime });
|
|
137
145
|
}
|
|
138
146
|
}
|
|
@@ -152,15 +160,20 @@ function collectRolloutFiles(sessionsDir, { sinceMs = 0 } = {}) {
|
|
|
152
160
|
* re-running a stage (e.g. after the workflow process is resumed mid-mission)
|
|
153
161
|
* yields the same total rather than double-counting.
|
|
154
162
|
*/
|
|
163
|
+
/**
|
|
164
|
+
* @param {string} codexHome
|
|
165
|
+
* @param {{ sinceMs?: number }} [opts]
|
|
166
|
+
*/
|
|
155
167
|
function extractCodexTelemetry(codexHome, { sinceMs = 0 } = {}) {
|
|
156
168
|
const files = collectRolloutFiles(codexSessionsDir(codexHome), { sinceMs });
|
|
157
|
-
if (files.length === 0) return null;
|
|
169
|
+
if (files.length === 0) {return null;}
|
|
158
170
|
|
|
159
171
|
const agg = {
|
|
160
172
|
inputTokens: 0, outputTokens: 0, cachedTokens: 0,
|
|
161
173
|
reasoningTokens: 0, totalTokens: 0, toolCalls: 0,
|
|
162
174
|
};
|
|
163
175
|
let newest = null;
|
|
176
|
+
let newestPath = null;
|
|
164
177
|
for (const file of files) {
|
|
165
178
|
let content;
|
|
166
179
|
try {
|
|
@@ -169,7 +182,7 @@ function extractCodexTelemetry(codexHome, { sinceMs = 0 } = {}) {
|
|
|
169
182
|
continue;
|
|
170
183
|
}
|
|
171
184
|
const t = parseCodexRollout(content);
|
|
172
|
-
if (!t) continue;
|
|
185
|
+
if (!t) {continue;}
|
|
173
186
|
agg.inputTokens += t.inputTokens;
|
|
174
187
|
agg.outputTokens += t.outputTokens;
|
|
175
188
|
agg.cachedTokens += t.cachedTokens;
|
|
@@ -177,9 +190,9 @@ function extractCodexTelemetry(codexHome, { sinceMs = 0 } = {}) {
|
|
|
177
190
|
agg.totalTokens += t.totalTokens;
|
|
178
191
|
agg.toolCalls += t.toolCalls;
|
|
179
192
|
newest = t; // files are oldest-first, so the last assignment is the newest
|
|
180
|
-
|
|
193
|
+
newestPath = file;
|
|
181
194
|
}
|
|
182
|
-
if (!newest) return null;
|
|
195
|
+
if (!newest) {return null;}
|
|
183
196
|
|
|
184
197
|
return {
|
|
185
198
|
sessionId: newest.sessionId,
|
|
@@ -188,7 +201,7 @@ function extractCodexTelemetry(codexHome, { sinceMs = 0 } = {}) {
|
|
|
188
201
|
effort: newest.effort,
|
|
189
202
|
contextWindow: newest.contextWindow,
|
|
190
203
|
usagePercent: newest.usagePercent,
|
|
191
|
-
rolloutPath:
|
|
204
|
+
rolloutPath: newestPath,
|
|
192
205
|
rolloutCount: files.length,
|
|
193
206
|
...agg,
|
|
194
207
|
};
|
package/lib/agents/codex.js
CHANGED
|
@@ -6,11 +6,15 @@ const { extractCodexTelemetry } = require('./codex-telemetry');
|
|
|
6
6
|
const sessions = require('../tools/sessions');
|
|
7
7
|
|
|
8
8
|
// Injectable I/O for tests. Production uses the real spawn-tee / export capture.
|
|
9
|
+
/** @type {Function} */
|
|
9
10
|
let _spawnAndTee = spawnAndTee;
|
|
11
|
+
/** @type {object} */
|
|
10
12
|
let _sessions = sessions;
|
|
11
13
|
|
|
12
14
|
// Test hooks: override the launcher's I/O without touching the public signature.
|
|
15
|
+
/** @param {Function} fn */
|
|
13
16
|
function __setSpawnAndTeeForTest(fn) { _spawnAndTee = fn || spawnAndTee; }
|
|
17
|
+
/** @param {object} mod */
|
|
14
18
|
function __setSessionsForTest(mod) { _sessions = mod || sessions; }
|
|
15
19
|
|
|
16
20
|
// Codex outputs "To continue this session, run codex resume <id>" at the end.
|
|
@@ -18,12 +22,13 @@ function __setSessionsForTest(mod) { _sessions = mod || sessions; }
|
|
|
18
22
|
const CODEX_SESSION_ID_RE = /codex\s+resume\s+([0-9a-f-]+)/i;
|
|
19
23
|
const CODEX_SESSION_ID_ALT_RE = /Session ID:\s*([0-9a-f-]+)/i;
|
|
20
24
|
|
|
25
|
+
/** @param {string} stdout */
|
|
21
26
|
function extractCodexSessionId(stdout) {
|
|
22
|
-
if (!stdout) return null;
|
|
27
|
+
if (!stdout) {return null;}
|
|
23
28
|
const m = CODEX_SESSION_ID_RE.exec(stdout);
|
|
24
|
-
if (m) return m[1];
|
|
29
|
+
if (m) {return m[1];}
|
|
25
30
|
const m2 = CODEX_SESSION_ID_ALT_RE.exec(stdout);
|
|
26
|
-
if (m2) return m2[1];
|
|
31
|
+
if (m2) {return m2[1];}
|
|
27
32
|
return null;
|
|
28
33
|
}
|
|
29
34
|
|
|
@@ -35,6 +40,9 @@ function hasLiveTty() {
|
|
|
35
40
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {{ prompt: string, worktree: string, interactive?: boolean, env?: {[key: string]: string}, resume?: boolean, sessionId?: string | null, model?: string | null }} opts
|
|
45
|
+
*/
|
|
38
46
|
function buildCodexDraftInvocation({ prompt, worktree, interactive = hasLiveTty(), env = {}, resume = false, sessionId = null, model = null }) {
|
|
39
47
|
if (resume) {
|
|
40
48
|
const args = ['exec', 'resume'];
|
|
@@ -43,7 +51,7 @@ function buildCodexDraftInvocation({ prompt, worktree, interactive = hasLiveTty(
|
|
|
43
51
|
} else {
|
|
44
52
|
args.push('--last');
|
|
45
53
|
}
|
|
46
|
-
if (model) args.push('-m', model);
|
|
54
|
+
if (model) {args.push('-m', model);}
|
|
47
55
|
args.push(prompt);
|
|
48
56
|
return {
|
|
49
57
|
command: resolveCodexCommand(),
|
|
@@ -77,6 +85,9 @@ function buildCodexDraftInvocation({ prompt, worktree, interactive = hasLiveTty(
|
|
|
77
85
|
};
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
/**
|
|
89
|
+
* @param {{ prompt: string, worktree: string, env?: {[key: string]: string}, resume?: boolean, sessionId?: string | null, model?: string | null, teeOptions?: object, slug?: string | null, role?: string | null }} opts
|
|
90
|
+
*/
|
|
80
91
|
function startCodexDraftAgent({ prompt, worktree, env = {}, resume = false, sessionId = null, model = null, teeOptions = {}, slug = null, role = null }) {
|
|
81
92
|
// The launcher always tees through spawnAndTee for limit-hit detection, which
|
|
82
93
|
// forces child stdio to ['inherit', 'pipe', 'pipe']. Codex's `--full-auto`
|
|
@@ -84,16 +95,22 @@ function startCodexDraftAgent({ prompt, worktree, env = {}, resume = false, sess
|
|
|
84
95
|
// `exec` path here regardless of whether the parent has a TTY.
|
|
85
96
|
ensureCodexHome(worktree);
|
|
86
97
|
|
|
98
|
+
/**
|
|
99
|
+
* @param {{stdout?: string, stderr?: string}} result
|
|
100
|
+
*/
|
|
87
101
|
function isStaleSessionResult(result) {
|
|
88
|
-
if (!result) return false;
|
|
102
|
+
if (!result) {return false;}
|
|
89
103
|
const stderr = result.stderr || '';
|
|
90
104
|
const stdout = result.stdout || '';
|
|
91
105
|
return (stderr.includes('Session not found') || stdout.includes('Session not found'));
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
/**
|
|
109
|
+
* @param {{stdout?: string, sessionId?: string, telemetry?: object, model?: string, provider?: string}} result
|
|
110
|
+
*/
|
|
94
111
|
function processResult(result) {
|
|
95
112
|
if (result && result.stdout) {
|
|
96
|
-
result.sessionId = extractCodexSessionId(result.stdout);
|
|
113
|
+
result.sessionId = extractCodexSessionId(result.stdout) || undefined;
|
|
97
114
|
}
|
|
98
115
|
if (result) {
|
|
99
116
|
// Real usage telemetry comes from the codex rollout JSONL written under the
|
|
@@ -102,8 +119,8 @@ function startCodexDraftAgent({ prompt, worktree, env = {}, resume = false, sess
|
|
|
102
119
|
const telemetry = extractCodexTelemetry(codexHomeRoot(worktree), { sinceMs: Date.now() });
|
|
103
120
|
if (telemetry) {
|
|
104
121
|
result.telemetry = telemetry;
|
|
105
|
-
if (telemetry.model) result.model = telemetry.model;
|
|
106
|
-
if (telemetry.provider) result.provider = telemetry.provider;
|
|
122
|
+
if (telemetry.model) {result.model = telemetry.model;}
|
|
123
|
+
if (telemetry.provider) {result.provider = telemetry.provider;}
|
|
107
124
|
}
|
|
108
125
|
} catch (_) {
|
|
109
126
|
// Telemetry is best-effort; never let it break the launch result.
|
|
@@ -112,12 +129,15 @@ function startCodexDraftAgent({ prompt, worktree, env = {}, resume = false, sess
|
|
|
112
129
|
return result;
|
|
113
130
|
}
|
|
114
131
|
|
|
132
|
+
/**
|
|
133
|
+
* @param {{command: string, args: string[], options?: object}} invocation
|
|
134
|
+
*/
|
|
115
135
|
function staleSessionHandler(invocation) {
|
|
116
136
|
return _spawnAndTee(invocation.command, invocation.args, { ...invocation.options, ...teeOptions })
|
|
117
|
-
.then(result => {
|
|
137
|
+
.then(/** @param {object} result */ (result) => {
|
|
118
138
|
if (isStaleSessionResult(result) && worktree && resume) {
|
|
119
139
|
try {
|
|
120
|
-
_sessions.clearSession(worktree, slug, role);
|
|
140
|
+
/** @type {any} */(_sessions).clearSession(worktree, slug, role);
|
|
121
141
|
} catch (_) { /* best-effort */ }
|
|
122
142
|
const freshInv = buildCodexDraftInvocation({ prompt, worktree, interactive: false, env, resume: false, sessionId: null, model });
|
|
123
143
|
return _spawnAndTee(freshInv.command, freshInv.args, { ...freshInv.options, ...teeOptions });
|
|
@@ -127,21 +147,23 @@ function startCodexDraftAgent({ prompt, worktree, env = {}, resume = false, sess
|
|
|
127
147
|
.then(processResult);
|
|
128
148
|
}
|
|
129
149
|
|
|
130
|
-
const startedMs = Date.now();
|
|
131
150
|
const invocation = buildCodexDraftInvocation({ prompt, worktree, interactive: false, env, resume, sessionId, model });
|
|
132
151
|
const resultPromise = staleSessionHandler(invocation);
|
|
133
152
|
|
|
134
153
|
return { invocation, resultPromise };
|
|
135
154
|
}
|
|
136
155
|
|
|
156
|
+
/** @param {string} worktree */
|
|
137
157
|
function codexHomeRoot(worktree) {
|
|
138
158
|
return path.join(worktree, '.workflow', 'codex-home');
|
|
139
159
|
}
|
|
140
160
|
|
|
161
|
+
/** @param {string} worktree */
|
|
141
162
|
function codexConfigPath(worktree) {
|
|
142
163
|
return path.join(codexHomeRoot(worktree), '.codex', 'config.toml');
|
|
143
164
|
}
|
|
144
165
|
|
|
166
|
+
/** @param {string} worktree */
|
|
145
167
|
function codexAuthPath(worktree) {
|
|
146
168
|
return path.join(codexHomeRoot(worktree), '.codex', 'auth.json');
|
|
147
169
|
}
|
|
@@ -150,6 +172,7 @@ function userCodexAuthPath() {
|
|
|
150
172
|
return path.join(os.homedir(), '.codex', 'auth.json');
|
|
151
173
|
}
|
|
152
174
|
|
|
175
|
+
/** @param {*} value */
|
|
153
176
|
function tomlString(value) {
|
|
154
177
|
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
155
178
|
}
|
|
@@ -157,6 +180,7 @@ function tomlString(value) {
|
|
|
157
180
|
// Graphify's Codex skill requires multi_agent = true for spawn_agent subagent
|
|
158
181
|
// dispatch (skill-codex.md line 233). Without this, the copied Graphify skill
|
|
159
182
|
// cannot launch semantic extraction subagents and parallel graph building fails.
|
|
183
|
+
/** @param {string} worktree */
|
|
160
184
|
function headlessCodexConfig(worktree) {
|
|
161
185
|
const worktreeParent = path.dirname(path.resolve(worktree));
|
|
162
186
|
return [
|
|
@@ -176,6 +200,7 @@ function headlessCodexConfig(worktree) {
|
|
|
176
200
|
].join('\n');
|
|
177
201
|
}
|
|
178
202
|
|
|
203
|
+
/** @param {string} worktree */
|
|
179
204
|
function ensureCodexHome(worktree) {
|
|
180
205
|
fs.mkdirSync(codexHomeRoot(worktree), { recursive: true });
|
|
181
206
|
fs.mkdirSync(path.dirname(codexConfigPath(worktree)), { recursive: true });
|