@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,356 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Opencode Telemetry Parser
|
|
5
|
+
*
|
|
6
|
+
* Parses `opencode export` JSON output to extract real token-usage data for
|
|
7
|
+
* Qwen sessions. The exported JSON may contain token usage in various shapes
|
|
8
|
+
* depending on the opencode version, so this parser is resilient to:
|
|
9
|
+
* - Missing token fields (substitutes 0)
|
|
10
|
+
* - Nested vs flat structures
|
|
11
|
+
* - Different field name conventions
|
|
12
|
+
*
|
|
13
|
+
* Provider and model fields are set to "opencode" and "qwen" respectively,
|
|
14
|
+
* matching the convention used by other telemetry modules.
|
|
15
|
+
*
|
|
16
|
+
* See task-1285 for the full telemetry credibility design.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PROVIDER = 'opencode';
|
|
20
|
+
const MODEL = 'qwen';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Safely extract a finite number from a value, defaulting to 0.
|
|
24
|
+
*/
|
|
25
|
+
function num(value) {
|
|
26
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Try to find token usage data anywhere in a parsed JSON object.
|
|
31
|
+
* Searches common nesting paths used by opencode export JSON.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} obj - Parsed JSON object
|
|
34
|
+
* @returns {object|null} Normalized token usage object or null
|
|
35
|
+
*/
|
|
36
|
+
function findTokenUsage(obj) {
|
|
37
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
38
|
+
|
|
39
|
+
// Direct token usage fields at top level
|
|
40
|
+
if ('input_tokens' in obj || 'output_tokens' in obj) {
|
|
41
|
+
return {
|
|
42
|
+
input_tokens: obj.input_tokens,
|
|
43
|
+
output_tokens: obj.output_tokens,
|
|
44
|
+
cached_input_tokens: obj.cached_input_tokens || obj.cached_tokens || 0,
|
|
45
|
+
total_tokens: obj.total_tokens || 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Nested under total_token_usage (Codex-compatible shape)
|
|
50
|
+
if (obj.total_token_usage && typeof obj.total_token_usage === 'object') {
|
|
51
|
+
return {
|
|
52
|
+
input_tokens: obj.total_token_usage.input_tokens || 0,
|
|
53
|
+
output_tokens: obj.total_token_usage.output_tokens || 0,
|
|
54
|
+
cached_input_tokens: obj.total_token_usage.cached_input_tokens || obj.total_token_usage.cached_tokens || 0,
|
|
55
|
+
total_tokens: obj.total_token_usage.total_tokens || 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Nested under token_usage
|
|
60
|
+
if (obj.token_usage && typeof obj.token_usage === 'object') {
|
|
61
|
+
return {
|
|
62
|
+
input_tokens: obj.token_usage.input_tokens || 0,
|
|
63
|
+
output_tokens: obj.token_usage.output_tokens || 0,
|
|
64
|
+
cached_input_tokens: obj.token_usage.cached_input_tokens || obj.token_usage.cached_tokens || 0,
|
|
65
|
+
total_tokens: obj.token_usage.total_tokens || 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Nested under usage
|
|
70
|
+
if (obj.usage && typeof obj.usage === 'object') {
|
|
71
|
+
return {
|
|
72
|
+
input_tokens: obj.usage.input_tokens || 0,
|
|
73
|
+
output_tokens: obj.usage.output_tokens || 0,
|
|
74
|
+
cached_input_tokens: obj.usage.cached_input_tokens || obj.usage.cached_tokens || 0,
|
|
75
|
+
total_tokens: obj.usage.total_tokens || 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Nested under meta or metadata
|
|
80
|
+
if ((obj.meta || obj.metadata) && typeof (obj.meta || obj.metadata) === 'object') {
|
|
81
|
+
const meta = obj.meta || obj.metadata;
|
|
82
|
+
if ('input_tokens' in meta || 'output_tokens' in meta) {
|
|
83
|
+
return {
|
|
84
|
+
input_tokens: meta.input_tokens || 0,
|
|
85
|
+
output_tokens: meta.output_tokens || 0,
|
|
86
|
+
cached_input_tokens: meta.cached_input_tokens || meta.cached_tokens || 0,
|
|
87
|
+
total_tokens: meta.total_tokens || 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (meta.total_token_usage && typeof meta.total_token_usage === 'object') {
|
|
91
|
+
return {
|
|
92
|
+
input_tokens: meta.total_token_usage.input_tokens || 0,
|
|
93
|
+
output_tokens: meta.total_token_usage.output_tokens || 0,
|
|
94
|
+
cached_input_tokens: meta.total_token_usage.cached_input_tokens || meta.total_token_usage.cached_tokens || 0,
|
|
95
|
+
total_tokens: meta.total_token_usage.total_tokens || 0,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Nested under info (opencode v2.x export format)
|
|
101
|
+
// Shape: { info: { tokens: { input, output, cache: { read, write }, reasoning } } }
|
|
102
|
+
if (obj.info && typeof obj.info === 'object' && obj.info.tokens && typeof obj.info.tokens === 'object') {
|
|
103
|
+
const t = obj.info.tokens;
|
|
104
|
+
if ('input' in t || 'output' in t) {
|
|
105
|
+
return {
|
|
106
|
+
input_tokens: t.input || 0,
|
|
107
|
+
output_tokens: t.output || 0,
|
|
108
|
+
cached_input_tokens: (t.cache && typeof t.cache === 'object') ? (t.cache.read || 0) : 0,
|
|
109
|
+
total_tokens: t.input + t.output || 0,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Search recursively through array elements (e.g., events array)
|
|
115
|
+
for (const key of Object.keys(obj)) {
|
|
116
|
+
const val = obj[key];
|
|
117
|
+
if (Array.isArray(val)) {
|
|
118
|
+
for (const item of val) {
|
|
119
|
+
if (item && typeof item === 'object') {
|
|
120
|
+
const found = findTokenUsage(item);
|
|
121
|
+
if (found) return found;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Extract a sessionId from parsed opencode export JSON.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} parsed - Parsed JSON object
|
|
134
|
+
* @returns {string|null} Session ID or null
|
|
135
|
+
*/
|
|
136
|
+
function extractSessionId(parsed) {
|
|
137
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
138
|
+
|
|
139
|
+
// Direct session_id
|
|
140
|
+
if (parsed.session_id) return String(parsed.session_id);
|
|
141
|
+
if (parsed.sessionId) return String(parsed.sessionId);
|
|
142
|
+
|
|
143
|
+
// Nested under info (opencode v2.x export format)
|
|
144
|
+
if (parsed.info && typeof parsed.info === 'object') {
|
|
145
|
+
if (parsed.info.id) return String(parsed.info.id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Nested in metadata/meta/session
|
|
149
|
+
if (parsed.metadata && typeof parsed.metadata === 'object') {
|
|
150
|
+
if (parsed.metadata.session_id) return String(parsed.metadata.session_id);
|
|
151
|
+
if (parsed.metadata.sessionId) return String(parsed.metadata.sessionId);
|
|
152
|
+
}
|
|
153
|
+
if (parsed.meta && typeof parsed.meta === 'object') {
|
|
154
|
+
if (parsed.meta.session_id) return String(parsed.meta.session_id);
|
|
155
|
+
if (parsed.meta.sessionId) return String(parsed.meta.sessionId);
|
|
156
|
+
}
|
|
157
|
+
if (parsed.session && typeof parsed.session === 'object') {
|
|
158
|
+
if (parsed.session.id) return String(parsed.session.id);
|
|
159
|
+
if (parsed.session.session_id) return String(parsed.session.session_id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Extract a model name from parsed opencode export JSON.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} parsed - Parsed JSON object
|
|
169
|
+
* @returns {string|null} Model name or null
|
|
170
|
+
*/
|
|
171
|
+
function extractModelName(parsed) {
|
|
172
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
173
|
+
|
|
174
|
+
// Direct model fields
|
|
175
|
+
if (parsed.model) return String(parsed.model);
|
|
176
|
+
if (parsed.model_name) return String(parsed.model_name);
|
|
177
|
+
|
|
178
|
+
// Nested under info.model (opencode v2.x format)
|
|
179
|
+
if (parsed.info && typeof parsed.info === 'object' && parsed.info.model && typeof parsed.info.model === 'object') {
|
|
180
|
+
if (parsed.info.model.id) return String(parsed.info.model.id);
|
|
181
|
+
if (parsed.info.model.name) return String(parsed.info.model.name);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Nested in metadata/meta/session
|
|
185
|
+
if (parsed.metadata && typeof parsed.metadata === 'object') {
|
|
186
|
+
if (parsed.metadata.model) return String(parsed.metadata.model);
|
|
187
|
+
if (parsed.metadata.model_name) return String(parsed.metadata.model_name);
|
|
188
|
+
}
|
|
189
|
+
if (parsed.meta && typeof parsed.meta === 'object') {
|
|
190
|
+
if (parsed.meta.model) return String(parsed.meta.model);
|
|
191
|
+
}
|
|
192
|
+
if (parsed.session && typeof parsed.session === 'object') {
|
|
193
|
+
if (parsed.session.model) return String(parsed.session.model);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Count tool calls from parsed opencode export JSON.
|
|
201
|
+
*
|
|
202
|
+
* @param {object} parsed - Parsed JSON object
|
|
203
|
+
* @returns {number} Tool call count
|
|
204
|
+
*/
|
|
205
|
+
function countToolCalls(parsed) {
|
|
206
|
+
if (!parsed || typeof parsed !== 'object') return 0;
|
|
207
|
+
|
|
208
|
+
let count = 0;
|
|
209
|
+
|
|
210
|
+
// Direct tool_calls field
|
|
211
|
+
if (typeof parsed.tool_calls === 'number') count += parsed.tool_calls;
|
|
212
|
+
if (typeof parsed.toolCalls === 'number') count += parsed.toolCalls;
|
|
213
|
+
if (typeof parsed.usage?.tool_calls === 'number') count += parsed.usage.tool_calls;
|
|
214
|
+
|
|
215
|
+
// Real opencode v2.x export schema: tool calls live in
|
|
216
|
+
// messages[].parts[] where part.type === 'tool'.
|
|
217
|
+
if (Array.isArray(parsed.messages)) {
|
|
218
|
+
for (const msg of parsed.messages) {
|
|
219
|
+
if (msg && typeof msg === 'object' && Array.isArray(msg.parts)) {
|
|
220
|
+
for (const part of msg.parts) {
|
|
221
|
+
if (part && typeof part === 'object' && part.type === 'tool') count += 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Nested arrays of tool calls
|
|
228
|
+
if (Array.isArray(parsed.events)) {
|
|
229
|
+
for (const evt of parsed.events) {
|
|
230
|
+
if (evt && typeof evt === 'object') {
|
|
231
|
+
if (evt.type === 'tool_use') count += 1;
|
|
232
|
+
if (evt.type === 'tool_call') count += 1;
|
|
233
|
+
if (evt.type === 'function_call') count += 1;
|
|
234
|
+
if (Array.isArray(evt.tool_calls)) count += evt.tool_calls.length;
|
|
235
|
+
if (Array.isArray(evt.content_block_start)) {
|
|
236
|
+
for (const cb of evt.content_block_start) {
|
|
237
|
+
if (cb && cb.type === 'tool_use') count += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (Array.isArray(parsed.tool_use_events)) {
|
|
245
|
+
count += parsed.tool_use_events.length;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return count;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Parse an `opencode export` JSON string and return a normalized telemetry
|
|
253
|
+
* object compatible with `stats.telemetryToStatsFields()`.
|
|
254
|
+
*
|
|
255
|
+
* Returns null when the content yields no usable token signal (empty string,
|
|
256
|
+
* non-JSON, or JSON without any token-usage fields).
|
|
257
|
+
*
|
|
258
|
+
* @param {string} jsonString - Raw JSON string from `opencode export`
|
|
259
|
+
* @returns {object|null} Normalized telemetry object or null
|
|
260
|
+
*/
|
|
261
|
+
function extractOpencodeTelemetryFromExport(jsonString) {
|
|
262
|
+
if (!jsonString || typeof jsonString !== 'string' || !jsonString.trim()) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let parsed;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(jsonString);
|
|
269
|
+
} catch (_) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const tokenUsage = findTokenUsage(parsed);
|
|
278
|
+
if (!tokenUsage) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Verify there is at least some non-zero token data
|
|
283
|
+
const inputTokens = num(tokenUsage.input_tokens);
|
|
284
|
+
const outputTokens = num(tokenUsage.output_tokens);
|
|
285
|
+
const cachedTokens = num(tokenUsage.cached_input_tokens);
|
|
286
|
+
|
|
287
|
+
// If all tokens are zero, treat as no signal (unless total_tokens explicitly exists and is non-zero)
|
|
288
|
+
if (inputTokens === 0 && outputTokens === 0 && cachedTokens === 0) {
|
|
289
|
+
if (num(tokenUsage.total_tokens) === 0) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
// total_tokens is set but individual fields are zero — treat total_tokens as the real signal
|
|
293
|
+
// and derive input/output from it conservatively
|
|
294
|
+
const totalTokens = num(tokenUsage.total_tokens);
|
|
295
|
+
return {
|
|
296
|
+
sessionId: extractSessionId(parsed),
|
|
297
|
+
provider: PROVIDER,
|
|
298
|
+
model: extractModelName(parsed) || MODEL,
|
|
299
|
+
inputTokens: 0,
|
|
300
|
+
outputTokens: 0,
|
|
301
|
+
cachedTokens: 0,
|
|
302
|
+
totalTokens,
|
|
303
|
+
toolCalls: countToolCalls(parsed),
|
|
304
|
+
usagePercent: null,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const totalTokens = num(tokenUsage.total_tokens) || (inputTokens + outputTokens);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
sessionId: extractSessionId(parsed),
|
|
312
|
+
provider: PROVIDER,
|
|
313
|
+
model: extractModelName(parsed) || MODEL,
|
|
314
|
+
inputTokens,
|
|
315
|
+
outputTokens,
|
|
316
|
+
cachedTokens,
|
|
317
|
+
totalTokens,
|
|
318
|
+
toolCalls: countToolCalls(parsed),
|
|
319
|
+
usagePercent: null,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Attempt to extract telemetry from an opencode launcher result.
|
|
325
|
+
* Deprecated — use extractOpencodeTelemetryFromExport directly.
|
|
326
|
+
* Kept for backward compatibility with existing call sites.
|
|
327
|
+
*
|
|
328
|
+
* @param {object} result - The launcher result object
|
|
329
|
+
* @returns {object|null} Telemetry object or null
|
|
330
|
+
*/
|
|
331
|
+
function extractOpencodeTelemetry(result) {
|
|
332
|
+
if (result && result.exportJson && typeof result.exportJson === 'string') {
|
|
333
|
+
return extractOpencodeTelemetryFromExport(result.exportJson);
|
|
334
|
+
}
|
|
335
|
+
if (result && result.telemetry) {
|
|
336
|
+
return result.telemetry;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Return the provider/model pair for opencode tasks.
|
|
343
|
+
* Used as fallback when telemetry is null.
|
|
344
|
+
*
|
|
345
|
+
* @returns {{provider: string, model: string}}
|
|
346
|
+
*/
|
|
347
|
+
function getOpencodeProviderModel() {
|
|
348
|
+
return { provider: PROVIDER, model: MODEL };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = {
|
|
352
|
+
extractOpencodeTelemetryFromExport,
|
|
353
|
+
extractOpencodeTelemetry,
|
|
354
|
+
getOpencodeProviderModel,
|
|
355
|
+
parseOpencodeExport: extractOpencodeTelemetryFromExport,
|
|
356
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
const { spawnAndTee } = require('../core/spawn-tee');
|
|
2
|
+
const { extractOpencodeTelemetryFromExport } = require('./opencode-telemetry');
|
|
3
|
+
const { captureOpencodeExport } = require('./opencode-export');
|
|
4
|
+
const sessions = require('../tools/sessions');
|
|
5
|
+
const { detectLimitHit } = require('./limit-hit');
|
|
6
|
+
|
|
7
|
+
// Injectable I/O for tests. Production uses the real spawn-tee / export capture.
|
|
8
|
+
let _spawnAndTee = spawnAndTee;
|
|
9
|
+
let _captureExport = captureOpencodeExport;
|
|
10
|
+
let _sessions = sessions;
|
|
11
|
+
|
|
12
|
+
// Test hooks: override the launcher's I/O without touching the public signature.
|
|
13
|
+
function __setSpawnAndTeeForTest(fn) { _spawnAndTee = fn || spawnAndTee; }
|
|
14
|
+
function __setExportCaptureForTest(fn) { _captureExport = fn || captureOpencodeExport; }
|
|
15
|
+
function __setSessionsForTest(mod) { _sessions = mod || sessions; }
|
|
16
|
+
|
|
17
|
+
// Opencode outputs "Continue opencode -s ses_<id>" at the end.
|
|
18
|
+
// Telemetry: after session completion, `opencode export` is invoked to extract
|
|
19
|
+
// token-usage data. See opencode-telemetry.js for the parser.
|
|
20
|
+
// Stats hooks in active.js and review-loop.js call recordStageStats which
|
|
21
|
+
// defaults to '0' for tokens when telemetry is null.
|
|
22
|
+
const OPENCODE_SESSION_ID_RE = /opencode\s+-s\s+(ses_\S+)/i;
|
|
23
|
+
|
|
24
|
+
function extractOpencodeSessionId(stdout) {
|
|
25
|
+
if (!stdout) return null;
|
|
26
|
+
const m = OPENCODE_SESSION_ID_RE.exec(stdout);
|
|
27
|
+
return m ? m[1] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveOpencodeCommand() {
|
|
31
|
+
return 'opencode';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildOpencodeInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null }) {
|
|
35
|
+
const args = ['run', '--pure', '--dangerously-skip-permissions'];
|
|
36
|
+
if (model) args.push('-m', model);
|
|
37
|
+
// Only resume when the marker explicitly says so (resume=true).
|
|
38
|
+
// sessionId from a stale cross-family marker must be ignored when resume=false.
|
|
39
|
+
if (resume && sessionId) {
|
|
40
|
+
args.push('-s', sessionId);
|
|
41
|
+
} else if (resume) {
|
|
42
|
+
args.push('--continue');
|
|
43
|
+
}
|
|
44
|
+
args.push(prompt);
|
|
45
|
+
return {
|
|
46
|
+
command: resolveOpencodeCommand(),
|
|
47
|
+
args,
|
|
48
|
+
options: {
|
|
49
|
+
stdio: 'inherit',
|
|
50
|
+
cwd: worktree,
|
|
51
|
+
env: { ...process.env, ...env }
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Transient qwen/vLLM backend failures that opencode surfaces as a non-zero
|
|
57
|
+
// exit. These are recoverable: the same prompt frequently succeeds on a second
|
|
58
|
+
// attempt, so the launcher retries once in-family before agents.js reroutes to a
|
|
59
|
+
// different agent family. Limit-hit phrases are intentionally NOT listed here —
|
|
60
|
+
// detectLimitHit() in agents.js owns that accounting (lib/agents/limit-hit.js).
|
|
61
|
+
// Hard errors (model-not-found, auth, ENOENT/EACCES) are never retried.
|
|
62
|
+
const TRANSIENT_OPENCODE_PATTERNS = [
|
|
63
|
+
/\bECONNRESET\b/i,
|
|
64
|
+
/\bECONNREFUSED\b/i,
|
|
65
|
+
/\bETIMEDOUT\b/i,
|
|
66
|
+
/\bENETUNREACH\b/i,
|
|
67
|
+
/\bENOTFOUND\b/i,
|
|
68
|
+
/\bEAI_AGAIN\b/i,
|
|
69
|
+
/\bEPIPE\b/i,
|
|
70
|
+
/socket hang ?up/i,
|
|
71
|
+
/\bfetch failed\b/i,
|
|
72
|
+
/network (?:error|timeout)/i,
|
|
73
|
+
/connection (?:error|reset|closed|refused|timed? ?out)/i,
|
|
74
|
+
/\b(?:502|503|504|529)\b/,
|
|
75
|
+
/\bbad gateway\b/i,
|
|
76
|
+
/\bservice (?:is )?unavailable\b/i,
|
|
77
|
+
/\bgateway time-?out\b/i,
|
|
78
|
+
/\b(?:server |api |model )?overloaded\b/i,
|
|
79
|
+
/\btemporarily unavailable\b/i,
|
|
80
|
+
/\bplease (?:try|retry) again\b/i,
|
|
81
|
+
/\bretry[- ]after\b/i,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// Hard, non-retryable failures: retrying cannot help and only wastes time/quota.
|
|
85
|
+
const HARD_OPENCODE_PATTERNS = [
|
|
86
|
+
/\bmodel not found\b/i,
|
|
87
|
+
/\bno such model\b/i,
|
|
88
|
+
/\bunknown model\b/i,
|
|
89
|
+
/\binvalid (?:api )?key\b/i,
|
|
90
|
+
/\b401\b[^\n]*\bunauthorized\b/i,
|
|
91
|
+
/\bauthentication (?:failed|error)\b/i,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function failureText(result) {
|
|
95
|
+
return `${(result && result.stdout) || ''}\n${(result && result.stderr) || ''}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isHardOpencodeFailure(result) {
|
|
99
|
+
if (!result) return false;
|
|
100
|
+
if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) return true;
|
|
101
|
+
const text = failureText(result);
|
|
102
|
+
return HARD_OPENCODE_PATTERNS.some((re) => re.test(text));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isTransientOpencodeFailure(result) {
|
|
106
|
+
if (!result) return false;
|
|
107
|
+
const text = failureText(result);
|
|
108
|
+
return TRANSIENT_OPENCODE_PATTERNS.some((re) => re.test(text));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Decide whether a failed opencode run should be retried once in-family.
|
|
112
|
+
// Only genuine non-zero exits carrying a transient backend signature qualify;
|
|
113
|
+
// clean exits, spawn errors (ENOENT/EACCES), killing signals, limit-hits, and
|
|
114
|
+
// hard errors never retry — so real failures still surface to the reroute loop.
|
|
115
|
+
function shouldRetryOpencodeFailure(result) {
|
|
116
|
+
if (!result) return false;
|
|
117
|
+
if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) return false;
|
|
118
|
+
if (result.signal) return false;
|
|
119
|
+
if (!(typeof result.status === 'number' && result.status !== 0)) return false;
|
|
120
|
+
if (detectLimitHit({
|
|
121
|
+
agent: 'qwen',
|
|
122
|
+
stdout: result.stdout,
|
|
123
|
+
stderr: result.stderr,
|
|
124
|
+
status: result.status,
|
|
125
|
+
signal: result.signal,
|
|
126
|
+
error: result.error,
|
|
127
|
+
})) return false;
|
|
128
|
+
if (isHardOpencodeFailure(result)) return false;
|
|
129
|
+
return isTransientOpencodeFailure(result);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function startOpencodeAgent({
|
|
133
|
+
prompt,
|
|
134
|
+
worktree,
|
|
135
|
+
env,
|
|
136
|
+
resume = false,
|
|
137
|
+
sessionId = null,
|
|
138
|
+
model = null,
|
|
139
|
+
teeOptions = {},
|
|
140
|
+
slug = null,
|
|
141
|
+
role = null,
|
|
142
|
+
maxTransientRetries = 1
|
|
143
|
+
}) {
|
|
144
|
+
function isStaleSessionResult(result) {
|
|
145
|
+
if (!result) return false;
|
|
146
|
+
const stderr = result.stderr || '';
|
|
147
|
+
const stdout = result.stdout || '';
|
|
148
|
+
return stderr.includes('Session not found') || stdout.includes('Session not found');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function runInvocation(invocation) {
|
|
152
|
+
const spawnOptions = { ...invocation.options, ...teeOptions };
|
|
153
|
+
return _spawnAndTee(invocation.command, invocation.args, spawnOptions);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function runInvocationWithRetry(invocation) {
|
|
157
|
+
let attempts = 0;
|
|
158
|
+
let result = await runInvocation(invocation);
|
|
159
|
+
while (attempts < maxTransientRetries && shouldRetryOpencodeFailure(result)) {
|
|
160
|
+
attempts += 1;
|
|
161
|
+
result = await runInvocation(invocation);
|
|
162
|
+
}
|
|
163
|
+
if (result) result.transientRetries = attempts;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function processResult(result) {
|
|
168
|
+
if (result && result.stdout) {
|
|
169
|
+
result.sessionId = extractOpencodeSessionId(result.stdout);
|
|
170
|
+
}
|
|
171
|
+
if (result && result.sessionId) {
|
|
172
|
+
try {
|
|
173
|
+
const exportJson = await _captureExport(result.sessionId, { worktree, env });
|
|
174
|
+
if (exportJson) {
|
|
175
|
+
const telemetry = extractOpencodeTelemetryFromExport(exportJson);
|
|
176
|
+
if (telemetry) {
|
|
177
|
+
result.telemetry = telemetry;
|
|
178
|
+
if (telemetry.model) result.model = telemetry.model;
|
|
179
|
+
if (telemetry.provider) result.provider = telemetry.provider;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch (_) {
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function staleSessionHandler(invocation) {
|
|
190
|
+
let result = await runInvocationWithRetry(invocation);
|
|
191
|
+
if (isStaleSessionResult(result) && worktree && resume) {
|
|
192
|
+
try {
|
|
193
|
+
_sessions.clearSession(worktree, slug, role);
|
|
194
|
+
} catch (_) { /* best-effort */ }
|
|
195
|
+
const freshInv = buildOpencodeInvocation({ prompt, worktree, env, resume: false, sessionId: null, model });
|
|
196
|
+
result = await runInvocationWithRetry(freshInv);
|
|
197
|
+
}
|
|
198
|
+
return processResult(result);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const invocation = buildOpencodeInvocation({ prompt, worktree, env, resume, sessionId, model });
|
|
202
|
+
const resultPromise = staleSessionHandler(invocation);
|
|
203
|
+
|
|
204
|
+
return { invocation, resultPromise };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
buildOpencodeInvocation,
|
|
209
|
+
extractOpencodeSessionId,
|
|
210
|
+
resolveOpencodeCommand,
|
|
211
|
+
startOpencodeAgent,
|
|
212
|
+
isTransientOpencodeFailure,
|
|
213
|
+
isHardOpencodeFailure,
|
|
214
|
+
shouldRetryOpencodeFailure,
|
|
215
|
+
__setSpawnAndTeeForTest,
|
|
216
|
+
__setExportCaptureForTest,
|
|
217
|
+
__setSessionsForTest
|
|
218
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared resolution of the most faithful telemetry for a recorded workflow
|
|
4
|
+
// stage (execute/review). Codex's authoritative usage is the windowed sum read
|
|
5
|
+
// from its on-disk rollout — `sinceMs` bounds the read to just this stage — so
|
|
6
|
+
// prefer it when present. For non-Codex agents (e.g. Claude, whose usage the
|
|
7
|
+
// launcher parses from stdout via claude-telemetry.js) no codex rollout exists
|
|
8
|
+
// and extractCodexTelemetry returns null *without throwing*, so we fall back to
|
|
9
|
+
// the telemetry the launcher already attached.
|
|
10
|
+
//
|
|
11
|
+
// This lives in one place so the execute hook (commands/active.js) and the
|
|
12
|
+
// review-loop hook (review/review-loop.js) cannot drift apart — the bug this
|
|
13
|
+
// fixes was active.js computing `sinceMs` and then discarding it, recording
|
|
14
|
+
// cumulative Codex usage for the execute phase instead of the windowed delta
|
|
15
|
+
// (task-1285 review F1/F6).
|
|
16
|
+
|
|
17
|
+
const { codexHomeRoot, extractCodexTelemetry } = require('./codex');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {string} opts.worktree - worktree root used to locate the codex home.
|
|
22
|
+
* @param {object} opts.result - launcher result; `result.telemetry` is the
|
|
23
|
+
* launcher-attached fallback and `result.startedAt` bounds the codex window.
|
|
24
|
+
* @param {number} [opts.sinceMs] - epoch ms marking the start of this stage.
|
|
25
|
+
* @returns {object|null} normalized telemetry, or null when none is available.
|
|
26
|
+
*/
|
|
27
|
+
function resolveStageTelemetry({ worktree, result, sinceMs = 0 } = {}) {
|
|
28
|
+
if (!result || !result.telemetry) return null;
|
|
29
|
+
try {
|
|
30
|
+
return extractCodexTelemetry(codexHomeRoot(worktree), { sinceMs: sinceMs || 0 }) || result.telemetry;
|
|
31
|
+
} catch (_) {
|
|
32
|
+
// Codex rollout unreadable: fall back to this stage's launcher telemetry.
|
|
33
|
+
return result.telemetry;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { resolveStageTelemetry };
|