amicus 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 (44) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +19 -0
  3. package/CHANGELOG.md +53 -0
  4. package/LICENSE +22 -1
  5. package/README.md +12 -2
  6. package/bin/amicus.js +13 -161
  7. package/package.json +6 -4
  8. package/scripts/postinstall.js +16 -7
  9. package/skills/second-opinion/COUNCIL-DESIGN.md +34 -34
  10. package/skills/second-opinion/MODEL-NOTES.md +23 -17
  11. package/skills/second-opinion/SKILL.md +77 -45
  12. package/src/cli-handlers-council.js +59 -0
  13. package/src/cli-handlers-doctor.js +173 -0
  14. package/src/cli-handlers-run.js +196 -0
  15. package/src/cli-handlers.js +1 -1
  16. package/src/cli.js +11 -2
  17. package/src/council/findings.js +48 -0
  18. package/src/council/ledger.js +82 -0
  19. package/src/council/tally.js +108 -0
  20. package/src/council/verdict.js +48 -0
  21. package/src/headless.js +43 -149
  22. package/src/mcp-server.js +6 -0
  23. package/src/sidecar/budget.js +83 -0
  24. package/src/sidecar/conversation-mirror.js +128 -0
  25. package/src/sidecar/fanout-leg.js +4 -1
  26. package/src/sidecar/fanout.js +34 -7
  27. package/src/sidecar/interactive-mirror.js +66 -0
  28. package/src/sidecar/interactive.js +35 -21
  29. package/src/sidecar/models.js +10 -9
  30. package/src/sidecar/session-finalize.js +26 -0
  31. package/src/sidecar/session-utils.js +5 -5
  32. package/src/sidecar/setup.js +2 -2
  33. package/src/sidecar/start.js +19 -6
  34. package/src/utils/activity-poller.js +47 -0
  35. package/src/utils/alias-resolver.js +1 -1
  36. package/src/utils/config.js +4 -4
  37. package/src/utils/error-doc.js +55 -0
  38. package/src/utils/lifecycle.js +1 -1
  39. package/src/utils/model-catalog.js +1 -1
  40. package/src/utils/pricing.js +93 -0
  41. package/src/utils/result-schema.js +21 -2
  42. package/src/utils/session-abort.js +40 -13
  43. package/src/utils/validators.js +17 -17
  44. /package/{skill → skills/sidecar}/SKILL.md +0 -0
package/src/headless.js CHANGED
@@ -12,6 +12,7 @@ const { ensureNodeModulesBinInPath } = require('./utils/path-setup');
12
12
  const { ensurePortAvailable } = require('./utils/server-setup');
13
13
  const { mapAgentToOpenCode } = require('./utils/agent-mapping');
14
14
  const { writeProgress } = require('./sidecar/progress');
15
+ const { createMirrorState, mirrorMessages, logMessage } = require('./sidecar/conversation-mirror');
15
16
 
16
17
  /**
17
18
  * Fold marker that the agent outputs when done
@@ -190,8 +191,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
190
191
  mode: 'headless',
191
192
  onTimeout: () => {
192
193
  logger.info('Headless idle timeout - shutting down', { taskId });
193
- server.close();
194
- process.exit(0);
194
+ const { idleBackstopTeardown } = require('./utils/session-abort');
195
+ process.exit(idleBackstopTeardown(sessionDir, server, externalServer));
195
196
  },
196
197
  }).start();
197
198
  }
@@ -244,7 +245,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
244
245
  abortSession(client, sessionId).catch(() => {});
245
246
  } catch { /* best-effort */ }
246
247
  try { server.close(); } catch { /* best-effort */ }
247
- const code = signal === 'SIGINT' ? 130 : 143;
248
+ const { resolveTerminalState } = require('./sidecar/session-finalize');
249
+ const code = resolveTerminalState({ aborted: true }, signal).exitCode;
248
250
  const t = setTimeout(() => process.exit(code), 300);
249
251
  if (t.unref) { t.unref(); }
250
252
  },
@@ -294,12 +296,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
294
296
  timeoutMs
295
297
  });
296
298
 
297
- let output = '';
299
+ const mirror = createMirrorState();
298
300
  let completed = false;
299
301
  let timedOut = false;
300
302
  let aborted = false;
301
303
  let sessionError = null; // Captures model/SDK errors from assistant messages
302
- const toolCalls = [];
303
304
 
