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.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +19 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +22 -1
- package/README.md +12 -2
- package/bin/amicus.js +13 -161
- package/package.json +6 -4
- package/scripts/postinstall.js +16 -7
- package/skills/second-opinion/COUNCIL-DESIGN.md +34 -34
- package/skills/second-opinion/MODEL-NOTES.md +23 -17
- package/skills/second-opinion/SKILL.md +77 -45
- package/src/cli-handlers-council.js +59 -0
- package/src/cli-handlers-doctor.js +173 -0
- package/src/cli-handlers-run.js +196 -0
- package/src/cli-handlers.js +1 -1
- package/src/cli.js +11 -2
- package/src/council/findings.js +48 -0
- package/src/council/ledger.js +82 -0
- package/src/council/tally.js +108 -0
- package/src/council/verdict.js +48 -0
- package/src/headless.js +43 -149
- package/src/mcp-server.js +6 -0
- package/src/sidecar/budget.js +83 -0
- package/src/sidecar/conversation-mirror.js +128 -0
- package/src/sidecar/fanout-leg.js +4 -1
- package/src/sidecar/fanout.js +34 -7
- package/src/sidecar/interactive-mirror.js +66 -0
- package/src/sidecar/interactive.js +35 -21
- package/src/sidecar/models.js +10 -9
- package/src/sidecar/session-finalize.js +26 -0
- package/src/sidecar/session-utils.js +5 -5
- package/src/sidecar/setup.js +2 -2
- package/src/sidecar/start.js +19 -6
- package/src/utils/activity-poller.js +47 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/config.js +4 -4
- package/src/utils/error-doc.js +55 -0
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/pricing.js +93 -0
- package/src/utils/result-schema.js +21 -2
- package/src/utils/session-abort.js +40 -13
- package/src/utils/validators.js +17 -17
- /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
|
-
|
|
194
|
-
process.exit(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
|