amicus 1.0.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 +86 -0
- package/LICENSE +22 -1
- package/README.md +14 -3
- package/bin/amicus.js +17 -162
- package/electron/ipc-setup.js +30 -9
- package/electron/main.js +13 -5
- package/electron/preload.js +30 -10
- package/electron/setup-ui-keys.js +9 -0
- package/electron/setup-ui-model.js +33 -23
- package/electron/setup-ui-styles.js +6 -1
- package/electron/setup-ui.js +91 -38
- package/electron/toolbar.js +4 -5
- package/package.json +7 -5
- package/scripts/postinstall.js +16 -7
- package/skills/second-opinion/COUNCIL-DESIGN.md +36 -34
- package/skills/second-opinion/MODEL-NOTES.md +23 -17
- package/skills/second-opinion/SKILL.md +84 -51
- package/{skill → skills/sidecar}/SKILL.md +14 -4
- 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 +66 -1
- package/src/cli.js +16 -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 +41 -10
- package/src/sidecar/session-finalize.js +26 -0
- package/src/sidecar/session-utils.js +5 -5
- package/src/sidecar/setup.js +55 -42
- 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/curated-models.js +88 -45
- 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/model-fetcher.js +16 -2
- package/src/utils/pricing.js +93 -0
- package/src/utils/quick-picks.js +81 -0
- package/src/utils/result-schema.js +21 -2
- package/src/utils/session-abort.js +40 -13
- package/src/utils/validators.js +17 -17
|
@@ -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
|
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -14,6 +14,7 @@ const fs = require('fs');
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const { logger } = require('../utils/logger');
|
|
16
16
|
const { runLeg } = require('./fanout-leg');
|
|
17
|
+
const { ERROR_CODES } = require('../utils/error-doc');
|
|
17
18
|
|
|
18
19
|
/** Default max legs per wave (env-overridable). */
|
|
19
20
|
const DEFAULT_MAX_LEGS = 10;
|
|
@@ -48,38 +49,39 @@ function deriveLegIds(waveId, count) {
|
|
|
48
49
|
async function validateFanoutModels(modelsArg, opts = {}) {
|
|
49
50
|
const raw = parseModelsList(modelsArg);
|
|
50
51
|
if (raw.length === 0) {
|
|
51
|
-
return { error: 'Error: --models requires a comma-separated list (e.g. gemini,gpt,deepseek)' };
|
|
52
|
+
return { error: 'Error: --models requires a comma-separated list (e.g. gemini,gpt,deepseek)', code: 'BAD_ARGS' };
|
|
52
53
|
}
|
|
53
54
|
// Invalid or non-positive AMICUS_FANOUT_MAX_LEGS (0, negative, garbage) falls back to the default.
|
|
54
55
|
const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
|
|
55
56
|
const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
|
|
56
57
|
if (raw.length > maxLegs) {
|
|
57
|
-
return { error: `Error: --models exceeds the fan-out cap of ${maxLegs} legs (set AMICUS_FANOUT_MAX_LEGS to raise)
|
|
58
|
+
return { error: `Error: --models exceeds the fan-out cap of ${maxLegs} legs (set AMICUS_FANOUT_MAX_LEGS to raise)`, code: 'BAD_ARGS' };
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
const { tryResolveModel } = require('../utils/config');
|
|
61
62
|
const { validateApiKey } = require('../utils/validators');
|
|
62
63
|
const { validateAgainstCatalog } = require('../utils/model-validator');
|
|
64
|
+
const { lookupPricing } = require('../utils/pricing');
|
|
63
65
|
const legs = [];
|
|
64
66
|
for (const modelInput of raw) {
|
|
65
67
|
const resolved = tryResolveModel(modelInput);
|
|
66
68
|
if (resolved.error) {
|
|
67
|
-
return { error: `Error: model '${modelInput}': ${resolved.error}
|
|
69
|
+
return { error: `Error: model '${modelInput}': ${resolved.error}`, code: 'BAD_MODEL' };
|
|
68
70
|
}
|
|
69
71
|
let model = resolved.model;
|
|
70
72
|
const keyCheck = validateApiKey(model);
|
|
71
73
|
if (!keyCheck.valid) {
|
|
72
|
-
return { error: keyCheck.error };
|
|
74
|
+
return { error: keyCheck.error, code: 'MISSING_KEY' };
|
|
73
75
|
}
|
|
74
76
|
if (!opts.noValidateModel) {
|
|
75
77
|
const alias = modelInput.includes('/') ? undefined : modelInput;
|
|
76
78
|
try {
|
|
77
79
|
model = await validateAgainstCatalog(model, alias);
|
|
78
80
|
} catch (err) {
|
|
79
|
-
return { error: err.message };
|
|
81
|
+
return { error: err.message, code: 'BAD_MODEL' };
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
|
-
legs.push({ modelInput, model });
|
|
84
|
+
legs.push({ modelInput, model, pricing: lookupPricing(model) });
|
|
83
85
|
}
|
|
84
86
|
return { legs };
|
|
85
87
|
}
|
|
@@ -131,11 +133,36 @@ async function runFanout(options) {
|
|
|
131
133
|
return { wave: doc, exitCode: 1 };
|
|
132
134
|
};
|
|
133
135
|
|
|
136
|
+
const failPre = (code, message, hint) => {
|
|
137
|
+
if (!options.quiet) {
|
|
138
|
+
if (options.json) {
|
|
139
|
+
const { buildErrorDoc } = require('../utils/error-doc');
|
|
140
|
+
console.log(JSON.stringify(buildErrorDoc({ code, message, hint }), null, 2));
|
|
141
|
+
} else {
|
|
142
|
+
console.error(hint ? `${message}\n${hint}` : message);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { wave: null, errorDoc: { code, message }, exitCode: 1 };
|
|
146
|
+
};
|
|
147
|
+
|
|
134
148
|
// 1. Fail-fast validation
|
|
135
149
|
const validated = await validateFanoutModels(options.models, { noValidateModel: options.noValidateModel });
|
|
136
|
-
if (validated.error) { return
|
|
150
|
+
if (validated.error) { return failPre(validated.code || 'BAD_ARGS', validated.error); }
|
|
137
151
|
const legs = validated.legs;
|
|
138
152
|
|
|
153
|
+
// 1b. Budget gate (pre-creation; refuse before spending)
|
|
154
|
+
if (!options.noCostGate) {
|
|
155
|
+
const { checkBudget, formatBudgetError } = require('./budget');
|
|
156
|
+
const { loadConfig } = require('../utils/config');
|
|
157
|
+
const cfg = loadConfig() || {};
|
|
158
|
+
const maxCostPerMtok = options.maxCostPerMtok !== undefined ? options.maxCostPerMtok : cfg.maxCostPerMtok;
|
|
159
|
+
const promptChars = (options.promptMeta && options.promptMeta.chars) || (options.prompt ? options.prompt.length : 0);
|
|
160
|
+
const budget = checkBudget(legs, { maxCostPerMtok, maxCost: options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost, promptChars });
|
|
161
|
+
if (!budget.ok) {
|
|
162
|
+
return failPre(ERROR_CODES.BUDGET_EXCEEDED, 'Error: budget gate refused the wave', formatBudgetError(budget));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
139
166
|
// 2. Wave record
|
|
140
167
|
const waveId = options.waveId || generateTaskId();
|
|
141
168
|
const legIds = deriveLegIds(waveId, legs.length);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/sidecar/interactive-mirror.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { createMirrorState, mirrorMessages, logMessage } = require('./conversation-mirror');
|
|
6
|
+
const { writeProgress } = require('./progress');
|
|
7
|
+
const { sumPerMessageUsage } = require('../utils/pricing');
|
|
8
|
+
const { logger } = require('../utils/logger');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Poll the OpenCode session and mirror it to conversation.jsonl + progress.json
|
|
12
|
+
* live, exactly like headless. Best-effort and non-blocking — a poll/write error
|
|
13
|
+
* never throws into the GUI session.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {() => Promise<Array>} opts.getMessages
|
|
17
|
+
* @param {string} opts.sessionDir
|
|
18
|
+
* @param {number} [opts.intervalMs=2000]
|
|
19
|
+
* @param {() => void} [opts.onActivity]
|
|
20
|
+
* @param {() => string} [opts.now]
|
|
21
|
+
* @returns {{ stop: () => Promise<{usage: object|null}> }}
|
|
22
|
+
*/
|
|
23
|
+
function startInteractiveMirror({ getMessages, sessionDir, intervalMs = 2000, onActivity, now }) {
|
|
24
|
+
const state = createMirrorState();
|
|
25
|
+
const conversationPath = path.join(sessionDir, 'conversation.jsonl');
|
|
26
|
+
let timer = null;
|
|
27
|
+
let stopped = false;
|
|
28
|
+
|
|
29
|
+
async function pollOnce() {
|
|
30
|
+
try {
|
|
31
|
+
const messages = await getMessages();
|
|
32
|
+
const mr = mirrorMessages(messages, state, { now });
|
|
33
|
+
mr.appendLines.forEach(line => logMessage(conversationPath, line));
|
|
34
|
+
mr.progressUpdates.forEach(p => writeProgress(sessionDir, p.stage, p.extra));
|
|
35
|
+
if (mr.appendLines.length > 0 && onActivity) { onActivity(); }
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logger.debug('Interactive mirror poll failed (best-effort)', { error: err.message });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const schedule = () => {
|
|
42
|
+
if (stopped) { return; }
|
|
43
|
+
timer = setTimeout(tick, intervalMs);
|
|
44
|
+
if (timer.unref) { timer.unref(); }
|
|
45
|
+
};
|
|
46
|
+
async function tick() {
|
|
47
|
+
if (stopped) { return; }
|
|
48
|
+
await pollOnce();
|
|
49
|
+
schedule();
|
|
50
|
+
}
|
|
51
|
+
schedule();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async stop() {
|
|
55
|
+
stopped = true;
|
|
56
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
57
|
+
await pollOnce(); // final flush
|
|
58
|
+
try { writeProgress(sessionDir, 'complete'); } catch { /* best-effort */ }
|
|
59
|
+
let usage = null;
|
|
60
|
+
try { usage = sumPerMessageUsage(state.usageByMsg); } catch { /* best-effort */ }
|
|
61
|
+
return { usage };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { startInteractiveMirror };
|
|
@@ -7,10 +7,12 @@ const path = require('path');
|
|
|
7
7
|
const { spawn } = require('child_process');
|
|
8
8
|
|
|
9
9
|
const { startOpenCodeServer } = require('./session-utils');
|
|
10
|
-
const { createSession, sendPromptAsync } = require('../opencode-client');
|
|
10
|
+
const { createSession, sendPromptAsync, getMessages } = require('../opencode-client');
|
|
11
11
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
12
12
|
const { logger } = require('../utils/logger');
|
|
13
13
|
const { getCompatEnv } = require('../utils/env-compat');
|
|
14
|
+
const { startInteractiveMirror } = require('./interactive-mirror');
|
|
15
|
+
const { getSessionDir } = require('../session-manager');
|
|
14
16
|
|
|
15
17
|
/** Get the Electron binary path via require('electron').
|
|
16
18
|
* Works in all install contexts (global, local, npx hoisted).
|
|
@@ -151,15 +153,35 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
151
153
|
|
|
152
154
|
const serverPort = new URL(server.url).port;
|
|
153
155
|
|
|
154
|
-
// Start idle watchdog for interactive mode (60-min default timeout)
|
|
156
|
+
// Start idle watchdog for interactive mode (60-min default timeout).
|
|
157
|
+
// The real teardown handler is installed BEFORE start() (closes the startup
|
|
158
|
+
// race) and references a closure that is assigned once Electron spawns.
|
|
155
159
|
const { IdleWatchdog } = require('../utils/idle-watchdog');
|
|
160
|
+
const { createActivityPoller, killIfAlive } = require('../utils/activity-poller');
|
|
161
|
+
const { getSessionStatus } = require('../opencode-client');
|
|
162
|
+
let electronProcess = null;
|
|
156
163
|
const watchdog = new IdleWatchdog({
|
|
157
164
|
mode: 'interactive',
|
|
158
165
|
onTimeout: () => {
|
|
159
166
|
logger.info('Interactive idle timeout - shutting down', { taskId });
|
|
167
|
+
killIfAlive(electronProcess);
|
|
160
168
|
},
|
|
161
169
|
}).start();
|
|
162
170
|
|
|
171
|
+
// Keep the idle clock from killing an actively-working session: poll OpenCode
|
|
172
|
+
// session status and touch the watchdog on any non-idle (busy/retry) state.
|
|
173
|
+
const activityPoller = createActivityPoller({
|
|
174
|
+
getStatus: () => getSessionStatus(ocClient, sessionId),
|
|
175
|
+
onActivity: () => watchdog.touch(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const sessionDir = getSessionDir(project, taskId);
|
|
179
|
+
const mirror = startInteractiveMirror({
|
|
180
|
+
getMessages: () => getMessages(ocClient, sessionId),
|
|
181
|
+
sessionDir,
|
|
182
|
+
onActivity: () => watchdog.touch(),
|
|
183
|
+
});
|
|
184
|
+
|
|
163
185
|
return new Promise((resolve, _reject) => {
|
|
164
186
|
const electronPath = getElectronPath();
|
|
165
187
|
const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
|
|
@@ -170,40 +192,32 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
170
192
|
taskId, model, project, nodeModulesBin, existingPath,
|
|
171
193
|
{ agent, isResume, conversation, mcp, client }
|
|
172
194
|
);
|
|
173
|
-
|
|
174
|
-
// Pass OpenCode server info to Electron
|
|
175
195
|
env.AMICUS_OPENCODE_PORT = serverPort;
|
|
176
196
|
env.AMICUS_SESSION_ID = sessionId;
|
|
177
197
|
|
|
178
198
|
const debugPort = getCompatEnv('DEBUG_PORT') || '9222';
|
|
179
199
|
logger.debug('Launching Electron', { taskId, model, debugPort, serverPort, sessionId });
|
|
180
200
|
|
|
181
|
-
|
|
201
|
+
electronProcess = spawn(electronPath, [
|
|
182
202
|
`--remote-debugging-port=${debugPort}`,
|
|
183
203
|
mainPath
|
|
184
204
|
], { cwd: project, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
185
205
|
|
|
186
|
-
//
|
|
187
|
-
electronProcess.stdout.on('data', () => {
|
|
188
|
-
watchdog.touch();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// Update watchdog onTimeout now that electronProcess is available
|
|
192
|
-
watchdog.onTimeout = () => {
|
|
193
|
-
logger.info('Interactive idle timeout - shutting down', { taskId });
|
|
194
|
-
if (!electronProcess.killed) {
|
|
195
|
-
electronProcess.kill('SIGTERM');
|
|
196
|
-
}
|
|
197
|
-
};
|
|
206
|
+
// Belt-and-suspenders: also touch on raw Electron stdout activity.
|
|
207
|
+
electronProcess.stdout.on('data', () => { watchdog.touch(); });
|
|
198
208
|
|
|
199
|
-
// Clean up server when Electron exits
|
|
200
|
-
|
|
201
|
-
handleElectronProcess(electronProcess, taskId, (result) => {
|
|
209
|
+
// Clean up server + timers when Electron exits.
|
|
210
|
+
handleElectronProcess(electronProcess, taskId, async (result) => {
|
|
202
211
|
watchdog.cancel();
|
|
212
|
+
activityPoller.stop();
|
|
213
|
+
try {
|
|
214
|
+
const { usage } = await mirror.stop();
|
|
215
|
+
if (usage) { result.usage = usage; }
|
|
216
|
+
} catch (err) { logger.debug('mirror stop failed', { error: err.message }); }
|
|
203
217
|
server.close();
|
|
204
218
|
logger.debug('OpenCode server closed after Electron exit');
|
|
205
219
|
result.opencodeSessionId = sessionId;
|
|
206
|
-
|
|
220
|
+
resolve(result);
|
|
207
221
|
});
|
|
208
222
|
});
|
|
209
223
|
}
|
package/src/sidecar/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `amicus models` (F5) — list/search the catalog, refresh it, audit aliases.
|
|
3
3
|
*
|
|
4
|
-
* amicus models list (
|
|
4
|
+
* amicus models list (effective aliases marked)
|
|
5
5
|
* amicus models --search <q> substring filter over id+name
|
|
6
6
|
* amicus models --refresh force-refresh the cache
|
|
7
7
|
* amicus models --check stale-alias audit (exit = stale count, max 100)
|
|
@@ -15,14 +15,17 @@
|
|
|
15
15
|
const { getCatalogInfo, refreshCatalog, catalogPath } = require('../utils/model-catalog');
|
|
16
16
|
const { collectAliasSources, findStaleAliases, suggestReplacements } = require('../utils/alias-audit');
|
|
17
17
|
const { buildCatalogDoc, buildAuditDoc } = require('../utils/result-schema');
|
|
18
|
+
const { getFamilies } = require('../utils/curated-models');
|
|
19
|
+
const { pickCurrent } = require('../utils/quick-picks');
|
|
18
20
|
|
|
19
21
|
const CHECK_EXIT_CAP = 100;
|
|
20
22
|
|
|
21
|
-
/** '0.000003' per token → '3.00' per Mtok; '—' when unknown */
|
|
23
|
+
/** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
|
|
22
24
|
function perMtok(perToken) {
|
|
23
25
|
if (perToken === null || perToken === undefined) { return '—'; }
|
|
24
26
|
const n = Number(perToken);
|
|
25
|
-
|
|
27
|
+
if (Number.isNaN(n) || n < 0) { return '—'; }
|
|
28
|
+
return (n * 1e6).toFixed(2);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
function fmtRow(m, aliasesById) {
|
|
@@ -34,11 +37,11 @@ function fmtRow(m, aliasesById) {
|
|
|
34
37
|
return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
/** alias marks: id → comma-joined alias names (
|
|
40
|
+
/** alias marks: id → comma-joined alias names (effective user aliases) */
|
|
38
41
|
function aliasMarks() {
|
|
39
|
-
const {
|
|
42
|
+
const { getEffectiveAliases } = require('../utils/config');
|
|
40
43
|
const map = new Map();
|
|
41
|
-
for (const [alias, model] of Object.entries(
|
|
44
|
+
for (const [alias, model] of Object.entries(getEffectiveAliases())) {
|
|
42
45
|
map.set(model, map.has(model) ? `${map.get(model)},${alias}` : alias);
|
|
43
46
|
}
|
|
44
47
|
return map;
|
|
@@ -57,10 +60,10 @@ async function runList(args) {
|
|
|
57
60
|
return 0;
|
|
58
61
|
}
|
|
59
62
|
const marks = aliasMarks();
|
|
60
|
-
//
|
|
61
|
-
const
|
|
63
|
+
// Effective aliases (alias-marked) rows first, then the rest.
|
|
64
|
+
const marked = filtered.filter(m => marks.has(m.id));
|
|
62
65
|
const rest = filtered.filter(m => !marks.has(m.id));
|
|
63
|
-
for (const m of [...
|
|
66
|
+
for (const m of [...marked, ...rest]) {
|
|
64
67
|
process.stdout.write(fmtRow(m, marks) + '\n');
|
|
65
68
|
}
|
|
66
69
|
const when = fetchedAt ? new Date(fetchedAt).toISOString() : 'never';
|
|
@@ -105,8 +108,13 @@ async function runCheck(args) {
|
|
|
105
108
|
}), null, 2) + '\n');
|
|
106
109
|
return Math.min(stale.length, CHECK_EXIT_CAP);
|
|
107
110
|
}
|
|
111
|
+
const driftLines = buildFallbackDriftReport(catalog);
|
|
108
112
|
if (stale.length === 0) {
|
|
109
113
|
process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
|
|
114
|
+
if (driftLines.length > 0) {
|
|
115
|
+
process.stdout.write('Pinned fallback drift:\n');
|
|
116
|
+
for (const l of driftLines) { process.stdout.write(l + '\n'); }
|
|
117
|
+
}
|
|
110
118
|
return 0;
|
|
111
119
|
}
|
|
112
120
|
for (const s of stale) {
|
|
@@ -118,9 +126,32 @@ async function runCheck(args) {
|
|
|
118
126
|
process.stdout.write(' no same-vendor candidates in catalog\n');
|
|
119
127
|
}
|
|
120
128
|
}
|
|
129
|
+
if (driftLines.length > 0) {
|
|
130
|
+
process.stdout.write('Pinned fallback drift:\n');
|
|
131
|
+
for (const l of driftLines) { process.stdout.write(l + '\n'); }
|
|
132
|
+
}
|
|
121
133
|
return Math.min(stale.length, CHECK_EXIT_CAP);
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Non-blocking drift report: pinned family fallbacks vs live resolution.
|
|
138
|
+
* Empty catalog → [] (cannot check). Never affects the exit code.
|
|
139
|
+
* @param {Array<{id:string}>} catalog
|
|
140
|
+
* @returns {string[]} human-readable warning lines
|
|
141
|
+
*/
|
|
142
|
+
function buildFallbackDriftReport(catalog) {
|
|
143
|
+
if (!catalog || catalog.length === 0) { return []; }
|
|
144
|
+
const lines = [];
|
|
145
|
+
for (const f of getFamilies()) {
|
|
146
|
+
const live = pickCurrent(catalog, 'openrouter/', f.vendorPath, f.idPattern);
|
|
147
|
+
if (live && f.fallback.openrouter && live !== f.fallback.openrouter) {
|
|
148
|
+
lines.push(
|
|
149
|
+
` pinned fallback drift: ${f.alias} → ${f.fallback.openrouter} (live: ${live}) — update curated-models.js`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return lines;
|
|
153
|
+
}
|
|
154
|
+
|
|
124
155
|
/** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
|
|
125
156
|
async function handleModels(args) {
|
|
126
157
|
if (args.search === true) {
|
|
@@ -132,4 +163,4 @@ async function handleModels(args) {
|
|
|
132
163
|
return runList(args);
|
|
133
164
|
}
|
|
134
165
|
|
|
135
|
-
module.exports = { handleModels };
|
|
166
|
+
module.exports = { handleModels, buildFallbackDriftReport };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map a runHeadless result to the canonical terminal status + process exit code.
|
|
5
|
+
* Single source of truth so start.js, the signal handler, and the idle backstop
|
|
6
|
+
* never disagree. Error wins over all other flags; signal callers pass the signal
|
|
7
|
+
* name for the 130/143 convention.
|
|
8
|
+
*
|
|
9
|
+
* @param {{completed?:boolean,timedOut?:boolean,aborted?:boolean,error?:any}|null} result
|
|
10
|
+
* @param {string} [signal] - 'SIGINT' | 'SIGTERM' | 'SIGBREAK' for signal aborts
|
|
11
|
+
* @returns {{status:'complete'|'error'|'timed-out'|'aborted', exitCode:number}}
|
|
12
|
+
*/
|
|
13
|
+
function resolveTerminalState(result, signal) {
|
|
14
|
+
if (!result || result.error) { return { status: 'error', exitCode: 1 }; }
|
|
15
|
+
if (result.aborted) {
|
|
16
|
+
const exitCode = signal === 'SIGINT' ? 130
|
|
17
|
+
: (signal === 'SIGTERM' || signal === 'SIGBREAK') ? 143
|
|
18
|
+
: 2;
|
|
19
|
+
return { status: 'aborted', exitCode };
|
|
20
|
+
}
|
|
21
|
+
if (result.timedOut) { return { status: 'timed-out', exitCode: 2 }; }
|
|
22
|
+
if (result.completed) { return { status: 'complete', exitCode: 0 }; }
|
|
23
|
+
return { status: 'error', exitCode: 1 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { resolveTerminalState };
|
|
@@ -90,12 +90,12 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
|
|
|
90
90
|
// Save summary
|
|
91
91
|
fs.writeFileSync(SessionPaths.summaryFile(sessionDir), summary, { mode: 0o600 });
|
|
92
92
|
|
|
93
|
-
// Update metadata to complete
|
|
94
|
-
metadata.status = 'complete';
|
|
93
|
+
// Update metadata to the resolved terminal status (default complete).
|
|
94
|
+
metadata.status = opts.status || 'complete';
|
|
95
95
|
metadata.completedAt = new Date().toISOString();
|
|
96
96
|
fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
97
97
|
|
|
98
|
-
logger.info('Session
|
|
98
|
+
logger.info('Session finalized', { taskId: metadata.taskId, status: metadata.status });
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/** Output summary to stdout with standard formatting */
|
|
@@ -122,9 +122,9 @@ function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
|
|
|
122
122
|
if (sessionDir) {
|
|
123
123
|
const { readProgress } = require('./progress');
|
|
124
124
|
const progress = readProgress(sessionDir);
|
|
125
|
-
process.stderr.write(`[
|
|
125
|
+
process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
|
|
126
126
|
} else {
|
|
127
|
-
process.stderr.write(`[
|
|
127
|
+
process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
|
|
128
128
|
}
|
|
129
129
|
}, interval);
|
|
130
130
|
|