304
305
  // Poll for completion by checking messages
305
306
  const startTime = Date.now();
@@ -317,11 +318,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
317
318
  let stablePolls = 0; // Count polls where nothing has changed
318
319
  let lastToolCallCount = 0;
319
320
  let lastToolResultCount = 0;
320
- const seenToolResultIds = new Set(); // deduplicate tool_result parts across polls
321
321
  let lastMessageCount = 0;
322
- let receivingReported = false; // Track whether 'receiving' stage was reported
323
- const seenTextParts = new Map(); // partId -> last captured text length
324
- // seenPartIds reserved for future use (tracking processed non-text parts)
325
322
 
326
323
  while (!completed && (Date.now() - startTime) < timeoutMs) {
327
324
  watchdog.touch();
@@ -360,134 +357,35 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
360
357
  consecutivePollFailures = 0;
361
358
  const messageCount = messages?.length || 0;
362
359
 
363
- // Find the last assistant message to check if it's complete
364
- let currentAssistantMsgId = null;
365
- let assistantFinished = false;
366
-
367
- if (messages && Array.isArray(messages)) {
368
- for (const msg of messages) {
369
- const role = msg.info?.role;
370
-
371
- // Track assistant message state
372
- if (role === 'assistant') {
373
- currentAssistantMsgId = msg.info.id;
374
- // Check for errors — capture for result propagation
375
- if (msg.info.error) {
376
- sessionError = msg.info.error.data?.message
377
- || msg.info.error.name
378
- || 'Unknown model error';
379
- logger.error('Session error detected in assistant message', {
380
- sessionId,
381
- error: msg.info.error.name,
382
- message: msg.info.error.data?.message
383
- });
384
- }
385
- }
386
-
387
- // Only process parts from assistant messages (skip user messages)
388
- if (role !== 'assistant' || !msg.parts) {
389
- continue;
390
- }
391
-
392
- for (const part of msg.parts) {
393
- const partId = part.id || `${msg.info.id}:${part.type}:${msg.parts.indexOf(part)}`;
394
-
395
- if (part.type === 'text' && part.text) {
396
- const prevLen = seenTextParts.get(partId) || 0;
397
- if (part.text.length > prevLen) {
398
- // Append only the new portion (handles streaming growth)
399
- const newText = part.text.slice(prevLen);
400
- output += newText;
401
- seenTextParts.set(partId, part.text.length);
402
- logMessage(conversationPath, {
403
- role: 'assistant',
404
- content: newText,
405
- timestamp: new Date().toISOString()
406
- });
407
-
408
- // Report 'receiving' stage on first text detection
409
- if (!receivingReported) {
410
- receivingReported = true;
411
- writeProgress(sessionDir, 'receiving', { messagesReceived: 1 });
412
- }
413
- }
414
- } else if ((part.type === 'tool_use' || part.type === 'tool') && !toolCalls.find(t => t.id === part.id)) {
415
- const toolCall = {
416
- id: part.id,
417
- name: part.name,
418
- input: part.input
419
- };
420
- toolCalls.push(toolCall);
421
- logger.debug('Tool call detected (polling)', {
422
- toolName: part.name,
423
- toolId: part.id,
424
- subagentType: part.input?.subagent_type,
425
- model: part.input?.model
426
- });
427
- logMessage(conversationPath, {
428
- role: 'assistant',
429
- type: 'tool_use',
430
- toolCall,
431
- timestamp: new Date().toISOString()
432
- });
433
-
434
- // Update progress on tool_use detection
435
- const toolLabel = part.name
436
- ? `Calling tool: ${part.name}`
437
- : 'Executing tool call...';
438
- writeProgress(sessionDir, 'receiving', {
439
- messagesReceived: toolCalls.length,
440
- latestTool: part.name || undefined,
441
- stageLabel: toolLabel
442
- });
443
- receivingReported = true;
444
- } else if (part.type === 'tool_result') {
445
- if (!seenToolResultIds.has(partId)) {
446
- seenToolResultIds.add(partId);
447
- }
448
- logger.debug('Tool result received (polling)', {
449
- toolUseId: part.tool_use_id,
450
- isError: part.is_error || false
451
- });
452
- logMessage(conversationPath, {
453
- role: 'tool',
454
- type: 'tool_result',
455
- toolUseId: part.tool_use_id,
456
- isError: part.is_error || false,
457
- content: part.content,
458
- timestamp: new Date().toISOString()
459
- });
460
- }
461
- }
462
- }
463
-
464
- // assistantFinished = true only when the LAST assistant message is complete
465
- // (earlier messages may finish while the model continues in new messages)
466
- const lastAssistant = messages
467
- .filter(m => m.info?.role === 'assistant')
468
- .pop();
469
- assistantFinished = !!(lastAssistant?.info?.time?.completed);
360
+ const mr = mirrorMessages(messages, mirror);
361
+ mr.appendLines.forEach(line => logMessage(conversationPath, line));
362
+ mr.progressUpdates.forEach(p => writeProgress(sessionDir, p.stage, p.extra));
363
+ const currentAssistantMsgId = mr.currentAssistantMsgId;
364
+ const assistantFinished = mr.assistantFinished;
365
+ if (mr.sessionError) {
366
+ sessionError = mr.sessionError;
367
+ logger.error('Session error detected in assistant message', { sessionId, message: mr.sessionError });
470
368
  }
471
369
 
472
370
  logger.debug('Poll status', {
473
371
  pollCount,
474
372
  messageCount,
475
373
  assistantFinished,
476
- outputLength: output.length,
374
+ outputLength: mirror.output.length,
477
375
  elapsed: Date.now() - startTime
478
376
  });
479
377
 
480
378
  // Check for completion marker on its own line (not inline in prose).
481
379
  // Models may mention [SIDECAR_FOLD] when describing code — only treat
482
380
  // it as a signal when it appears as a standalone line.
483
- if (/^\s*\[SIDECAR_FOLD\]\s*$/m.test(output)) {
381
+ if (/^\s*\[SIDECAR_FOLD\]\s*$/m.test(mirror.output)) {
484
382
  completed = true;
485
383
  break;
486
384
  }
487
385
 
488
386
  // If the model returned an error with no output, exit immediately
489
387
  // (don't wait for timeout — the model won't produce anything)
490
- if (sessionError && !output && assistantFinished) {
388
+ if (sessionError && !mirror.output && assistantFinished) {
491
389
  logger.error('Model returned error with no output, exiting', {
492
390
  sessionError, pollCount
493
391
  });
@@ -497,7 +395,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
497
395
  // Authoritative idle signal from the OpenCode SDK (preferred over the heuristic).
498
396
  // Gate on real output so a pre-processing 'idle' cannot end the run early.
499
397
  // Best-effort: on any error, fall back to the activity heuristic below.
500
- if (output.length > 0) {
398
+ if (mirror.output.length > 0) {
501
399
  try {
502
400
  const remainingForStatus = deadline - Date.now();
503
401
  const statusData = await withTimeout(
@@ -518,12 +416,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
518
416
  // Activity-aware idle detection: ANY of text growth, a new tool call, a new
519
417
  // tool result, a new message, or a new assistant message id counts as progress.
520
418
  // Only count toward completion when NOTHING changed (genuine idle).
521
- const outputGrew = output.length > lastOutputLength;
522
- lastOutputLength = output.length;
523
- const toolActivity = toolCalls.length > lastToolCallCount;
524
- lastToolCallCount = toolCalls.length;
525
- const resultActivity = seenToolResultIds.size > lastToolResultCount;
526
- lastToolResultCount = seenToolResultIds.size;
419
+ const outputGrew = mirror.output.length > lastOutputLength;
420
+ lastOutputLength = mirror.output.length;
421
+ const toolActivity = mirror.toolCalls.length > lastToolCallCount;
422
+ lastToolCallCount = mirror.toolCalls.length;
423
+ const resultActivity = mirror.seenToolResultIds.size > lastToolResultCount;
424
+ lastToolResultCount = mirror.seenToolResultIds.size;
527
425
  const messageActivity = messageCount > lastMessageCount;
528
426
  lastMessageCount = messageCount;
529
427
  const newAssistant = currentAssistantMsgId !== lastAssistantMsgId;
@@ -533,7 +431,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
533
431
  if (!progressed) {
534
432
  // Require real output before counting toward completion — the SDK creates an
535
433
  // empty assistant-message placeholder on promptAsync that is NOT a finished response.
536
- if (currentAssistantMsgId !== null && output.length > 0) {
434
+ if (currentAssistantMsgId !== null && mirror.output.length > 0) {
537
435
  stablePolls++;
538
436
  const threshold = assistantFinished ? stableFinishedPolls : stableIdlePolls;
539
437
  if (stablePolls >= threshold) {
@@ -542,7 +440,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
542
440
  }
543
441
  } else {
544
442
  logger.debug('Waiting for model to produce output', {
545
- pollCount, hasAssistantMsg: currentAssistantMsgId !== null, outputLength: output.length
443
+ pollCount, hasAssistantMsg: currentAssistantMsgId !== null, outputLength: mirror.output.length
546
444
  });
547
445
  }
548
446
  } else {
@@ -576,7 +474,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
576
474
  aborted,
577
475
  pollCount,
578
476
  stablePolls,
579
- outputLength: output.length,
477
+ outputLength: mirror.output.length,
580
478
  elapsed: Date.now() - startTime,
581
479
  hasAssistantMsg: lastAssistantMsgId !== null,
582
480
  sessionError: sessionError || null
@@ -602,11 +500,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
602
500
  if (!externalServer) { server.close(); }
603
501
 
604
502
  // Log summary of tool calls for debugging
605
- if (toolCalls.length > 0) {
503
+ if (mirror.toolCalls.length > 0) {
606
504
  logger.info('Tool calls summary', {
607
- totalToolCalls: toolCalls.length,
608
- taskToolCalls: toolCalls.filter(t => t.name === 'Task').length,
609
- subagentTypes: toolCalls
505
+ totalToolCalls: mirror.toolCalls.length,
506
+ taskToolCalls: mirror.toolCalls.filter(t => t.name === 'Task').length,
507
+ subagentTypes: mirror.toolCalls
610
508
  .filter(t => t.name === 'Task' && t.input?.subagent_type)
611
509
  .map(t => ({ type: t.input.subagent_type, model: t.input.model || 'inherited' }))
612
510
  });
@@ -616,25 +514,30 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
616
514
  // a model error alongside streamed output still yields a usable summary),
617
515
  // and ALWAYS when the poll loop bailed on consecutive failures (F4: a dead
618
516
  // server must never classify as a complete leg, even with partial output).
619
- if (sessionError && (!output || pollFailureBail)) {
517
+ const { sumPerMessageUsage } = require('./utils/pricing');
518
+ const usage = sumPerMessageUsage(mirror.usageByMsg);
519
+
520
+ if (sessionError && (!mirror.output || pollFailureBail)) {
620
521
  return {
621
- summary: output ? extractSummary(output) : '',
522
+ summary: mirror.output ? extractSummary(mirror.output) : '',
622
523
  completed: false,
623
524
  timedOut,
624
525
  aborted,
625
526
  taskId,
626
- toolCalls,
527
+ toolCalls: mirror.toolCalls,
528
+ usage,
627
529
  error: sessionError
628
530
  };
629
531
  }
630
532
 
631
533
  return {
632
- summary: extractSummary(output),
534
+ summary: extractSummary(mirror.output),
633
535
  completed,
634
536
  timedOut,
635
537
  aborted,
636
538
  taskId,
637
- toolCalls, // Include tool calls in result for verification
539
+ toolCalls: mirror.toolCalls, // Include tool calls in result for verification
540
+ usage,
638
541
  exitCode: 0
639
542
  };
640
543
 
@@ -656,12 +559,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
656
559
  if (watchdog) { watchdog.cancel(); }
657
560
  if (uninstallSignals) { uninstallSignals(); }
658
561
  if (!externalServer) { server.close(); }
562
+ const { emptyUsageTotals } = require('./utils/pricing');
659
563
  return {
660
564
  summary: '',
661
565
  completed: false,
662
566
  timedOut: false,
663
567
  aborted: false,
664
568
  taskId,
569
+ usage: emptyUsageTotals(),
665
570
  error: error.message
666
571
  };
667
572
  }
@@ -714,17 +619,6 @@ function formatFoldOutput({ model, sessionId, client, cwd, mode, summary }) {
714
619
  ].join('\n');
715
620
  }
716
621
 
717
- /**
718
- * Log a message to the conversation JSONL file
719
- * Spec Reference: §8.2 - Capture conversation to JSONL in real-time
720
- *
721
- * @param {string} conversationPath - Path to conversation.jsonl
722
- * @param {object} message - Message object with role, content, timestamp
723
- */
724
- function logMessage(conversationPath, message) {
725
- fs.appendFileSync(conversationPath, JSON.stringify(message) + '\n', { mode: 0o600 });
726
- }
727
-
728
622
  module.exports = {
729
623
  runHeadless,
730
624
  waitForServer,
package/src/mcp-server.js CHANGED
@@ -66,6 +66,12 @@ function spawnSidecarProcess(args, sessionDir) {
66
66
  stdio: ['ignore', 'ignore', stderrFd],
67
67
  env: { ...process.env, AMICUS_DEBUG_PORT: '9223', LOG_LEVEL: process.env.LOG_LEVEL || 'info' },
68
68
  });
69
+ // The child inherited its own copy of the stderr fd during spawn; close the
70
+ // parent's copy so we don't leak a descriptor. On Windows an open fd also
71
+ // blocks deletion of debug.log (e.g. tests that mock spawn then rm the dir).
72
+ if (typeof stderrFd === 'number') {
73
+ try { fs.closeSync(stderrFd); } catch { /* best-effort */ }
74
+ }
69
75
  child.unref();
70
76
  return child;
71
77
  }
@@ -0,0 +1,83 @@
1
+ // src/sidecar/budget.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module budget
6
+ * Pre-flight spend gate (WS-2 #10). Two guards:
7
+ * - HARD per-$/Mtok threshold (on by default): refuses a model whose catalog
8
+ * price-per-Mtok exceeds the cap. Structural o3-pro / opus-fast guard; no
9
+ * output length guess needed.
10
+ * - SOFT total-$ ceiling (opt-in via --max-cost): refuses when the summed
11
+ * per-leg ESTIMATE exceeds the ceiling. Estimate, not guaranteed.
12
+ * Unpriced legs (direct providers, pricing:null) are surfaced, never $0.
13
+ */
14
+
15
+ // Tuned against the live catalog at implementation time (observed 2026-06-23):
16
+ // opus (claude-opus-4.8) ~$25/Mtok out (allowed)
17
+ // o3 ~$8/Mtok out (allowed)
18
+ // o3-pro ~$80/Mtok out (blocked)
19
+ // gemini-pro ~$12/Mtok out (allowed)
20
+ // deepseek-v4-pro ~$0.87/Mtok out (allowed)
21
+ // Setting 60 blocks o3-pro (80) while allowing the normal council bench.
22
+ const DEFAULT_MAX_COST_PER_MTOK = 60;
23
+
24
+ // Rough output budget for the soft ceiling estimate (output length is unknown
25
+ // pre-flight). Deliberately conservative; the ceiling is labeled "estimate".
26
+ const ASSUMED_OUTPUT_TOKENS = 4000;
27
+
28
+ function perMtok(perToken) { return perToken * 1e6; }
29
+
30
+ /**
31
+ * @param {Array<{modelInput,model,pricing:{prompt,completion}|null}>} legs
32
+ * @param {{maxCostPerMtok?,maxCost?,promptChars?,assumedOutputTokens?}} [opts]
33
+ */
34
+ function checkBudget(legs, opts = {}) {
35
+ const cap = (typeof opts.maxCostPerMtok === 'number' && opts.maxCostPerMtok > 0)
36
+ ? opts.maxCostPerMtok : DEFAULT_MAX_COST_PER_MTOK;
37
+ const inTok = Math.ceil((opts.promptChars || 0) / 4);
38
+ const outTok = opts.assumedOutputTokens || ASSUMED_OUTPUT_TOKENS;
39
+ const offending = [];
40
+ const breakdownLegs = [];
41
+ let totalEstCost = 0;
42
+ let unpricedCount = 0;
43
+
44
+ for (const leg of legs) {
45
+ if (!leg.pricing) {
46
+ breakdownLegs.push({ modelInput: leg.modelInput, model: leg.model, priced: false, perMtok: null, estCost: null });
47
+ unpricedCount++;
48
+ continue;
49
+ }
50
+ const pm = Math.max(perMtok(leg.pricing.prompt), perMtok(leg.pricing.completion));
51
+ const estCost = leg.pricing.prompt * inTok + leg.pricing.completion * outTok;
52
+ totalEstCost += estCost;
53
+ const overThreshold = pm > cap;
54
+ breakdownLegs.push({ modelInput: leg.modelInput, model: leg.model, priced: true, perMtok: pm, estCost, overThreshold });
55
+ if (overThreshold) {
56
+ offending.push({ modelInput: leg.modelInput, model: leg.model, perMtok: pm,
57
+ reason: `$${pm.toFixed(2)}/Mtok exceeds the $${cap.toFixed(2)}/Mtok cap` });
58
+ }
59
+ }
60
+
61
+ const overCeiling = (typeof opts.maxCost === 'number' && opts.maxCost > 0) ? totalEstCost > opts.maxCost : false;
62
+ const ok = offending.length === 0 && !overCeiling;
63
+ return { ok, offending, overCeiling, breakdown: { legs: breakdownLegs, totalEstCost, unpricedCount, maxCostPerMtok: cap, maxCost: opts.maxCost || null } };
64
+ }
65
+
66
+ /** Human-readable refusal text (also used as the error envelope `hint`). */
67
+ function formatBudgetError(result) {
68
+ const lines = [];
69
+ if (result.offending.length > 0) {
70
+ lines.push('Budget gate: model(s) over the per-$/Mtok threshold:');
71
+ for (const o of result.offending) { lines.push(` - ${o.modelInput} (${o.model}): ${o.reason}`); }
72
+ }
73
+ if (result.overCeiling) {
74
+ lines.push(`Budget gate: estimated total $${result.breakdown.totalEstCost.toFixed(4)} exceeds --max-cost $${result.breakdown.maxCost.toFixed(4)} (estimate, not guaranteed).`);
75
+ }
76
+ if (result.breakdown.unpricedCount > 0) {
77
+ lines.push(`(${result.breakdown.unpricedCount} unpriced leg(s) — direct provider; cost unknown, not included in the estimate.)`);
78
+ }
79
+ lines.push('Override: --max-cost <$> to raise the ceiling, or --no-cost-gate to disable both guards (e.g. an intentional o3 run).');
80
+ return lines.join('\n');
81
+ }
82
+
83
+ module.exports = { checkBudget, formatBudgetError, DEFAULT_MAX_COST_PER_MTOK, ASSUMED_OUTPUT_TOKENS };
@@ -0,0 +1,128 @@
1
+ // src/sidecar/conversation-mirror.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module conversation-mirror
6
+ * Pure transform of an OpenCode getMessages() snapshot into conversation.jsonl
7
+ * append-lines + progress.json updates. Extracted from the headless poll loop so
8
+ * the interactive GUI path can mirror the same way (WS-4 #7). No I/O, no clock
9
+ * except the injectable `now`.
10
+ */
11
+
12
+ /** Fresh cursor for a session's mirror. */
13
+ function createMirrorState() {
14
+ return {
15
+ seenTextParts: new Map(), // partId -> last captured text length
16
+ toolCalls: [], // [{id,name,input}]
17
+ seenToolResultIds: new Set(),
18
+ receivingReported: false,
19
+ output: '', // accumulated assistant text
20
+ usageByMsg: new Map(), // msgId -> {tokens, cost}
21
+ };
22
+ }
23
+
24
+ /**
25
+ * @param {Array} messages getMessages() snapshot
26
+ * @param {object} state from createMirrorState() (mutated + returned)
27
+ * @param {{now?: () => string}} [opts]
28
+ */
29
+ function mirrorMessages(messages, state, opts = {}) {
30
+ const now = opts.now || (() => new Date().toISOString());
31
+ const appendLines = [];
32
+ const progressUpdates = [];
33
+ let currentAssistantMsgId = null;
34
+ let assistantFinished = false;
35
+ let sessionError = null;
36
+ const list = Array.isArray(messages) ? messages : [];
37
+ const messageCount = list.length;
38
+
39
+ for (const msg of list) {
40
+ const role = msg.info && msg.info.role;
41
+
42
+ // Track assistant message state
43
+ if (role === 'assistant') {
44
+ currentAssistantMsgId = msg.info.id;
45
+ if (msg.info.tokens || typeof msg.info.cost === 'number') {
46
+ state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
47
+ }
48
+ // Check for errors — capture for result propagation
49
+ if (msg.info.error) {
50
+ sessionError = (msg.info.error.data && msg.info.error.data.message)
51
+ || msg.info.error.name || 'Unknown model error';
52
+ }
53
+ }
54
+
55
+ // Only process parts from assistant messages (skip user messages)
56
+ if (role !== 'assistant' || !msg.parts) { continue; }
57
+
58
+ for (const part of msg.parts) {
59
+ const partId = part.id || `${msg.info.id}:${part.type}:${msg.parts.indexOf(part)}`;
60
+
61
+ if (part.type === 'text' && part.text) {
62
+ const prevLen = state.seenTextParts.get(partId) || 0;
63
+ if (part.text.length > prevLen) {
64
+ // Append only the new portion (handles streaming growth)
65
+ const newText = part.text.slice(prevLen);
66
+ state.output += newText;
67
+ state.seenTextParts.set(partId, part.text.length);
68
+ appendLines.push({ role: 'assistant', content: newText, timestamp: now() });
69
+
70
+ // Report 'receiving' stage on first text detection
71
+ if (!state.receivingReported) {
72
+ state.receivingReported = true;
73
+ progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
74
+ }
75
+ }
76
+ } else if ((part.type === 'tool_use' || part.type === 'tool') && !state.toolCalls.find(t => t.id === part.id)) {
77
+ const toolCall = { id: part.id, name: part.name, input: part.input };
78
+ state.toolCalls.push(toolCall);
79
+ appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
80
+
81
+ // Update progress on tool_use detection
82
+ progressUpdates.push({
83
+ stage: 'receiving',
84
+ extra: {
85
+ messagesReceived: state.toolCalls.length,
86
+ latestTool: part.name || undefined,
87
+ stageLabel: part.name ? `Calling tool: ${part.name}` : 'Executing tool call...',
88
+ },
89
+ });
90
+ state.receivingReported = true;
91
+ } else if (part.type === 'tool_result') {
92
+ // Dedup: append only on first sight (fixes latent double-log bug in headless poll loop)
93
+ if (!state.seenToolResultIds.has(partId)) {
94
+ state.seenToolResultIds.add(partId);
95
+ appendLines.push({
96
+ role: 'tool',
97
+ type: 'tool_result',
98
+ toolUseId: part.tool_use_id,
99
+ isError: part.is_error || false,
100
+ content: part.content,
101
+ timestamp: now(),
102
+ });
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ // assistantFinished = true only when the LAST assistant message is complete
109
+ // (earlier messages may finish while the model continues in new messages)
110
+ const lastAssistant = list.filter(m => m.info && m.info.role === 'assistant').pop();
111
+ assistantFinished = !!(lastAssistant && lastAssistant.info.time && lastAssistant.info.time.completed);
112
+
113
+ return { appendLines, progressUpdates, state, currentAssistantMsgId, assistantFinished, sessionError, messageCount };
114
+ }
115
+
116
+ const fs = require('fs');
117
+
118
+ /**
119
+ * Append one JSONL record to conversation.jsonl (0o600). Relocated here from
120
+ * headless.js so BOTH the headless loop and the interactive mirror import it from
121
+ * one place (no cross-module coupling to headless's 750-line surface).
122
+ * @param {string} conversationPath @param {object} message
123
+ */
124
+ function logMessage(conversationPath, message) {
125
+ fs.appendFileSync(conversationPath, JSON.stringify(message) + '\n', { mode: 0o600 });
126
+ }
127
+
128
+ module.exports = { createMirrorState, mirrorMessages, logMessage };
@@ -87,10 +87,13 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
87
87
  if (summary) {
88
88
  fs.writeFileSync(SessionPaths.summaryFile(legDir), summary, { mode: 0o600 });
89
89
  }
90
+ const { resolveUsage } = require('../utils/pricing');
91
+ const usage = result && result.usage ? resolveUsage({ model: leg.model, usageTotals: result.usage }) : null;
90
92
  const finalMeta = writeLegPatch(legDir, {
91
93
  status,
92
94
  reason: result.error || undefined,
93
95
  completedAt: new Date().toISOString(),
96
+ usage: usage || undefined,
94
97
  });
95
98
  const effectiveResult = finalMeta.status === 'aborted'
96
99
  ? { ...result, aborted: true }
@@ -100,7 +103,7 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
100
103
  }
101
104
  return buildRunResult({
102
105
  taskId: legId, metadata: finalMeta, result: effectiveResult, summary,
103
- modelInput: leg.modelInput, sessionDir: legDir, waveId,
106
+ modelInput: leg.modelInput, sessionDir: legDir, waveId, usage,
104
107
  });
105
108
  }
106
109