@magnusekdahl/parallix 1.0.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 +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- package/tools/setup-forgejo-docker.sh +84 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Claude telemetry is read from the Claude CLI's `--output-format stream-json`
|
|
4
|
+
// stdout stream (see claude.js `buildClaudeInvocation`). Unlike Codex — which
|
|
5
|
+
// writes a per-session rollout JSONL to disk — the Claude CLI emits its usage
|
|
6
|
+
// signal inline on stdout as SSE-style JSONL events, so we parse the captured
|
|
7
|
+
// stdout tail directly (no on-disk transcript to read).
|
|
8
|
+
//
|
|
9
|
+
// The CLI runs with `--include-partial-messages`, so the Anthropic SSE events
|
|
10
|
+
// are forwarded as JSONL. Two envelope shapes are handled:
|
|
11
|
+
//
|
|
12
|
+
// 1. Wrapped (real CLI form): each partial event is nested under a
|
|
13
|
+
// `stream_event` wrapper:
|
|
14
|
+
// {"type":"stream_event","event":{"type":"message_start","message":{"usage":{...}}}}
|
|
15
|
+
// {"type":"stream_event","event":{"type":"message_delta","usage":{"output_tokens":N}}}
|
|
16
|
+
// {"type":"stream_event","event":{"type":"content_block_start","content_block":{"type":"tool_use"}}}
|
|
17
|
+
// 2. Raw (bare SSE form): the Anthropic event sits at the top level:
|
|
18
|
+
// {"type":"message_start","message":{"usage":{...}}}
|
|
19
|
+
// {"type":"message_delta","usage":{"output_tokens":N}}
|
|
20
|
+
//
|
|
21
|
+
// The CLI also emits top-level `system` (init), `assistant`, and `result`
|
|
22
|
+
// events; `system`/`result` carry `session_id` and `model`, and `result`
|
|
23
|
+
// carries a final aggregate `usage` used as a fallback when partial events were
|
|
24
|
+
// truncated out of the captured stdout tail.
|
|
25
|
+
//
|
|
26
|
+
// Token semantics (Anthropic streaming):
|
|
27
|
+
// - `message_start.usage.input_tokens` is the prompt size for that turn. A
|
|
28
|
+
// multi-turn (tool-use) invocation re-sends the growing context each turn,
|
|
29
|
+
// so summing every turn's input would multi-count the shared prompt. We take
|
|
30
|
+
// the FIRST message_start's input_tokens as the representative prompt size.
|
|
31
|
+
// - `message_delta.usage.output_tokens` is the FINAL cumulative output count
|
|
32
|
+
// for that one message. Across turns these are independent generations, so
|
|
33
|
+
// the stage's total generated output is the SUM of each turn's final value.
|
|
34
|
+
// - `cache_read_input_tokens` / `cache_creation_input_tokens` appear on
|
|
35
|
+
// message_start.usage when prompt caching is active; summed across turns.
|
|
36
|
+
|
|
37
|
+
const PROVIDER = 'anthropic';
|
|
38
|
+
|
|
39
|
+
function num(value) {
|
|
40
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Unwrap a parsed JSONL line into its underlying Anthropic SSE event. The CLI's
|
|
45
|
+
* `--include-partial-messages` form nests the real event under `stream_event`;
|
|
46
|
+
* the bare form has it at the top level. Returns the inner event object.
|
|
47
|
+
*/
|
|
48
|
+
function unwrapEvent(evt) {
|
|
49
|
+
if (evt && evt.type === 'stream_event' && evt.event && typeof evt.event === 'object') {
|
|
50
|
+
return evt.event;
|
|
51
|
+
}
|
|
52
|
+
return evt;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse a Claude `stream-json` stdout string into a normalized structure of the
|
|
57
|
+
* usage-bearing events. Returns null when the content yields no usable signal
|
|
58
|
+
* (e.g. empty, garbage, or a stream with no usage events at all).
|
|
59
|
+
*
|
|
60
|
+
* Shape:
|
|
61
|
+
* {
|
|
62
|
+
* sessionId, model, provider: 'anthropic',
|
|
63
|
+
* messageStarts: [{ inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, model }],
|
|
64
|
+
* messageDeltas: [{ outputTokens }],
|
|
65
|
+
* toolCalls,
|
|
66
|
+
* resultUsage: { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens } | null
|
|
67
|
+
* }
|
|
68
|
+
*/
|
|
69
|
+
function parseClaudeStreamJson(content) {
|
|
70
|
+
if (!content) return null;
|
|
71
|
+
|
|
72
|
+
let sessionId = null;
|
|
73
|
+
let model = null;
|
|
74
|
+
let toolCalls = 0;
|
|
75
|
+
let resultUsage = null;
|
|
76
|
+
let resultCostUsd = null;
|
|
77
|
+
const messageStarts = [];
|
|
78
|
+
const messageDeltas = [];
|
|
79
|
+
|
|
80
|
+
for (const line of String(content).split('\n')) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (!trimmed.startsWith('{')) continue;
|
|
83
|
+
let outer;
|
|
84
|
+
try {
|
|
85
|
+
outer = JSON.parse(trimmed);
|
|
86
|
+
} catch (_) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!outer || typeof outer !== 'object') continue;
|
|
90
|
+
|
|
91
|
+
// Top-level CLI envelope fields (present on system/result and sometimes the
|
|
92
|
+
// wrapper) carry session/model metadata.
|
|
93
|
+
if (outer.session_id) sessionId = outer.session_id;
|
|
94
|
+
if (outer.type === 'system' && outer.model) model = outer.model;
|
|
95
|
+
|
|
96
|
+
// The final `result` event carries `total_cost_usd` (direct from the CLI)
|
|
97
|
+
// and an aggregate usage object (fallback when partial events were evicted).
|
|
98
|
+
if (outer.type === 'result') {
|
|
99
|
+
if (outer.usage && typeof outer.usage === 'object') {
|
|
100
|
+
resultUsage = {
|
|
101
|
+
inputTokens: num(outer.usage.input_tokens),
|
|
102
|
+
outputTokens: num(outer.usage.output_tokens),
|
|
103
|
+
cacheReadTokens: num(outer.usage.cache_read_input_tokens),
|
|
104
|
+
cacheCreationTokens: num(outer.usage.cache_creation_input_tokens),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (typeof outer.total_cost_usd === 'number' && Number.isFinite(outer.total_cost_usd)) {
|
|
108
|
+
resultCostUsd = outer.total_cost_usd;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const evt = unwrapEvent(outer);
|
|
113
|
+
if (!evt || typeof evt !== 'object') continue;
|
|
114
|
+
|
|
115
|
+
switch (evt.type) {
|
|
116
|
+
case 'message_start': {
|
|
117
|
+
const message = evt.message || {};
|
|
118
|
+
const usage = message.usage || {};
|
|
119
|
+
if (message.model) model = message.model;
|
|
120
|
+
messageStarts.push({
|
|
121
|
+
inputTokens: num(usage.input_tokens),
|
|
122
|
+
outputTokens: num(usage.output_tokens),
|
|
123
|
+
cacheReadTokens: num(usage.cache_read_input_tokens),
|
|
124
|
+
cacheCreationTokens: num(usage.cache_creation_input_tokens),
|
|
125
|
+
model: message.model || null,
|
|
126
|
+
});
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'message_delta': {
|
|
130
|
+
const usage = evt.usage || {};
|
|
131
|
+
messageDeltas.push({ outputTokens: num(usage.output_tokens) });
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'content_block_start': {
|
|
135
|
+
const block = evt.content_block || {};
|
|
136
|
+
if (block.type === 'tool_use') toolCalls += 1;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
default:
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const hasSignal = messageStarts.length > 0 || messageDeltas.length > 0 || resultUsage || model || sessionId;
|
|
145
|
+
if (!hasSignal) return null;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
sessionId,
|
|
149
|
+
model: model || null,
|
|
150
|
+
provider: PROVIDER,
|
|
151
|
+
messageStarts,
|
|
152
|
+
messageDeltas,
|
|
153
|
+
toolCalls,
|
|
154
|
+
resultUsage,
|
|
155
|
+
resultCostUsd,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Aggregate token counts from a Claude `stream-json` stdout stream into a
|
|
161
|
+
* telemetry object compatible with `stats.telemetryToStatsFields()`.
|
|
162
|
+
*
|
|
163
|
+
* Aggregation rules (see token-semantics note at top of file):
|
|
164
|
+
* - inputTokens = first message_start's input_tokens (representative prompt
|
|
165
|
+
* size; avoids multi-counting the re-sent context across
|
|
166
|
+
* tool-use turns).
|
|
167
|
+
* - outputTokens = sum of every message_delta's output_tokens (total
|
|
168
|
+
* generation across all turns).
|
|
169
|
+
* - cachedTokens = sum of message_start cache_read_input_tokens across turns.
|
|
170
|
+
* - totalTokens = inputTokens + outputTokens.
|
|
171
|
+
*
|
|
172
|
+
* When partial events are absent (e.g. truncated stdout tail) but a `result`
|
|
173
|
+
* event survived, its aggregate usage is used as a fallback.
|
|
174
|
+
*
|
|
175
|
+
* Returns null when the stream yields no usable token signal.
|
|
176
|
+
*/
|
|
177
|
+
function extractClaudeTelemetryFromStdout(stdout) {
|
|
178
|
+
const parsed = parseClaudeStreamJson(stdout);
|
|
179
|
+
if (!parsed) return null;
|
|
180
|
+
|
|
181
|
+
const { messageStarts, messageDeltas, resultUsage, resultCostUsd } = parsed;
|
|
182
|
+
|
|
183
|
+
let inputTokens = messageStarts.length > 0 ? messageStarts[0].inputTokens : 0;
|
|
184
|
+
let outputTokens = messageDeltas.reduce((sum, d) => sum + d.outputTokens, 0);
|
|
185
|
+
let cachedTokens = messageStarts.reduce((sum, s) => sum + s.cacheReadTokens, 0);
|
|
186
|
+
let cacheCreationTokens = messageStarts.reduce((sum, s) => sum + s.cacheCreationTokens, 0);
|
|
187
|
+
|
|
188
|
+
// If partial events were truncated out of the captured tail, fall back to the
|
|
189
|
+
// final `result` event's aggregate usage so we still record real numbers.
|
|
190
|
+
if (inputTokens === 0 && outputTokens === 0 && resultUsage) {
|
|
191
|
+
inputTokens = resultUsage.inputTokens;
|
|
192
|
+
outputTokens = resultUsage.outputTokens;
|
|
193
|
+
cachedTokens = resultUsage.cacheReadTokens;
|
|
194
|
+
cacheCreationTokens = resultUsage.cacheCreationTokens;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// NOTE: a small `inputTokens` (even 1) alongside a large `cachedTokens` is NOT
|
|
198
|
+
// an artifact — it is normal prompt caching. Anthropic streaming reports only
|
|
199
|
+
// the *uncached* prompt delta as `message_start.usage.input_tokens`; the bulk
|
|
200
|
+
// of the re-sent context is billed under `cache_read_input_tokens`. We keep
|
|
201
|
+
// the parsed value truthful and surface caching honestly in the stats render
|
|
202
|
+
// rather than rewriting telemetry here (see lib/commands/stats.js render).
|
|
203
|
+
|
|
204
|
+
// No token signal at all (only metadata events): not worth a telemetry row.
|
|
205
|
+
if (inputTokens === 0 && outputTokens === 0 && cachedTokens === 0 && cacheCreationTokens === 0) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
sessionId: parsed.sessionId,
|
|
211
|
+
provider: parsed.provider,
|
|
212
|
+
model: parsed.model,
|
|
213
|
+
effort: null,
|
|
214
|
+
inputTokens,
|
|
215
|
+
outputTokens,
|
|
216
|
+
cachedTokens,
|
|
217
|
+
cacheCreationTokens,
|
|
218
|
+
reasoningTokens: 0,
|
|
219
|
+
totalTokens: inputTokens + outputTokens,
|
|
220
|
+
contextWindow: 0,
|
|
221
|
+
toolCalls: parsed.toolCalls,
|
|
222
|
+
// Claude's CLI exposes no rate-limit percentage (unlike Codex); leave null
|
|
223
|
+
// so telemetryToStatsFields records openai_usage_after as 0.
|
|
224
|
+
usagePercent: null,
|
|
225
|
+
// total_cost_usd is emitted directly by the Claude CLI in the result event.
|
|
226
|
+
cost_usd: typeof resultCostUsd === 'number' ? resultCostUsd : 0,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
parseClaudeStreamJson,
|
|
232
|
+
extractClaudeTelemetryFromStdout,
|
|
233
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const { spawnAndTee } = require('../core/spawn-tee');
|
|
2
|
+
const { extractClaudeTelemetryFromStdout } = require('./claude-telemetry');
|
|
3
|
+
const sessions = require('../tools/sessions');
|
|
4
|
+
|
|
5
|
+
// Injectable I/O for tests. Production uses the real spawn-tee / export capture.
|
|
6
|
+
let _spawnAndTee = spawnAndTee;
|
|
7
|
+
let _sessions = sessions;
|
|
8
|
+
|
|
9
|
+
// Test hooks: override the launcher's I/O without touching the public signature.
|
|
10
|
+
function __setSpawnAndTeeForTest(fn) { _spawnAndTee = fn || spawnAndTee; }
|
|
11
|
+
function __setSessionsForTest(mod) { _sessions = mod || sessions; }
|
|
12
|
+
|
|
13
|
+
// Claude usage telemetry is parsed from the `stream-json` stdout stream, which
|
|
14
|
+
// carries `message_start` (input) and `message_delta` (output) usage events. The
|
|
15
|
+
// leading `message_start` (with input_tokens) sits at the very start of the
|
|
16
|
+
// stream, so the spawn-tee tail buffer must be large enough to retain it for a
|
|
17
|
+
// full mission-scale invocation rather than the 64 KiB limit-hit default.
|
|
18
|
+
const CLAUDE_TELEMETRY_TAIL_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
// Claude outputs "Resume this session with: claude --resume <id>" at the end.
|
|
21
|
+
const CLAUDE_SESSION_ID_RE = /claude\s+--resume\s+([0-9a-f-]+)/i;
|
|
22
|
+
|
|
23
|
+
function extractClaudeSessionIdFromStreamJson(stdout) {
|
|
24
|
+
if (!stdout) return null;
|
|
25
|
+
const lines = stdout.split('\n');
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (!trimmed.startsWith('{')) continue;
|
|
29
|
+
try {
|
|
30
|
+
const evt = JSON.parse(trimmed);
|
|
31
|
+
// stream-json 'result' events carry session_id at the top level.
|
|
32
|
+
if (evt.type === 'result' && evt.session_id) return evt.session_id;
|
|
33
|
+
} catch {
|
|
34
|
+
/* not JSON — skip */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractClaudeSessionId(stdout) {
|
|
41
|
+
if (!stdout) return null;
|
|
42
|
+
// Try stream-json result event first (new default format).
|
|
43
|
+
const fromStream = extractClaudeSessionIdFromStreamJson(stdout);
|
|
44
|
+
if (fromStream) return fromStream;
|
|
45
|
+
// Fall back to legacy plain-text regex.
|
|
46
|
+
const m = CLAUDE_SESSION_ID_RE.exec(stdout);
|
|
47
|
+
return m ? m[1] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveClaudeCommand() {
|
|
51
|
+
return 'claude';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildClaudeInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null }) {
|
|
55
|
+
const args = ['--dangerously-skip-permissions'];
|
|
56
|
+
if (model) args.push('--model', model);
|
|
57
|
+
// Only resume when the marker explicitly says so (resume=true).
|
|
58
|
+
// sessionId from a stale cross-family marker must be ignored when resume=false.
|
|
59
|
+
if (resume && sessionId) {
|
|
60
|
+
args.push('--resume', sessionId);
|
|
61
|
+
} else if (resume) {
|
|
62
|
+
args.push('--continue');
|
|
63
|
+
}
|
|
64
|
+
// Stream human-readable intermediate progress via stream-json events.
|
|
65
|
+
// The spawn-tee tee mechanism forwards these JSONL events to the terminal
|
|
66
|
+
// in real time so the operator sees tool calls and assistant text as they
|
|
67
|
+
// happen, rather than waiting for the final result.
|
|
68
|
+
// --include-partial-messages is required: without it, the assistant event
|
|
69
|
+
// contains the full response at once and no intermediate progress is emitted.
|
|
70
|
+
args.push('--output-format', 'stream-json', '--verbose', '--include-partial-messages');
|
|
71
|
+
args.push('-p', prompt);
|
|
72
|
+
return {
|
|
73
|
+
command: resolveClaudeCommand(),
|
|
74
|
+
args,
|
|
75
|
+
options: {
|
|
76
|
+
stdio: 'inherit',
|
|
77
|
+
cwd: worktree,
|
|
78
|
+
env: { ...process.env, ...env }
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function startClaudeAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {}, slug = null, role = null }) {
|
|
84
|
+
function isStaleSessionResult(result) {
|
|
85
|
+
if (!result) return false;
|
|
86
|
+
const stderr = result.stderr || '';
|
|
87
|
+
const stdout = result.stdout || '';
|
|
88
|
+
return (stderr.includes('Session not found') || stdout.includes('Session not found'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function processResult(result) {
|
|
92
|
+
if (result && result.stdout) {
|
|
93
|
+
result.sessionId = extractClaudeSessionId(result.stdout);
|
|
94
|
+
try {
|
|
95
|
+
const telemetry = extractClaudeTelemetryFromStdout(result.stdout);
|
|
96
|
+
if (telemetry) {
|
|
97
|
+
result.telemetry = telemetry;
|
|
98
|
+
if (telemetry.model) result.model = telemetry.model;
|
|
99
|
+
if (telemetry.provider) result.provider = telemetry.provider;
|
|
100
|
+
}
|
|
101
|
+
} catch (_) {
|
|
102
|
+
// Telemetry is best-effort; never let it break the launch result.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function staleSessionHandler(invocation) {
|
|
109
|
+
const teeWithTail = { maxTailBytes: CLAUDE_TELEMETRY_TAIL_BYTES, ...invocation.options, ...teeOptions };
|
|
110
|
+
return _spawnAndTee(invocation.command, invocation.args, teeWithTail)
|
|
111
|
+
.then(result => {
|
|
112
|
+
if (isStaleSessionResult(result) && worktree && resume) {
|
|
113
|
+
try {
|
|
114
|
+
_sessions.clearSession(worktree, slug, role);
|
|
115
|
+
} catch (_) { /* best-effort */ }
|
|
116
|
+
const freshInv = buildClaudeInvocation({ prompt, worktree, env, resume: false, sessionId: null, model });
|
|
117
|
+
const freshTee = { maxTailBytes: CLAUDE_TELEMETRY_TAIL_BYTES, ...freshInv.options, ...teeOptions };
|
|
118
|
+
return _spawnAndTee(freshInv.command, freshInv.args, freshTee);
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
})
|
|
122
|
+
.then(processResult);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const invocation = buildClaudeInvocation({ prompt, worktree, env, resume, sessionId, model });
|
|
126
|
+
const resultPromise = staleSessionHandler(invocation);
|
|
127
|
+
|
|
128
|
+
return { invocation, resultPromise };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
buildClaudeInvocation,
|
|
133
|
+
extractClaudeSessionId,
|
|
134
|
+
extractClaudeTelemetryFromStdout,
|
|
135
|
+
resolveClaudeCommand,
|
|
136
|
+
startClaudeAgent,
|
|
137
|
+
__setSpawnAndTeeForTest,
|
|
138
|
+
__setSessionsForTest
|
|
139
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Codex telemetry is read from the per-session rollout JSONL that the Codex CLI
|
|
4
|
+
// always writes under `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`.
|
|
5
|
+
// The headless launcher (codex.js) points `HOME` at `<worktree>/.workflow/codex-home`,
|
|
6
|
+
// so each mission worktree owns an isolated `…/.codex/sessions` tree. Reading the
|
|
7
|
+
// rollout — rather than parsing the `--json` stdout stream — keeps the human-readable
|
|
8
|
+
// transcript and session-resume behaviour intact while still yielding real usage data.
|
|
9
|
+
//
|
|
10
|
+
// Relevant rollout events (each line is one JSON object):
|
|
11
|
+
// {"type":"session_meta","payload":{"id","model_provider","model"?,...}}
|
|
12
|
+
// {"type":"turn_context","payload":{"model","effort",...}}
|
|
13
|
+
// {"type":"response_item","payload":{"type":"function_call",...}}
|
|
14
|
+
// {"type":"event_msg","payload":{"type":"token_count","info":{...},"rate_limits":{...}}}
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
function codexSessionsDir(codexHome) {
|
|
20
|
+
return path.join(codexHome, '.codex', 'sessions');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a rollout JSONL string into a telemetry object. Returns null when the
|
|
25
|
+
* content yields no usable signal (e.g. a failed turn with no token_count).
|
|
26
|
+
*/
|
|
27
|
+
function parseCodexRollout(content) {
|
|
28
|
+
if (!content) return null;
|
|
29
|
+
|
|
30
|
+
let sessionId = null;
|
|
31
|
+
let provider = null;
|
|
32
|
+
let model = null;
|
|
33
|
+
let effort = null;
|
|
34
|
+
let toolCalls = 0;
|
|
35
|
+
let lastUsage = null;
|
|
36
|
+
let contextWindow = 0;
|
|
37
|
+
let usagePercent = null;
|
|
38
|
+
|
|
39
|
+
for (const line of String(content).split('\n')) {
|
|
40
|
+
const trimmed = line.trim();
|
|
41
|
+
if (!trimmed.startsWith('{')) continue;
|
|
42
|
+
let evt;
|
|
43
|
+
try {
|
|
44
|
+
evt = JSON.parse(trimmed);
|
|
45
|
+
} catch (_) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const payload = evt && evt.payload;
|
|
49
|
+
if (!payload) continue;
|
|
50
|
+
|
|
51
|
+
switch (evt.type) {
|
|
52
|
+
case 'session_meta':
|
|
53
|
+
sessionId = payload.id || sessionId;
|
|
54
|
+
provider = payload.model_provider || provider;
|
|
55
|
+
if (payload.model) model = payload.model;
|
|
56
|
+
break;
|
|
57
|
+
case 'turn_context':
|
|
58
|
+
// 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
|
+
break;
|
|
62
|
+
case 'response_item':
|
|
63
|
+
if (payload.type === 'function_call') toolCalls += 1;
|
|
64
|
+
break;
|
|
65
|
+
case 'event_msg':
|
|
66
|
+
if (payload.type === 'token_count') {
|
|
67
|
+
if (payload.info && payload.info.total_token_usage) {
|
|
68
|
+
lastUsage = payload.info.total_token_usage;
|
|
69
|
+
if (payload.info.model_context_window) {
|
|
70
|
+
contextWindow = payload.info.model_context_window;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const pct = payload.rate_limits
|
|
74
|
+
&& payload.rate_limits.primary
|
|
75
|
+
&& payload.rate_limits.primary.used_percent;
|
|
76
|
+
if (typeof pct === 'number') usagePercent = pct;
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!lastUsage && !model && !sessionId) return null;
|
|
85
|
+
|
|
86
|
+
const usage = lastUsage || {};
|
|
87
|
+
return {
|
|
88
|
+
sessionId,
|
|
89
|
+
provider: provider || null,
|
|
90
|
+
model: model || null,
|
|
91
|
+
effort: effort || null,
|
|
92
|
+
inputTokens: usage.input_tokens || 0,
|
|
93
|
+
outputTokens: usage.output_tokens || 0,
|
|
94
|
+
cachedTokens: usage.cached_input_tokens || 0,
|
|
95
|
+
reasoningTokens: usage.reasoning_output_tokens || 0,
|
|
96
|
+
totalTokens: usage.total_tokens || 0,
|
|
97
|
+
contextWindow: contextWindow || 0,
|
|
98
|
+
toolCalls,
|
|
99
|
+
usagePercent,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Collect rollout files under a Codex sessions tree modified at or after
|
|
105
|
+
* `sinceMs`, sorted oldest-first by mtime. Each `codex exec` invocation —
|
|
106
|
+
* including `exec resume` — writes a NEW rollout file with a fresh
|
|
107
|
+
* `total_token_usage` counter, so a multi-round stage produces several files
|
|
108
|
+
* whose totals must be summed to get the stage's real quota consumption.
|
|
109
|
+
*/
|
|
110
|
+
function collectRolloutFiles(sessionsDir, { sinceMs = 0 } = {}) {
|
|
111
|
+
if (!sessionsDir || !fs.existsSync(sessionsDir)) return [];
|
|
112
|
+
|
|
113
|
+
const found = [];
|
|
114
|
+
const stack = [sessionsDir];
|
|
115
|
+
while (stack.length > 0) {
|
|
116
|
+
const dir = stack.pop();
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
120
|
+
} catch (_) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
const full = path.join(dir, entry.name);
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
stack.push(full);
|
|
127
|
+
} else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) {
|
|
128
|
+
let stat;
|
|
129
|
+
try {
|
|
130
|
+
stat = fs.statSync(full);
|
|
131
|
+
} catch (_) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const mtime = stat.mtimeMs;
|
|
135
|
+
if (mtime + 1000 < sinceMs) continue; // 1s grace for clock skew
|
|
136
|
+
found.push({ full, mtime });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
found.sort((a, b) => a.mtime - b.mtime);
|
|
141
|
+
return found.map(f => f.full);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Sum codex telemetry across every rollout written at or after `sinceMs` in
|
|
146
|
+
* `codexHome`. Token counts and tool calls are summed across all rollouts in the
|
|
147
|
+
* window (so all rounds of a resumed session are counted); model/effort/provider
|
|
148
|
+
* and the rate-limit usage snapshot are taken from the newest rollout. Returns
|
|
149
|
+
* null when no rollout/usable data is found.
|
|
150
|
+
*
|
|
151
|
+
* Idempotent by construction: it recomputes from the rollout files each call, so
|
|
152
|
+
* re-running a stage (e.g. after the workflow process is resumed mid-mission)
|
|
153
|
+
* yields the same total rather than double-counting.
|
|
154
|
+
*/
|
|
155
|
+
function extractCodexTelemetry(codexHome, { sinceMs = 0 } = {}) {
|
|
156
|
+
const files = collectRolloutFiles(codexSessionsDir(codexHome), { sinceMs });
|
|
157
|
+
if (files.length === 0) return null;
|
|
158
|
+
|
|
159
|
+
const agg = {
|
|
160
|
+
inputTokens: 0, outputTokens: 0, cachedTokens: 0,
|
|
161
|
+
reasoningTokens: 0, totalTokens: 0, toolCalls: 0,
|
|
162
|
+
};
|
|
163
|
+
let newest = null;
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
let content;
|
|
166
|
+
try {
|
|
167
|
+
content = fs.readFileSync(file, 'utf8');
|
|
168
|
+
} catch (_) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const t = parseCodexRollout(content);
|
|
172
|
+
if (!t) continue;
|
|
173
|
+
agg.inputTokens += t.inputTokens;
|
|
174
|
+
agg.outputTokens += t.outputTokens;
|
|
175
|
+
agg.cachedTokens += t.cachedTokens;
|
|
176
|
+
agg.reasoningTokens += t.reasoningTokens;
|
|
177
|
+
agg.totalTokens += t.totalTokens;
|
|
178
|
+
agg.toolCalls += t.toolCalls;
|
|
179
|
+
newest = t; // files are oldest-first, so the last assignment is the newest
|
|
180
|
+
newest.rolloutPath = file;
|
|
181
|
+
}
|
|
182
|
+
if (!newest) return null;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
sessionId: newest.sessionId,
|
|
186
|
+
provider: newest.provider,
|
|
187
|
+
model: newest.model,
|
|
188
|
+
effort: newest.effort,
|
|
189
|
+
contextWindow: newest.contextWindow,
|
|
190
|
+
usagePercent: newest.usagePercent,
|
|
191
|
+
rolloutPath: newest.rolloutPath,
|
|
192
|
+
rolloutCount: files.length,
|
|
193
|
+
...agg,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
codexSessionsDir,
|
|
199
|
+
parseCodexRollout,
|
|
200
|
+
collectRolloutFiles,
|
|
201
|
+
extractCodexTelemetry,
|
|
202
|
+
};
|