@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.
Files changed (68) hide show
  1. package/config/integration-pipelines.json +9 -0
  2. package/config/workflow.config.schema.json +12 -0
  3. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
  4. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
  5. package/docs/adr/0041-integration-pipeline-gates.md +1 -1
  6. package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
  7. package/docs/adr/index.md +1 -0
  8. package/docs/use-cases.md +44 -0
  9. package/lib/agents/agents.js +149 -100
  10. package/lib/agents/claude-telemetry.js +14 -10
  11. package/lib/agents/claude.js +32 -11
  12. package/lib/agents/codex-telemetry.js +32 -19
  13. package/lib/agents/codex.js +36 -11
  14. package/lib/agents/limit-hit.js +42 -25
  15. package/lib/agents/mistral-telemetry.js +1 -1
  16. package/lib/agents/mistral.js +13 -3
  17. package/lib/agents/opencode-export.js +11 -5
  18. package/lib/agents/opencode-telemetry.js +75 -90
  19. package/lib/agents/opencode.js +86 -29
  20. package/lib/agents/stage-telemetry.js +3 -7
  21. package/lib/commands/active.js +139 -94
  22. package/lib/commands/checkpoint.js +3 -1
  23. package/lib/commands/config.js +5 -3
  24. package/lib/commands/coverage-gate.js +17 -10
  25. package/lib/commands/diff.js +11 -5
  26. package/lib/commands/draft.js +95 -55
  27. package/lib/commands/handoff.js +253 -65
  28. package/lib/commands/integrate.js +288 -188
  29. package/lib/commands/mission-start.js +39 -34
  30. package/lib/commands/rebase.js +32 -21
  31. package/lib/commands/repair-handoff.js +21 -18
  32. package/lib/commands/resolve-conflict.js +5 -2
  33. package/lib/commands/review.js +1 -0
  34. package/lib/commands/setup-review.js +1 -0
  35. package/lib/commands/stats-backfill.js +50 -41
  36. package/lib/commands/stats.js +653 -204
  37. package/lib/commands/status.js +33 -28
  38. package/lib/core/fmt.js +58 -15
  39. package/lib/core/git.js +7 -5
  40. package/lib/core/gitignore.js +6 -4
  41. package/lib/core/mission-utils.js +161 -88
  42. package/lib/core/nels.js +199 -0
  43. package/lib/core/persistent-data-migration.js +55 -23
  44. package/lib/core/product-config.js +44 -23
  45. package/lib/core/runtime-matrix.js +15 -2
  46. package/lib/core/spawn-tee.js +40 -13
  47. package/lib/core/state-map.js +32 -13
  48. package/lib/core/storage.js +19 -5
  49. package/lib/core/subagent-limit.js +28 -0
  50. package/lib/core/verification.js +29 -12
  51. package/lib/review/rebase.js +11 -3
  52. package/lib/review/review-adapter.js +93 -6
  53. package/lib/review/review-artifacts.js +89 -23
  54. package/lib/review/review-commands.js +139 -45
  55. package/lib/review/review-events.js +103 -52
  56. package/lib/review/review-loop.js +117 -87
  57. package/lib/review/review-polling.js +25 -5
  58. package/lib/review/review-prompts.js +36 -5
  59. package/lib/review/review-state.js +39 -15
  60. package/lib/tools/backlog.js +162 -71
  61. package/lib/tools/forgejo.js +290 -129
  62. package/lib/tools/gatekeeper.js +15 -0
  63. package/lib/tools/redgreen.js +220 -0
  64. package/lib/tools/sessions.js +12 -5
  65. package/lib/tools/setup-review.js +170 -70
  66. package/package.json +11 -3
  67. package/prompts/draft.md +6 -0
  68. package/templates/mission-scaffold.md +1 -1
@@ -39,10 +39,12 @@ const PATTERN_SETS = Object.freeze({
39
39
  ]
40
40
  });
41
41
 
42
+ /** @param {string} agent */
42
43
  function getPatternsForAgent(agent) {
43
- return PATTERN_SETS[agent] || [];
44
+ return PATTERN_SETS[/** @type {keyof typeof PATTERN_SETS} */(agent)] || [];
44
45
  }
45
46
 
