dual-brain 3.1.0 → 3.3.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.
@@ -60,8 +60,33 @@ function findCodex() {
60
60
  // Prompt builder
61
61
  // ---------------------------------------------------------------------------
62
62
 
63
- function buildGptPrompt({ question, context, files }) {
63
+ function buildGptPrompt({ question, context, files, round, claudePerspective }) {
64
+ if (round === 2 && claudePerspective) {
65
+ return `You are GPT-5.5 in a collaborative architectural discussion with Claude (Opus).
66
+ You gave your initial analysis on a question. Claude has now provided its independent perspective.
67
+ This is a professional dialogue — two experts refining a decision together.
68
+
69
+ Original question: ${question}
70
+ ${context ? `\nContext: ${context}` : ''}
71
+
72
+ Claude's perspective:
73
+ ${claudePerspective}
74
+
75
+ Now respond as a colleague, not a critic. Structure your response:
76
+ 1. AGREEMENTS: Where Claude's analysis strengthens or confirms your thinking
77
+ 2. PUSHBACK: Where you disagree — be specific about WHY with evidence or reasoning
78
+ 3. NEW INSIGHTS: Anything Claude's perspective surfaced that you missed
79
+ 4. REFINED RECOMMENDATION: Your updated recommendation incorporating both perspectives
80
+ 5. REMAINING CONCERNS: Open questions neither of you fully resolved
81
+ 6. CONFIDENCE DELTA: Has your confidence changed? Why?
82
+
83
+ Be direct and substantive. If Claude is right about something you got wrong, say so.
84
+ If you still disagree after considering their points, explain what specific evidence would change your mind.`;
85
+ }
86
+
64
87
  return `You are GPT-5.5, providing an independent architectural perspective.
88
+ This is Round 1 of a dual-brain analysis — Claude (Opus) will independently analyze the same question,
89
+ then send you their perspective for a collaborative discussion in Round 2.
65
90
 
66
91
  Question: ${question}
67
92
  ${context ? `\nContext: ${context}` : ''}
@@ -165,7 +190,7 @@ function logUsage({ durationMs, usage, success }) {
165
190
  // Core exported function
166
191
  // ---------------------------------------------------------------------------
167
192
 
168
- export async function dualThink({ question, context, files } = {}) {
193
+ export async function dualThink({ question, context, files, round, claudePerspective } = {}) {
169
194
  if (!question) {
170
195
  return {
171
196
  gpt: null,
@@ -174,6 +199,8 @@ export async function dualThink({ question, context, files } = {}) {
174
199
  };
175
200
  }
176
201
 
202
+ const effectiveRound = (round === 2 && claudePerspective) ? 2 : 1;
203
+
177
204
  const codexBin = findCodex();
178
205
  if (!codexBin) {
179
206
  return {
@@ -183,7 +210,6 @@ export async function dualThink({ question, context, files } = {}) {
183
210
  };
184
211
  }
185
212
 
186
- // Check Codex auth before running
187
213
  try {
188
214
  execSync(`${codexBin} login status`, {
189
215
  encoding: 'utf8',
@@ -198,7 +224,7 @@ export async function dualThink({ question, context, files } = {}) {
198
224
  };
199
225
  }
200
226
 
201
- const prompt = buildGptPrompt({ question, context, files });
227
+ const prompt = buildGptPrompt({ question, context, files, round: effectiveRound, claudePerspective });
202
228
  const raw = runGptAnalysis(codexBin, prompt);
203
229
 
204
230
  logUsage({ durationMs: raw.durationMs, usage: raw.usage, success: raw.success });
@@ -207,18 +233,44 @@ export async function dualThink({ question, context, files } = {}) {
207
233
  return {
208
234
  gpt: null,
209
235
  error: raw.error || 'GPT analysis failed',
210
- fallback: 'Proceed with single-brain analysis on Claude Opus',
236
+ fallback: effectiveRound === 2
237
+ ? 'GPT rebuttal unavailable — synthesize from Round 1 analysis alone'
238
+ : 'Proceed with single-brain analysis on Claude Opus',
239
+ };
240
+ }
241
+
242
+ if (effectiveRound === 2) {
243
+ return {
244
+ round: 2,
245
+ gpt: {
246
+ rebuttal: raw.text,
247
+ model: MODEL,
248
+ durationMs: raw.durationMs,
249
+ tokens: raw.usage,
250
+ },
251
+ instructions: `GPT has responded to your analysis. Now synthesize both rounds into a FINAL DECISION:
252
+ 1. Where you both agree → high confidence, proceed
253
+ 2. Where GPT pushed back on your points → re-evaluate honestly
254
+ 3. Where you still disagree → state why and what evidence would resolve it
255
+ 4. Final recommendation with combined confidence level`,
256
+ question,
211
257
  };
212
258
  }
213
259
 
214
260
  return {
261
+ round: 1,
215
262
  gpt: {
216
263
  recommendation: raw.text,
217
264
  model: MODEL,
218
265
  durationMs: raw.durationMs,
219
266
  tokens: raw.usage,
220
267
  },
221
- instructions: 'Now provide YOUR independent analysis of the same question. Then compare both perspectives and make a final decision. If you disagree with GPT, explain why with evidence.',
268
+ instructions: `Round 1 complete. Now:
269
+ 1. Provide YOUR independent analysis of the same question (same structure: recommendation, rationale, alternatives, risks, confidence, verification)
270
+ 2. Then call Round 2 to send your perspective back to GPT:
271
+ node .claude/hooks/dual-brain-think.mjs --question "<same question>" --round 2 --claude-says "<your analysis summary>"
272
+ 3. GPT will respond to your specific points — agreements, pushback, and refined recommendation
273
+ 4. You then synthesize both rounds into the final decision`,
222
274
  question,
223
275
  context: context || null,
224
276
  };
@@ -268,32 +320,41 @@ function printResult(result, question) {
268
320
  const TOP = '╔══════════════════════════════════════════════════╗';
269
321
  const BOT = '╚══════════════════════════════════════════════════╝';
270
322
 
323
+ const roundLabel = result.round === 2 ? 'Round 2 — Rebuttal' : 'Round 1 — Initial';
324
+
271
325
  console.log(TOP);
272
- console.log('║ Dual-Brain Think ║');
326
+ console.log(`║ 🧠 Dual-Brain Think · ${roundLabel}`.padEnd(51) + '║');
273
327
  console.log(BAR);
274
- // Truncate question to fit the box
275
328
  const q = question.length > 44 ? question.slice(0, 41) + '...' : question;
276
329
  console.log(`║ Question: ${q.padEnd(38)} ║`);
277
330
  console.log(BAR);
278
331
 
279
332
  if (!result.gpt) {
280
- // Failure path
281
- console.log(`║ ERROR: ${(result.error || 'Unknown error').padEnd(41)} ║`);
333
+ console.log(`║ ${(result.error || 'Unknown error').padEnd(45)} ║`);
282
334
  console.log(BAR);
283
- console.log(`║ Fallback: ${(result.fallback || '').padEnd(39)} ║`);
335
+ console.log(`║ ↩️ ${(result.fallback || '').padEnd(45)} ║`);
284
336
  console.log(BOT);
285
337
  return;
286
338
  }
287
339
 
288
- const durSec = (result.gpt.durationMs / 1000).toFixed(1);
289
- console.log(`║ GPT-5.5 Perspective (${MODEL}, ${durSec}s):`.padEnd(51) + '║');
340
+ const gptData = result.gpt;
341
+ const durSec = (gptData.durationMs / 1000).toFixed(1);
342
+ console.log(`║ 🤖 GPT-5.5 (${durSec}s):`.padEnd(51) + '║');
290
343
  console.log(BAR);
291
344
  console.log('');
292
- console.log(result.gpt.recommendation);
345
+ console.log(gptData.recommendation || gptData.rebuttal);
293
346
  console.log('');
294
347
  console.log(BAR);
295
- console.log('║ Now: Provide YOUR analysis and compare. ║');
296
- console.log('║ If you disagree, explain why with evidence. ║');
348
+
349
+ if (result.round === 2) {
350
+ console.log('║ 🔄 Synthesize both rounds into final decision. ║');
351
+ console.log('║ Where you agree → high confidence. ║');
352
+ console.log('║ Where you disagree → state what would resolve it.║');
353
+ } else {
354
+ console.log('║ 📝 Your turn: analyze independently, then call ║');
355
+ console.log('║ Round 2 with --round 2 --claude-says "..." ║');
356
+ console.log('║ for GPT\'s rebuttal to your analysis. ║');
357
+ }
297
358
  console.log(BOT);
298
359
  }
299
360
 
@@ -306,7 +367,8 @@ if (import.meta.url === `file://${process.argv[1]}`) {
306
367
 
307
368
  if (!args.question) {
308
369
  console.error(
309
- 'Usage: node dual-brain-think.mjs --question "<question>" [--context "<context>"] [--files file1,file2]'
370
+ 'Usage: node dual-brain-think.mjs --question "<question>" [--context "<ctx>"] [--files f1,f2]\n' +
371
+ ' node dual-brain-think.mjs --question "<question>" --round 2 --claude-says "<analysis>"'
310
372
  );
311
373
  process.exit(1);
312
374
  }
@@ -315,6 +377,8 @@ if (import.meta.url === `file://${process.argv[1]}`) {
315
377
  question: args.question,
316
378
  context: args.context,
317
379
  files: args.files,
380
+ round: args.round ? parseInt(args.round, 10) : 1,
381
+ claudePerspective: args['claude-says'] || null,
318
382
  });
319
383
 
320
384
  printResult(result, args.question);
@@ -1,13 +1,27 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, appendFileSync } from 'fs';
2
+ import { readFileSync, writeFileSync, appendFileSync, renameSync } from 'fs';
3
3
  import { createHash } from 'crypto';
4
4
  import { dirname, resolve, join } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
 
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const CONFIG_FILE = resolve(__dirname, '..', 'orchestrator.json');
9
+ const PROFILE_FILE = resolve(__dirname, '..', 'dual-brain.profile.json');
9
10
  const DRIFT_STATE = resolve(__dirname, '.drift-warned');
10
11
 
12
+ function loadProfile() {
13
+ try {
14
+ const data = JSON.parse(readFileSync(PROFILE_FILE, 'utf8'));
15
+ return data.active || 'balanced';
16
+ } catch { return 'balanced'; }
17
+ }
18
+
19
+ const PROFILE_SETTINGS = {
20
+ balanced: { demote_think: false, promote_execute: false, bias: 0 },
21
+ 'cost-saver': { demote_think: true, promote_execute: false, bias: -20 },
22
+ 'quality-first': { demote_think: false, promote_execute: true, bias: 10 },
23
+ };
24
+
11
25
  function checkPricingDrift(config) {
12
26
  const verified = config.pricing_verified;
13
27
  if (!verified) return null;
@@ -29,9 +43,12 @@ function checkPricingDrift(config) {
29
43
  return `**[Drift Warning]** Pricing was last verified ${age} days ago. Run \`node .claude/hooks/setup-wizard.mjs\` to update.`;
30
44
  }
31
45
 
46
+ const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null;
47
+
32
48
  function logRecommendation(event) {
33
49
  const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
34
- const entry = JSON.stringify({
50
+ const profileName = event.profile || 'balanced';
51
+ const entryObj = {
35
52
  timestamp: new Date().toISOString(),
36
53
  type: 'tier_recommendation',
37
54
  detected_tier: event.tier,
@@ -39,13 +56,64 @@ function logRecommendation(event) {
39
56
  actual_model: event.actual,
40
57
  prompt_hash: event.promptHash,
41
58
  followed: event.followed,
42
- });
59
+ session_id: SESSION_ID,
60
+ profile: profileName,
61
+ };
62
+ const entry = JSON.stringify(entryObj);
43
63
  try {
44
64
  appendFileSync(logFile, entry + '\n');
45
65
  } catch {}
66
+
67
+ // Sync summary update (for dupe detection on next call)
68
+ try {
69
+ const today = new Date().toISOString().slice(0, 10);
70
+ const summaryFile = join(__dirname, `usage-summary-${today}.json`);
71
+ let summary;
72
+ try { summary = JSON.parse(readFileSync(summaryFile, 'utf8')); } catch { summary = { version: 1, recent_hashes: [] }; }
73
+ if (event.promptHash) {
74
+ summary.recent_hashes = summary.recent_hashes || [];
75
+ summary.recent_hashes.push({ hash: event.promptHash, ts: entryObj.timestamp });
76
+ const tenMinAgo = Date.now() - 10 * 60 * 1000;
77
+ summary.recent_hashes = summary.recent_hashes.filter(h => Date.parse(h.ts) >= tenMinAgo);
78
+ }
79
+ summary.updated_at = new Date().toISOString();
80
+ const tmp = summaryFile + '.tmp.' + process.pid;
81
+ writeFileSync(tmp, JSON.stringify(summary, null, 2) + '\n');
82
+ renameSync(tmp, summaryFile);
83
+ } catch {}
84
+
85
+ // Sync ledger write (append-only, fast)
86
+ try {
87
+ const ledgerEntry = JSON.stringify({
88
+ type: 'decision',
89
+ id: entryObj.timestamp.replace(/\W/g, '').slice(-12),
90
+ timestamp: entryObj.timestamp,
91
+ session_id: SESSION_ID,
92
+ profile: profileName,
93
+ tier: event.tier,
94
+ provider: detectProvider(event.actual),
95
+ model: event.actual || 'unknown',
96
+ recommended_model: event.recommended,
97
+ followed: event.followed,
98
+ prompt_hash: event.promptHash,
99
+ });
100
+ appendFileSync(join(__dirname, 'decision-ledger.jsonl'), ledgerEntry + '\n');
101
+ } catch {}
46
102
  }
47
103
 
48
104
  function checkDuplicate(promptHash) {
105
+ // Try summary checkpoint first (O(1))
106
+ try {
107
+ const summaryPath = join(__dirname, `usage-summary-${new Date().toISOString().slice(0, 10)}.json`);
108
+ const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
109
+ const tenMinAgo = Date.now() - 10 * 60 * 1000;
110
+ const match = (summary.recent_hashes || []).find(
111
+ h => h.hash === promptHash && Date.parse(h.ts) >= tenMinAgo
112
+ );
113
+ if (match) return { timestamp: match.ts, prompt_hash: promptHash };
114
+ } catch {}
115
+
116
+ // Fallback: scan log
49
117
  const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
50
118
  try {
51
119
  const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
@@ -73,27 +141,34 @@ function detectProvider(model) {
73
141
  }
74
142
 
75
143
  function quickPressureCheck(tier) {
144
+ // Try summary checkpoint first (O(1))
145
+ try {
146
+ const today = new Date().toISOString().slice(0, 10);
147
+ const summaryPath = join(__dirname, `usage-summary-${today}.json`);
148
+ const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
149
+ const cutoff = Date.now() - 5 * 60 * 60 * 1000;
150
+ const claudeTs = (summary.pressure?.claude?.[tier] || []).filter(t => Date.parse(t) >= cutoff);
151
+ const openaiTs = (summary.pressure?.openai?.[tier] || []).filter(t => Date.parse(t) >= cutoff);
152
+ return { claudeCalls: claudeTs.length, openaiCalls: openaiTs.length };
153
+ } catch {}
154
+
155
+ // Fallback: scan log
76
156
  try {
77
157
  const today = new Date().toISOString().slice(0, 10);
78
158
  const logFile = join(__dirname, `usage-${today}.jsonl`);
79
159
  const lines = readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
80
-
81
160
  const fiveHoursAgo = Date.now() - 5 * 60 * 60 * 1000;
82
161
  let claudeCalls = 0, openaiCalls = 0;
83
-
84
162
  for (const line of lines) {
85
163
  try {
86
164
  const entry = JSON.parse(line);
87
165
  if (Date.parse(entry.timestamp) < fiveHoursAgo) continue;
88
166
  if (entry.tier !== tier) continue;
89
-
90
- const provider = entry.provider ||
91
- (entry.model?.includes('gpt') ? 'openai' : 'claude');
167
+ const provider = entry.provider || (entry.model?.includes('gpt') ? 'openai' : 'claude');
92
168
  if (provider === 'claude') claudeCalls++;
93
169
  else openaiCalls++;
94
170
  } catch {}
95
171
  }
96
-
97
172
  return { claudeCalls, openaiCalls };
98
173
  } catch {
99
174
  return null;
@@ -162,6 +237,10 @@ try {
162
237
  return parts.join('\n\n');
163
238
  };
164
239
 
240
+ // Load profile early so all log entries can reference it
241
+ const profileName = loadProfile();
242
+ const profileSettings = PROFILE_SETTINGS[profileName] || PROFILE_SETTINGS.balanced;
243
+
165
244
  // Multi-tier detection — only when tier is not already resolved from subagent_defaults
166
245
  if (!tier) {
167
246
  const hasThink = THINK_WORDS.test(text);
@@ -186,6 +265,7 @@ try {
186
265
  actual: currentModel,
187
266
  promptHash,
188
267
  followed: false,
268
+ profile: profileName,
189
269
  });
190
270
  process.stdout.write(JSON.stringify({ systemMessage: fullMsg }));
191
271
  process.exit(0);
@@ -197,12 +277,21 @@ try {
197
277
  else tier = 'execute';
198
278
  }
199
279
 
280
+ // Apply profile-driven tier adjustments
281
+ if (profileSettings.demote_think && tier === 'think' && !THINK_WORDS.test(text)) {
282
+ tier = 'execute';
283
+ }
284
+ if (profileSettings.promote_execute && tier === 'execute' && THINK_WORDS.test(text)) {
285
+ tier = 'think';
286
+ }
287
+
200
288
  // Compute balance hint now that tier is resolved
201
289
  {
202
290
  const currentProvider = detectProvider(currentModel);
203
291
  if (currentProvider === 'claude') {
204
292
  const balance = quickPressureCheck(tier);
205
- if (balance && balance.claudeCalls > balance.openaiCalls * 2 && balance.claudeCalls > 10) {
293
+ const biasThreshold = profileSettings.bias >= 0 ? 10 : 20;
294
+ if (balance && balance.claudeCalls > balance.openaiCalls * 2 && balance.claudeCalls > biasThreshold) {
206
295
  const dispatchModel = tier === 'think' ? 'gpt-5.5' : tier === 'execute' ? 'gpt-5.4' : 'gpt-4.1-mini';
207
296
  balanceHint = `\n\n💡 **Balance tip:** Claude has ${balance.claudeCalls} ${tier} calls vs OpenAI's ${balance.openaiCalls} in the last 5hrs. Consider dispatching isolated work to GPT: \`node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --model ${dispatchModel}\``;
208
297
  }
@@ -221,6 +310,7 @@ try {
221
310
  actual: currentModel,
222
311
  promptHash,
223
312
  followed: true,
313
+ profile: profileName,
224
314
  });
225
315
  const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
226
316
  if (onlyWarnings) {
@@ -241,6 +331,7 @@ try {
241
331
  actual: currentModel,
242
332
  promptHash,
243
333
  followed: false,
334
+ profile: profileName,
244
335
  });
245
336
  process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
246
337
  } else {
@@ -251,6 +342,7 @@ try {
251
342
  actual: currentModel,
252
343
  promptHash,
253
344
  followed: true,
345
+ profile: profileName,
254
346
  });
255
347
  const onlyWarnings = [duplicateWarning, driftWarning, balanceHint].filter(Boolean).join('\n\n');
256
348
  if (onlyWarnings) {
@@ -271,6 +363,7 @@ try {
271
363
  actual: currentModel,
272
364
  promptHash,
273
365
  followed: false,
366
+ profile: profileName,
274
367
  });
275
368
  process.stdout.write(JSON.stringify({ systemMessage: prependWarnings(msg) }));
276
369
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { execSync, spawnSync } from 'child_process';
21
- import { appendFileSync } from 'fs';
21
+ import { appendFileSync, readFileSync } from 'fs';
22
22
  import { dirname, join } from 'path';
23
23
  import { fileURLToPath } from 'url';
24
24
 
@@ -117,10 +117,19 @@ function executeCodex(codexBin, model, prompt, cwd, timeoutMs) {
117
117
  .filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
118
118
  .map(m => m.item);
119
119
 
120
+ // Estimate startup time: time to first agent message or completed item
121
+ const firstItemTs = messages.find(m => m.type === 'item.completed')?.timestamp;
122
+ let startupMs = null;
123
+ if (firstItemTs) {
124
+ startupMs = Date.parse(firstItemTs) - startTime;
125
+ if (startupMs < 0 || startupMs > durationMs) startupMs = null;
126
+ }
127
+
120
128
  return {
121
129
  success: proc.status === 0 && errors.length === 0,
122
130
  summary: agentMessages.join('\n\n'),
123
131
  durationMs,
132
+ startupMs,
124
133
  model,
125
134
  usage: usage || null,
126
135
  errors: errors.map(e => e.message || e.error?.message || 'unknown'),
@@ -134,10 +143,18 @@ function executeCodex(codexBin, model, prompt, cwd, timeoutMs) {
134
143
  // Usage logger
135
144
  // ---------------------------------------------------------------------------
136
145
 
146
+ function loadActiveProfile() {
147
+ try {
148
+ return JSON.parse(readFileSync(join(__dirname, '..', 'dual-brain.profile.json'), 'utf8')).active || 'balanced';
149
+ } catch { return 'balanced'; }
150
+ }
151
+
152
+ const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() || null;
153
+
137
154
  function logUsageEvent(result, task) {
138
155
  const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
139
- const entry = JSON.stringify({
140
- schema_version: 2,
156
+ const entryObj = {
157
+ schema_version: 3,
141
158
  timestamp: new Date().toISOString(),
142
159
  provider: 'openai',
143
160
  tier: task.tier || 'execute',
@@ -145,14 +162,40 @@ function logUsageEvent(result, task) {
145
162
  model: result.model,
146
163
  status: result.success ? 'ok' : 'error',
147
164
  durationMs: result.durationMs,
165
+ codex_startup_ms: result.startupMs || null,
166
+ codex_total_ms: result.durationMs,
148
167
  input_tokens: result.usage?.input_tokens ?? null,
149
168
  output_tokens: result.usage?.output_tokens ?? null,
150
- session_id: process.env.CLAUDE_SESSION_ID || null,
169
+ session_id: SESSION_ID,
170
+ profile: result.profile || 'balanced',
151
171
  dispatcher: 'gpt-work-dispatcher',
152
- });
172
+ };
153
173
  try {
154
- appendFileSync(logFile, entry + '\n');
174
+ appendFileSync(logFile, JSON.stringify(entryObj) + '\n');
155
175
  } catch {}
176
+
177
+ // Update summary checkpoint with codex latency
178
+ import('./summary-checkpoint.mjs').then(({ updateSummary }) => {
179
+ updateSummary(entryObj);
180
+ }).catch(() => {});
181
+
182
+ // Record to decision ledger
183
+ import('./decision-ledger.mjs').then(({ recordDecision, recordOutcome }) => {
184
+ const id = recordDecision({
185
+ session_id: SESSION_ID,
186
+ profile: entryObj.profile,
187
+ tier: task.tier || 'execute',
188
+ provider: 'openai',
189
+ model: result.model,
190
+ });
191
+ recordOutcome(id, {
192
+ actual_duration_ms: result.durationMs,
193
+ codex_startup_ms: result.startupMs || null,
194
+ success: result.success,
195
+ actual_input_tokens: result.usage?.input_tokens || null,
196
+ actual_output_tokens: result.usage?.output_tokens || null,
197
+ });
198
+ }).catch(() => {});
156
199
  }
157
200
 
158
201
  // ---------------------------------------------------------------------------
@@ -171,6 +214,7 @@ export async function dispatchGptTask(task) {
171
214
  const model = task.model || 'gpt-5.4';
172
215
  const prompt = buildPrompt(task);
173
216
  const result = executeCodex(codexBin, model, prompt, task.cwd, task.timeoutMs);
217
+ result.profile = loadActiveProfile();
174
218
  logUsageEvent(result, task);
175
219
  return result;
176
220
  }