47
+ /** @param {string} agent @param {string} text */
46
48
  function findLimitHitMatch(agent, text) {
47
49
  const patterns = getPatternsForAgent(agent);
48
50
  for (const pattern of patterns) {
@@ -54,6 +56,7 @@ function findLimitHitMatch(agent, text) {
54
56
  return null;
55
57
  }
56
58
 
59
+ /** @param {string} text @param {number} index @param {number} length */
57
60
  function clipContext(text, index, length) {
58
61
  const before = Math.max(0, index - 200);
59
62
  const after = Math.min(text.length, index + length + 200);
@@ -67,18 +70,20 @@ const RELATIVE_PATTERN = /\bin\s+(\d+)\s+(hour|hours|minute|minutes|second|secon
67
70
  const RETRY_AFTER_PATTERN = /retry[- ]?after[:\s]+(\d+)\s*(seconds?|s|minutes?|m)?/i;
68
71
  const RETRY_AT_DATE_PATTERN = /(?:retry|reset|try again|reset(?:s)? at)[^\n]{0,40}?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i;
69
72
 
73
+ /** @param {string | undefined} value */
70
74
  function parseIsoOffset(value) {
71
- if (!value) return null;
72
- if (value === 'Z') return 0;
75
+ if (!value) {return null;}
76
+ if (value === 'Z') {return 0;}
73
77
  const m = /([+-])(\d{2}):?(\d{2})/.exec(value);
74
- if (!m) return null;
78
+ if (!m) {return null;}
75
79
  const sign = m[1] === '-' ? -1 : 1;
76
80
  return sign * (Number(m[2]) * 60 + Number(m[3]));
77
81
  }
78
82
 
83
+ /** @param {string} text */
79
84
  function parseIso(text) {
80
85
  const m = ISO_PATTERN.exec(text);
81
- if (!m) return null;
86
+ if (!m) {return null;}
82
87
  const [, year, month, day, hour, minute, second, offset] = m;
83
88
  const offsetMinutes = parseIsoOffset(offset);
84
89
  if (offsetMinutes === null) {
@@ -88,55 +93,61 @@ function parseIso(text) {
88
93
  return new Date(utcMs - offsetMinutes * 60 * 1000);
89
94
  }
90
95
 
96
+ /** @param {string} text @param {Date} now */
91
97
  function parseTwelveHour(text, now) {
92
98
  const m = TWELVE_HOUR_PATTERN.exec(text);
93
- if (!m) return null;
99
+ if (!m) {return null;}
94
100
  let hour = Number(m[1]);
95
101
  const minute = Number(m[2] || 0);
96
102
  const meridiem = m[3].toLowerCase();
97
- if (hour < 1 || hour > 12) return null;
98
- if (meridiem === 'pm' && hour !== 12) hour += 12;
99
- if (meridiem === 'am' && hour === 12) hour = 0;
103
+ if (hour < 1 || hour > 12) {return null;}
104
+ if (meridiem === 'pm' && hour !== 12) {hour += 12;}
105
+ if (meridiem === 'am' && hour === 12) {hour = 0;}
100
106
  return projectClockTime(now, hour, minute);
101
107
  }
102
108
 
109
+ /** @param {string} text @param {Date} now */
103
110
  function parseTwentyFourHour(text, now) {
104
111
  const m = TWENTY_FOUR_HOUR_PATTERN.exec(text);
105
- if (!m) return null;
112
+ if (!m) {return null;}
106
113
  const hour = Number(m[1]);
107
114
  const minute = Number(m[2]);
108
115
  return projectClockTime(now, hour, minute);
109
116
  }
110
117
 
118
+ /** @param {string} text @param {Date} now */
111
119
  function parseRelative(text, now) {
112
120
  const m = RELATIVE_PATTERN.exec(text);
113
- if (!m) return null;
121
+ if (!m) {return null;}
114
122
  const amount = Number(m[1]);
115
123
  const unit = m[2].toLowerCase();
116
124
  let ms;
117
- if (unit.startsWith('hour')) ms = amount * 60 * 60 * 1000;
118
- else if (unit.startsWith('minute')) ms = amount * 60 * 1000;
119
- else ms = amount * 1000;
125
+ if (unit.startsWith('hour')) {ms = amount * 60 * 60 * 1000;}
126
+ else if (unit.startsWith('minute')) {ms = amount * 60 * 1000;}
127
+ else {ms = amount * 1000;}
120
128
  return new Date(now.getTime() + ms);
121
129
  }
122
130
 
131
+ /** @param {string} text @param {Date} now */
123
132
  function parseRetryAfter(text, now) {
124
133
  const m = RETRY_AFTER_PATTERN.exec(text);
125
- if (!m) return null;
134
+ if (!m) {return null;}
126
135
  const amount = Number(m[1]);
127
136
  const unit = (m[2] || 's').toLowerCase();
128
137
  let ms;
129
- if (unit.startsWith('m') && unit !== 's') ms = amount * 60 * 1000;
130
- else ms = amount * 1000;
138
+ if (unit.startsWith('m') && unit !== 's') {ms = amount * 60 * 1000;}
139
+ else {ms = amount * 1000;}
131
140
  return new Date(now.getTime() + ms);
132
141
  }
133
142
 
143
+ /** @param {string} text */
134
144
  function parseRetryAtDate(text) {
135
145
  const m = RETRY_AT_DATE_PATTERN.exec(text);
136
- if (!m) return null;
146
+ if (!m) {return null;}
137
147
  return parseIso(m[1]);
138
148
  }
139
149
 
150
+ /** @param {Date} now @param {number} hour @param {number} minute */
140
151
  function projectClockTime(now, hour, minute) {
141
152
  const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
142
153
  if (candidate.getTime() <= now.getTime()) {
@@ -145,8 +156,9 @@ function projectClockTime(now, hour, minute) {
145
156
  return candidate;
146
157
  }
147
158
 
159
+ /** @param {string} text @param {Date} [now] */
148
160
  function parseResetTime(text, now = new Date()) {
149
- if (!text) return null;
161
+ if (!text) {return null;}
150
162
  return (
151
163
  parseRetryAtDate(text) ||
152
164
  parseIso(text) ||
@@ -157,6 +169,7 @@ function parseResetTime(text, now = new Date()) {
157
169
  );
158
170
  }
159
171
 
172
+ /** @param {Date} date */
160
173
  function formatBlockUntil(date) {
161
174
  const year = date.getFullYear();
162
175
  const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -165,6 +178,7 @@ function formatBlockUntil(date) {
165
178
  return `${year}-${month}-${day} ${hour}`;
166
179
  }
167
180
 
181
+ /** @param {Date} date */
168
182
  function ceilToNextHour(date) {
169
183
  const ceil = new Date(date.getTime());
170
184
  if (ceil.getMinutes() === 0 && ceil.getSeconds() === 0 && ceil.getMilliseconds() === 0) {
@@ -175,6 +189,9 @@ function ceilToNextHour(date) {
175
189
  return ceil;
176
190
  }
177
191
 
192
+ /**
193
+ * @param {{ agent: string, stdout?: string, stderr?: string, status?: number, signal?: string | null, error?: { code?: string } | null, now?: Date }} [opts]
194
+ */
178
195
  function detectLimitHit({
179
196
  agent,
180
197
  stdout = '',
@@ -183,8 +200,8 @@ function detectLimitHit({
183
200
  signal,
184
201
  error,
185
202
  now = new Date()
186
- } = {}) {
187
- if (!agent) return null;
203
+ } = /** @type {{ agent: string, stdout?: string, stderr?: string, status?: number, signal?: string | null, error?: { code?: string } | null, now?: Date }} */({})) {
204
+ if (!agent) {return null;}
188
205
  // Gate detection on a failed launcher invocation. A successful child (exit 0,
189
206
  // no signal, no spawn error) means any limit-hit phrases in the transcript
190
207
  // are quoted text — typically when the agent itself reviewed code, tests, or
@@ -193,11 +210,11 @@ function detectLimitHit({
193
210
  // `status === undefined` means the caller did not pass exit metadata; preserve
194
211
  // the legacy behavior so callers that haven't been updated still get detection.
195
212
  const launcherFailed =
196
- error != null ||
197
- signal != null ||
213
+ error !== null && error !== undefined ||
214
+ signal !== null && signal !== undefined ||
198
215
  (typeof status === 'number' && status !== 0) ||
199
216
  typeof status === 'undefined';
200
- if (!launcherFailed) return null;
217
+ if (!launcherFailed) {return null;}
201
218
 
202
219
  const combined = `${stdout || ''}\n${stderr || ''}`;
203
220
  const match = findLimitHitMatch(agent, combined);
@@ -216,7 +233,7 @@ function detectLimitHit({
216
233
  target = new Date(now.getTime() + DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000);
217
234
  source = 'fallback';
218
235
  }
219
- } else if (signal != null) {
236
+ } else if (signal !== null && signal !== undefined) {
220
237
  // Agent was killed by a signal (e.g. SIGINT/Ctrl-C) but no limit-hit
221
238
  // pattern matched. Apply a short-term block so the next invocation
222
239
  // selects a different agent instead of re-picking the same one.
@@ -23,7 +23,7 @@
23
23
  * @param {object} result - The launcher result object
24
24
  * @returns {null} Always null — mistral/vibe has no telemetry parser
25
25
  */
26
- function extractMistralTelemetry(result) {
26
+ function extractMistralTelemetry() {
27
27
  // Mistral Vibe does not emit token usage data. Honest zero.
28
28
  return null;
29
29
  }
@@ -12,9 +12,11 @@ const { spawnAndTee } = require('../core/spawn-tee');
12
12
  // Telemetry: mistral/vibe does not expose token-usage data. See mistral-telemetry.js
13
13
  // for the honest-zero stub. Stats hooks in active.js and review-loop.js call
14
14
  // recordStageStats which defaults to '0' for tokens when telemetry is null.
15
- const MISTRAL_SESSION_ID_RE = null;
16
15
 
16
+
17
+ /** @param {string} stdout */
17
18
  function extractMistralSessionId(stdout) {
19
+ void stdout;
18
20
  // Vibe does not currently emit a resume hint in programmatic mode.
19
21
  // Return null to indicate no resume capability via stdout parsing.
20
22
  return null;
@@ -24,7 +26,12 @@ function resolveMistralCommand() {
24
26
  return 'vibe';
25
27
  }
26
28
 
27
- function buildMistralInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null }) {
29
+ /**
30
+ * @param {{ prompt: string, worktree: string, env?: object, resume?: boolean, sessionId?: string | null, model?: string | null }} opts
31
+ */
32
+ function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model = null }) {
33
+ void resume;
34
+ void sessionId;
28
35
  const args = ['--prompt', prompt, '--trust', '--output', 'text'];
29
36
 
30
37
  // Vibe programmatic mode does not support --resume flag in the same way
@@ -48,9 +55,12 @@ function buildMistralInvocation({ prompt, worktree, env, resume = false, session
48
55
  };
49
56
  }
50
57
 
58
+ /**
59
+ * @param {{ prompt: string, worktree: string, env?: object, resume?: boolean, sessionId?: string | null, model?: string | null, teeOptions?: object }} opts
60
+ */
51
61
  function startMistralAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {} }) {
52
62
  const invocation = buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model });
53
- const resultPromise = spawnAndTee(invocation.command, invocation.args, { ...invocation.options, ...teeOptions }).then(result => {
63
+ const resultPromise = spawnAndTee(invocation.command, invocation.args, /** @type {any} */({ ...invocation.options, ...teeOptions })).then(result => {
54
64
  if (result && result.stdout) {
55
65
  result.sessionId = extractMistralSessionId(result.stdout);
56
66
  }
@@ -50,15 +50,19 @@ function captureOpencodeExport(sessionId, opts = {}) {
50
50
  }
51
51
 
52
52
  let settled = false;
53
+ /** @type {NodeJS.Timeout | null} */
53
54
  let timer = null;
54
55
  let size = 0;
56
+ /** @type {number | null} */
55
57
  let tmpFd = null;
58
+ /** @type {string | null} */
56
59
  let tmpPath = null;
57
60
 
61
+ /** @param {string | null} value */
58
62
  const finish = (value) => {
59
- if (settled) return;
63
+ if (settled) {return;}
60
64
  settled = true;
61
- if (timer) clearTimeout(timer);
65
+ if (timer) {clearTimeout(timer);}
62
66
  // Clean up temp file
63
67
  if (tmpFd !== null) {
64
68
  try { fs.closeSync(tmpFd); } catch (_) { /* already closed */ }
@@ -69,8 +73,9 @@ function captureOpencodeExport(sessionId, opts = {}) {
69
73
  resolve(value);
70
74
  };
71
75
 
76
+ /** @param {import('child_process').ChildProcess | undefined} child */
72
77
  const killChild = (child) => {
73
- try { child.kill('SIGKILL'); } catch (_) { /* already gone */ }
78
+ try { if (child) { child.kill('SIGKILL'); } } catch (_) { /* already gone */ }
74
79
  };
75
80
 
76
81
  // Create a temp file for stdout to avoid pipe-buffer truncation
@@ -117,8 +122,8 @@ function captureOpencodeExport(sessionId, opts = {}) {
117
122
 
118
123
  child.on('error', () => finish(null));
119
124
 
120
- child.on('close', (code) => {
121
- if (settled) return;
125
+ child.on('close', () => {
126
+ if (settled) {return;}
122
127
  if (tmpFd === null) {
123
128
  finish(null);
124
129
  return;
@@ -130,6 +135,7 @@ function captureOpencodeExport(sessionId, opts = {}) {
130
135
  tmpFd = null;
131
136
 
132
137
  let content;
138
+ if (!tmpPath) { finish(null); return; }
133
139
  try {
134
140
  content = fs.readFileSync(tmpPath, 'utf8');
135
141
  } catch (_) {
@@ -19,80 +19,75 @@
19
19
  const PROVIDER = 'opencode';
20
20
  const MODEL = 'custom';
21
21
 
22
- /**
23
- * Safely extract a finite number from a value, defaulting to 0.
24
- */
22
+ /** @param {*} value */
25
23
  function num(value) {
26
24
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
27
25
  }
28
26
 
29
27
  /**
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
28
+ * @param {{[key: string]: any}} obj
29
+ * @returns {{input_tokens?: number, output_tokens?: number, cached_input_tokens?: number, total_tokens?: number}|null}
35
30
  */
36
31
  function findTokenUsage(obj) {
37
- if (!obj || typeof obj !== 'object') return null;
32
+ if (!obj || typeof obj !== 'object') {return null;}
38
33
 
39
34
  // Direct token usage fields at top level
40
35
  if ('input_tokens' in obj || 'output_tokens' in obj) {
41
36
  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,
37
+ input_tokens: /** @type {number} */(obj.input_tokens),
38
+ output_tokens: /** @type {number} */(obj.output_tokens),
39
+ cached_input_tokens: /** @type {number} */(obj.cached_input_tokens || obj.cached_tokens || 0),
40
+ total_tokens: /** @type {number} */(obj.total_tokens || 0),
46
41
  };
47
42
  }
48
43
 
49
44
  // Nested under total_token_usage (Codex-compatible shape)
50
45
  if (obj.total_token_usage && typeof obj.total_token_usage === 'object') {
51
46
  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,
47
+ input_tokens: /** @type {number} */(obj.total_token_usage.input_tokens || 0),
48
+ output_tokens: /** @type {number} */(obj.total_token_usage.output_tokens || 0),
49
+ cached_input_tokens: /** @type {number} */(obj.total_token_usage.cached_input_tokens || obj.total_token_usage.cached_tokens || 0),
50
+ total_tokens: /** @type {number} */(obj.total_token_usage.total_tokens || 0),
56
51
  };
57
52
  }
58
53
 
59
54
  // Nested under token_usage
60
55
  if (obj.token_usage && typeof obj.token_usage === 'object') {
61
56
  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,
57
+ input_tokens: /** @type {number} */(obj.token_usage.input_tokens || 0),
58
+ output_tokens: /** @type {number} */(obj.token_usage.output_tokens || 0),
59
+ cached_input_tokens: /** @type {number} */(obj.token_usage.cached_input_tokens || obj.token_usage.cached_tokens || 0),
60
+ total_tokens: /** @type {number} */(obj.token_usage.total_tokens || 0),
66
61
  };
67
62
  }
68
63
 
69
64
  // Nested under usage
70
65
  if (obj.usage && typeof obj.usage === 'object') {
71
66
  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,
67
+ input_tokens: /** @type {number} */(obj.usage.input_tokens || 0),
68
+ output_tokens: /** @type {number} */(obj.usage.output_tokens || 0),
69
+ cached_input_tokens: /** @type {number} */(obj.usage.cached_input_tokens || obj.usage.cached_tokens || 0),
70
+ total_tokens: /** @type {number} */(obj.usage.total_tokens || 0),
76
71
  };
77
72
  }
78
73
 
79
74
  // 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) {
75
+ const metaOrMeta = obj.meta || obj.metadata;
76
+ if (metaOrMeta && typeof metaOrMeta === 'object') {
77
+ if ('input_tokens' in metaOrMeta || 'output_tokens' in metaOrMeta) {
83
78
  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,
79
+ input_tokens: /** @type {number} */(metaOrMeta.input_tokens || 0),
80
+ output_tokens: /** @type {number} */(metaOrMeta.output_tokens || 0),
81
+ cached_input_tokens: /** @type {number} */(metaOrMeta.cached_input_tokens || metaOrMeta.cached_tokens || 0),
82
+ total_tokens: /** @type {number} */(metaOrMeta.total_tokens || 0),
88
83
  };
89
84
  }
90
- if (meta.total_token_usage && typeof meta.total_token_usage === 'object') {
85
+ if (metaOrMeta.total_token_usage && typeof metaOrMeta.total_token_usage === 'object') {
91
86
  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,
87
+ input_tokens: /** @type {number} */(metaOrMeta.total_token_usage.input_tokens || 0),
88
+ output_tokens: /** @type {number} */(metaOrMeta.total_token_usage.output_tokens || 0),
89
+ cached_input_tokens: /** @type {number} */(metaOrMeta.total_token_usage.cached_input_tokens || metaOrMeta.total_token_usage.cached_tokens || 0),
90
+ total_tokens: /** @type {number} */(metaOrMeta.total_token_usage.total_tokens || 0),
96
91
  };
97
92
  }
98
93
  }
@@ -103,10 +98,10 @@ function findTokenUsage(obj) {
103
98
  const t = obj.info.tokens;
104
99
  if ('input' in t || 'output' in t) {
105
100
  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,
101
+ input_tokens: /** @type {number} */(t.input || 0),
102
+ output_tokens: /** @type {number} */(t.output || 0),
103
+ cached_input_tokens: (t.cache && typeof t.cache === 'object') ? /** @type {number} */(t.cache.read || 0) : 0,
104
+ total_tokens: /** @type {number} */(t.input + t.output || 0),
110
105
  };
111
106
  }
112
107
  }
@@ -117,8 +112,8 @@ function findTokenUsage(obj) {
117
112
  if (Array.isArray(val)) {
118
113
  for (const item of val) {
119
114
  if (item && typeof item === 'object') {
120
- const found = findTokenUsage(item);
121
- if (found) return found;
115
+ const found = findTokenUsage(/** @type {{[key: string]: any}} */(item));
116
+ if (found) {return found;}
122
117
  }
123
118
  }
124
119
  }
@@ -128,89 +123,83 @@ function findTokenUsage(obj) {
128
123
  }
129
124
 
130
125
  /**
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
126
+ * @param {{[key: string]: any}} parsed
127
+ * @returns {string|null}
135
128
  */
136
129
  function extractSessionId(parsed) {
137
- if (!parsed || typeof parsed !== 'object') return null;
130
+ if (!parsed || typeof parsed !== 'object') {return null;}
138
131
 
139
132
  // Direct session_id
140
- if (parsed.session_id) return String(parsed.session_id);
141
- if (parsed.sessionId) return String(parsed.sessionId);
133
+ if (parsed.session_id) {return String(parsed.session_id);}
134
+ if (parsed.sessionId) {return String(parsed.sessionId);}
142
135
 
143
136
  // Nested under info (opencode v2.x export format)
144
137
  if (parsed.info && typeof parsed.info === 'object') {
145
- if (parsed.info.id) return String(parsed.info.id);
138
+ if (parsed.info.id) {return String(parsed.info.id);}
146
139
  }
147
140
 
148
141
  // Nested in metadata/meta/session
149
142
  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);
143
+ if (parsed.metadata.session_id) {return String(parsed.metadata.session_id);}
144
+ if (parsed.metadata.sessionId) {return String(parsed.metadata.sessionId);}
152
145
  }
153
146
  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);
147
+ if (parsed.meta.session_id) {return String(parsed.meta.session_id);}
148
+ if (parsed.meta.sessionId) {return String(parsed.meta.sessionId);}
156
149
  }
157
150
  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);
151
+ if (parsed.session.id) {return String(parsed.session.id);}
152
+ if (parsed.session.session_id) {return String(parsed.session.session_id);}
160
153
  }
161
154
 
162
155
  return null;
163
156
  }
164
157
 
165
158
  /**
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
159
+ * @param {{[key: string]: any}} parsed
160
+ * @returns {string|null}
170
161
  */
171
162
  function extractModelName(parsed) {
172
- if (!parsed || typeof parsed !== 'object') return null;
163
+ if (!parsed || typeof parsed !== 'object') {return null;}
173
164
 
174
165
  // Direct model fields
175
- if (parsed.model) return String(parsed.model);
176
- if (parsed.model_name) return String(parsed.model_name);
166
+ if (parsed.model) {return String(parsed.model);}
167
+ if (parsed.model_name) {return String(parsed.model_name);}
177
168
 
178
169
  // Nested under info.model (opencode v2.x format)
179
170
  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);
171
+ if (parsed.info.model.id) {return String(parsed.info.model.id);}
172
+ if (parsed.info.model.name) {return String(parsed.info.model.name);}
182
173
  }
183
174
 
184
175
  // Nested in metadata/meta/session
185
176
  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);
177
+ if (parsed.metadata.model) {return String(parsed.metadata.model);}
178
+ if (parsed.metadata.model_name) {return String(parsed.metadata.model_name);}
188
179
  }
189
180
  if (parsed.meta && typeof parsed.meta === 'object') {
190
- if (parsed.meta.model) return String(parsed.meta.model);
181
+ if (parsed.meta.model) {return String(parsed.meta.model);}
191
182
  }
192
183
  if (parsed.session && typeof parsed.session === 'object') {
193
- if (parsed.session.model) return String(parsed.session.model);
184
+ if (parsed.session.model) {return String(parsed.session.model);}
194
185
  }
195
186
 
196
187
  return null;
197
188
  }
198
189
 
199
190
  /**
200
- * Count tool calls from parsed opencode export JSON.
201
- *
202
- * @param {object} parsed - Parsed JSON object
203
- * @returns {number} Tool call count
191
+ * @param {{[key: string]: any}} parsed
192
+ * @returns {number}
204
193
  */
205
194
  function countToolCalls(parsed) {
206
- if (!parsed || typeof parsed !== 'object') return 0;
195
+ if (!parsed || typeof parsed !== 'object') {return 0;}
207
196
 
208
197
  let count = 0;
209
198
 
210
199
  // 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;
200
+ if (typeof parsed.tool_calls === 'number') {count += parsed.tool_calls;}
201
+ if (typeof parsed.toolCalls === 'number') {count += parsed.toolCalls;}
202
+ if (typeof parsed.usage?.tool_calls === 'number') {count += parsed.usage.tool_calls;}
214
203
 
215
204
  // Real opencode v2.x export schema: tool calls live in
216
205
  // messages[].parts[] where part.type === 'tool'.
@@ -218,7 +207,7 @@ function countToolCalls(parsed) {
218
207
  for (const msg of parsed.messages) {
219
208
  if (msg && typeof msg === 'object' && Array.isArray(msg.parts)) {
220
209
  for (const part of msg.parts) {
221
- if (part && typeof part === 'object' && part.type === 'tool') count += 1;
210
+ if (part && typeof part === 'object' && part.type === 'tool') {count += 1;}
222
211
  }
223
212
  }
224
213
  }
@@ -228,13 +217,13 @@ function countToolCalls(parsed) {
228
217
  if (Array.isArray(parsed.events)) {
229
218
  for (const evt of parsed.events) {
230
219
  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;
220
+ if (evt.type === 'tool_use') {count += 1;}
221
+ if (evt.type === 'tool_call') {count += 1;}
222
+ if (evt.type === 'function_call') {count += 1;}
223
+ if (Array.isArray(evt.tool_calls)) {count += evt.tool_calls.length;}
235
224
  if (Array.isArray(evt.content_block_start)) {
236
225
  for (const cb of evt.content_block_start) {
237
- if (cb && cb.type === 'tool_use') count += 1;
226
+ if (cb && cb.type === 'tool_use') {count += 1;}
238
227
  }
239
228
  }
240
229
  }
@@ -329,12 +318,8 @@ function extractOpencodeTelemetryFromExport(jsonString, fallbackModel) {
329
318
  }
330
319
 
331
320
  /**
332
- * Attempt to extract telemetry from an opencode launcher result.
333
- * Deprecated — use extractOpencodeTelemetryFromExport directly.
334
- * Kept for backward compatibility with existing call sites.
335
- *
336
- * @param {object} result - The launcher result object
337
- * @returns {object|null} Telemetry object or null
321
+ * @param {{exportJson?: string, telemetry?: object}} result
322
+ * @returns {object|null}
338
323
  */
339
324
  function extractOpencodeTelemetry(result) {
340
325
  if (result && result.exportJson && typeof result.exportJson === 'string') {