amicus 1.9.1 → 2.1.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/plugin.json +1 -1
- package/CHANGELOG.md +200 -0
- package/README.md +40 -170
- package/bin/amicus.js +19 -107
- package/commands/council.md +7 -3
- package/electron/fold.js +10 -1
- package/electron/ipc-setup.js +10 -15
- package/electron/main.js +21 -16
- package/electron/preload-setup.js +0 -1
- package/electron/setup-ui-council.js +64 -10
- package/electron/setup-ui-styles.js +34 -3
- package/electron/setup-ui.js +44 -12
- package/package.json +2 -5
- package/skills/second-opinion/MODEL-NOTES.md +2 -2
- package/skills/second-opinion/SKILL.md +30 -28
- package/skills/sidecar/SKILL.md +20 -17
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +20 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +9 -8
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +55 -0
- package/src/council/presets-cli.js +141 -0
- package/src/headless.js +146 -38
- package/src/index.js +1 -9
- package/src/mcp-server.js +140 -113
- package/src/mcp-tools.js +58 -24
- package/src/mcp-wait.js +8 -5
- package/src/opencode-client.js +33 -10
- package/src/prompt-builder.js +32 -11
- package/src/session-manager.js +7 -14
- package/src/sidecar/continue.js +34 -12
- package/src/sidecar/conversation-mirror.js +22 -1
- package/src/sidecar/crash-handler.js +2 -1
- package/src/sidecar/fanout-leg.js +12 -3
- package/src/sidecar/fanout.js +27 -10
- package/src/sidecar/interactive-process.js +6 -17
- package/src/sidecar/interactive.js +5 -6
- package/src/sidecar/models.js +33 -4
- package/src/sidecar/progress.js +2 -1
- package/src/sidecar/read.js +4 -6
- package/src/sidecar/resume.js +41 -11
- package/src/sidecar/session-finalize.js +2 -1
- package/src/sidecar/session-utils.js +13 -35
- package/src/sidecar/setup-window.js +2 -3
- package/src/sidecar/start.js +22 -7
- package/src/utils/abort-coordinator.js +57 -7
- package/src/utils/abort-result.js +36 -0
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/doctor-mcp-checks.js +84 -0
- package/src/utils/env-loader.js +1 -2
- package/src/utils/fold-marker.js +79 -0
- package/src/utils/idle-watchdog.js +9 -12
- package/src/utils/input-validators.js +52 -1
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +80 -19
- package/src/utils/mcp-self-identity.js +12 -5
- package/src/utils/model-catalog.js +54 -6
- package/src/utils/read-slice.js +73 -0
- package/src/utils/remediation-hints.js +9 -0
- package/src/utils/result-schema-version.js +14 -0
- package/src/utils/result-schema.js +18 -12
- package/src/utils/session-abort.js +1 -1
- package/src/utils/session-index-tmp-sweep.js +80 -0
- package/src/utils/session-index.js +4 -5
- package/src/utils/session-path.js +6 -10
- package/src/utils/shared-server.js +7 -5
- package/src/utils/spend-ledger.js +80 -0
- package/src/utils/updater.js +2 -3
- package/src/utils/env-compat.js +0 -38
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const HINTS = require('./utils/remediation-hints');
|
|
5
|
+
// B14/4.3: 'mcp' + 'mcp-legacy' check bodies (mirrors the B15 tmpSweep split — see file header).
|
|
6
|
+
const mcpChecks = require('./utils/doctor-mcp-checks');
|
|
5
7
|
|
|
6
8
|
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
|
|
7
9
|
|
|
@@ -39,8 +41,13 @@ function realDeps() {
|
|
|
39
41
|
// stays separate; repair only runs when fix is requested.
|
|
40
42
|
repairElectron: (opts) => require('./sidecar/electron-install').repairElectron(opts),
|
|
41
43
|
fix: false,
|
|
42
|
-
discoverClaudeCodeMcps: () => require('./utils/mcp-discovery').discoverClaudeCodeMcps(),
|
|
43
44
|
discoverCoworkMcps: () => require('./utils/mcp-discovery').discoverCoworkMcps(),
|
|
45
|
+
// B14: raw (unstripped) read — the PRIMARY 'mcp' check signal.
|
|
46
|
+
// discoverClaudeCodeMcps() always strips 'amicus'/'sidecar'-shaped
|
|
47
|
+
// entries (recursive-spawn guard, src/utils/mcp-self-identity.js) and so
|
|
48
|
+
// can never be used to detect a healthy registration — see
|
|
49
|
+
// utils/doctor-mcp-checks.js for the full rationale.
|
|
50
|
+
hasAmicusRegistration: () => require('./utils/mcp-discovery').hasAmicusRegistration(),
|
|
44
51
|
inspectLegacyMcpEntries: () => require('./utils/legacy-mcp-migration').inspectAllLegacySidecarEntries(),
|
|
45
52
|
migrateLegacyMcpEntries: () => require('./utils/legacy-mcp-migration').migrateLegacySidecar(),
|
|
46
53
|
skillInstalled: () => {
|
|
@@ -48,8 +55,13 @@ function realDeps() {
|
|
|
48
55
|
return fs.existsSync(path.join(dir, 'sidecar', 'SKILL.md'))
|
|
49
56
|
&& fs.existsSync(path.join(dir, 'second-opinion', 'SKILL.md'));
|
|
50
57
|
},
|
|
58
|
+
now: () => Date.now(),
|
|
59
|
+
listSessionIndexTmpFiles: () => tmpSweep.listSessionIndexTmpFiles(), // B15
|
|
60
|
+
unlinkSessionIndexTmp: (n) => tmpSweep.unlinkSessionIndexTmp(n),
|
|
51
61
|
};
|
|
52
62
|
}
|
|
63
|
+
// B15: sweep logic in utils/session-index-tmp-sweep.js (mirrors mcp-legacy's split).
|
|
64
|
+
const tmpSweep = require('./utils/session-index-tmp-sweep');
|
|
53
65
|
|
|
54
66
|
/** Run one guarded check; a thrown fn becomes an error line. */
|
|
55
67
|
function guard(id, name, fn) {
|
|
@@ -170,59 +182,14 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
170
182
|
: { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint: `${HINTS.reinstall} (re-runs the skill install)` }
|
|
171
183
|
)));
|
|
172
184
|
|
|
173
|
-
checks.push(guard('mcp', 'MCP registration', () =>
|
|
174
|
-
const code = d.discoverClaudeCodeMcps();
|
|
175
|
-
const cowork = d.discoverCoworkMcps();
|
|
176
|
-
const inCode = !!(code && code.amicus);
|
|
177
|
-
const inCowork = !!(cowork && cowork.amicus);
|
|
178
|
-
// Primary signal: Claude Code MCP registration. Cowork/Desktop is reported as bonus only.
|
|
179
|
-
if (!inCode) {
|
|
180
|
-
return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint: `${HINTS.reinstall} (or install the amicus plugin)` };
|
|
181
|
-
}
|
|
182
|
-
const extra = inCowork ? ', Cowork/Desktop' : '';
|
|
183
|
-
return { id: 'mcp', name: 'MCP registration', status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
|
|
184
|
-
}));
|
|
185
|
+
checks.push(guard('mcp', 'MCP registration', () => mcpChecks.evaluateMcpRegistration(d)));
|
|
185
186
|
|
|
186
|
-
// Duplicate legacy 'sidecar' MCP registration
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const entries = d.inspectLegacyMcpEntries() || [];
|
|
193
|
-
const dupes = entries.filter(e => e.status === 'removable');
|
|
194
|
-
const custom = entries.filter(e => e.status === 'customized');
|
|
195
|
-
// An unreadable config is neither "no problem" nor a duplicate we can act
|
|
196
|
-
// on — reporting it as ok/'none' would hide a config doctor (and --fix)
|
|
197
|
-
// could not actually inspect. Always surface it, even alongside dupes.
|
|
198
|
-
const unreadable = entries.filter(e => e.status === 'unreadable');
|
|
199
|
-
const unreadableNote = unreadable.length
|
|
200
|
-
? `${unreadable.map(e => e.target).join(', ')} config unreadable — skipped`
|
|
201
|
-
: null;
|
|
202
|
-
if (dupes.length === 0) {
|
|
203
|
-
if (unreadableNote) {
|
|
204
|
-
const suffix = custom.length ? `; custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone` : '';
|
|
205
|
-
return { id, name, status: 'warn', message: `${unreadableNote}${suffix}`, hint: null };
|
|
206
|
-
}
|
|
207
|
-
const message = custom.length
|
|
208
|
-
? `custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone`
|
|
209
|
-
: 'none';
|
|
210
|
-
return { id, name, status: 'ok', message, hint: null };
|
|
211
|
-
}
|
|
212
|
-
if (d.fix) {
|
|
213
|
-
const removed = (d.migrateLegacyMcpEntries() || []).filter(r => r.result === 'removed');
|
|
214
|
-
if (removed.length >= dupes.length) {
|
|
215
|
-
const message = `removed legacy entry from: ${removed.map(r => r.target).join(', ')}`;
|
|
216
|
-
return unreadableNote
|
|
217
|
-
? { id, name, status: 'warn', message: `${message}; ${unreadableNote}`, hint: HINTS.removeLegacySidecar }
|
|
218
|
-
: { id, name, status: 'ok', message, hint: null };
|
|
219
|
-
}
|
|
220
|
-
const message = `removed ${removed.length}/${dupes.length} duplicate(s) — could not update every config`;
|
|
221
|
-
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
|
|
222
|
-
}
|
|
223
|
-
const message = `duplicate 'sidecar' entry in ${dupes.map(e => e.target).join(', ')} — doubles the MCP tool list`;
|
|
224
|
-
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
|
|
225
|
-
}));
|
|
187
|
+
// Duplicate legacy 'sidecar' MCP registration check — logic lives in
|
|
188
|
+
// utils/doctor-mcp-checks.js (mirrors the B15 tmpSweep split) to keep this
|
|
189
|
+
// file under the 300-line size gate.
|
|
190
|
+
checks.push(guard('mcp-legacy', 'Legacy sidecar MCP entry', () => mcpChecks.evaluateLegacyMcpEntry(d)));
|
|
191
|
+
|
|
192
|
+
checks.push(guard('sessions-index-tmp', 'Session index tmp files', () => tmpSweep.evaluateSessionIndexTmpSweep(d)));
|
|
226
193
|
|
|
227
194
|
// #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
|
|
228
195
|
checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Resume/Continue Handlers (B21-rest extraction)
|
|
3
|
+
*
|
|
4
|
+
* Split out of src/cli-handlers-run.js (which stayed over the 300-line size
|
|
5
|
+
* gate once --json plumbing landed here) — same extraction rationale as the
|
|
6
|
+
* original WS-2 split of bin/amicus.js.
|
|
7
|
+
*
|
|
8
|
+
* Contains: handleResume, handleContinue
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { resolveModelFromArgs, validateFallbackModel } = require('./utils/start-helpers');
|
|
14
|
+
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
15
|
+
const { requireNoUiForJson, requireValidTaskId } = require('./utils/cli-preflight');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle 'amicus resume' command
|
|
19
|
+
* Spec Reference: §4.3
|
|
20
|
+
*/
|
|
21
|
+
async function handleResume(args) {
|
|
22
|
+
const useJson = !!args.json;
|
|
23
|
+
const taskId = requireValidTaskId(args, useJson, 'resume', 'Usage: amicus resume <task_id>');
|
|
24
|
+
requireNoUiForJson(args, useJson);
|
|
25
|
+
|
|
26
|
+
const { resumeAmicus } = require('./index');
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
return await resumeAmicus({
|
|
30
|
+
taskId,
|
|
31
|
+
project: args.cwd,
|
|
32
|
+
headless: args['no-ui'],
|
|
33
|
+
timeout: args.timeout,
|
|
34
|
+
json: useJson,
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// resumeSidecar throws a plain Error before it has a chance to consult
|
|
38
|
+
// `json` (e.g. the session directory doesn't exist) — under --json that
|
|
39
|
+
// must still land as ONE parseable envelope on stdout, not an uncaught
|
|
40
|
+
// throw. Non-json mode is unaffected: re-throw so bin/amicus.js's
|
|
41
|
+
// existing top-level catch prints `Error: <message>` exactly as before.
|
|
42
|
+
if (!useJson) { throw err; }
|
|
43
|
+
process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: err.message }));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Handle 'amicus continue' command
|
|
49
|
+
* Spec Reference: §4.4
|
|
50
|
+
*/
|
|
51
|
+
async function handleContinue(args) {
|
|
52
|
+
const useJson = !!args.json;
|
|
53
|
+
const taskId = requireValidTaskId(args, useJson, 'continue', 'Usage: amicus continue <task_id> --prompt "..."');
|
|
54
|
+
|
|
55
|
+
// BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
|
|
56
|
+
// follow-up prompt via file, dodging the ~32KB Windows command-line cap.
|
|
57
|
+
if (args['prompt-file'] !== undefined) {
|
|
58
|
+
const { resolvePromptSource } = require('./utils/prompt-source');
|
|
59
|
+
const promptRes = resolvePromptSource(args);
|
|
60
|
+
if (promptRes.error) {
|
|
61
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error }));
|
|
62
|
+
}
|
|
63
|
+
args.prompt = promptRes.prompt;
|
|
64
|
+
delete args['prompt-file'];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!args.prompt && !args.briefing) {
|
|
68
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: 'Error: --prompt is required for continue' }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
requireNoUiForJson(args, useJson);
|
|
72
|
+
|
|
73
|
+
// F5: an explicitly passed --model gets the same resolution+validation as start.
|
|
74
|
+
if (args.model !== undefined) {
|
|
75
|
+
const { model, alias } = resolveModelFromArgs(args);
|
|
76
|
+
args.model = model;
|
|
77
|
+
args.model = await validateFallbackModel(args, alias);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { continueAmicus } = require('./index');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return await continueAmicus({
|
|
84
|
+
taskId,
|
|
85
|
+
newTaskId: args['task-id'],
|
|
86
|
+
briefing: args.prompt || args.briefing,
|
|
87
|
+
model: args.model,
|
|
88
|
+
project: args.cwd,
|
|
89
|
+
contextTurns: args['context-turns'],
|
|
90
|
+
contextMaxTokens: args['context-max-tokens'],
|
|
91
|
+
headless: args['no-ui'],
|
|
92
|
+
timeout: args.timeout,
|
|
93
|
+
json: useJson,
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Same rationale as handleResume above: continueSidecar/loadPreviousSession
|
|
97
|
+
// throws before consulting `json` when the PREVIOUS session doesn't exist.
|
|
98
|
+
if (!useJson) { throw err; }
|
|
99
|
+
process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: err.message }));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { handleResume, handleContinue };
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* size gate and to make handlers unit-testable without running main().
|
|
6
6
|
*
|
|
7
7
|
* Contains: handleStart, handleFanout, handleRead
|
|
8
|
-
*
|
|
8
|
+
* See also: src/cli-handlers-resume-continue.js (handleResume, handleContinue —
|
|
9
|
+
* split out to stay under the size gate) and src/cli-handlers.js (handleList
|
|
10
|
+
* remains inline in bin/amicus.js).
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
'use strict';
|
|
@@ -14,6 +16,7 @@ const { validateStartArgs } = require('./cli');
|
|
|
14
16
|
const { validateTaskId } = require('./utils/validators');
|
|
15
17
|
const { resolveModelFromArgs, validateFallbackModel } = require('./utils/start-helpers');
|
|
16
18
|
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
19
|
+
const { requireNoUiForJson } = require('./utils/cli-preflight');
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Handle 'sidecar start' command
|
|
@@ -33,9 +36,7 @@ async function handleStart(args) {
|
|
|
33
36
|
// prompt-file set and trip its mutually-exclusive branch.
|
|
34
37
|
delete args['prompt-file'];
|
|
35
38
|
}
|
|
36
|
-
|
|
37
|
-
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
|
|
38
|
-
}
|
|
39
|
+
requireNoUiForJson(args, useJson);
|
|
39
40
|
|
|
40
41
|
const mc = args['max-cost'];
|
|
41
42
|
if (mc !== undefined && (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0)) {
|
|
@@ -68,9 +69,9 @@ async function handleStart(args) {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
const {
|
|
72
|
+
const { startAmicus } = require('./index');
|
|
72
73
|
|
|
73
|
-
return await
|
|
74
|
+
return await startAmicus({
|
|
74
75
|
taskId: args['task-id'],
|
|
75
76
|
model: args.model,
|
|
76
77
|
prompt: args.prompt,
|
|
@@ -212,9 +213,9 @@ async function handleRead(args) {
|
|
|
212
213
|
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: taskIdCheck.error }));
|
|
213
214
|
}
|
|
214
215
|
|
|
215
|
-
const {
|
|
216
|
+
const { readAmicus } = require('./index');
|
|
216
217
|
|
|
217
|
-
await
|
|
218
|
+
await readAmicus({
|
|
218
219
|
taskId,
|
|
219
220
|
conversation: args.conversation,
|
|
220
221
|
metadata: args.metadata,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// src/cli-handlers-spend.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `amicus spend` — cross-run cost rollup over spend-ledger.jsonl (B24).
|
|
6
|
+
* Mirrors cli-handlers-doctor.js's injectable-deps shape (a `depsOverride`
|
|
7
|
+
* parameter tests can use to stub I/O/network) and cli-handlers-council.js's
|
|
8
|
+
* command-file split (kept its own file to stay well under the size gate).
|
|
9
|
+
*
|
|
10
|
+
* buildSpendDoc() lives HERE rather than in src/utils/result-schema.js: that
|
|
11
|
+
* module is at its 300-line size-gate ceiling (filled by a parallel lane in
|
|
12
|
+
* this same phase), so this module defines its own doc builder using the
|
|
13
|
+
* SAME schemaVersion convention (result-schema's SCHEMA_VERSION, imported —
|
|
14
|
+
* not a forked/independent counter) rather than adding to a full file. If
|
|
15
|
+
* result-schema.js is ever split/slimmed, buildSpendDoc is the one to fold
|
|
16
|
+
* back in alongside buildCatalogDoc/buildDoctorDoc.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { readSpendRows } = require('./utils/spend-ledger');
|
|
20
|
+
const { formatCost } = require('./utils/pricing');
|
|
21
|
+
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
22
|
+
|
|
23
|
+
const CREDIT_CHECK_TIMEOUT_MS = 5000;
|
|
24
|
+
|
|
25
|
+
/** @param {string} since e.g. '7d' @returns {number|null} whole days, or null if unparseable */
|
|
26
|
+
function parseSinceDays(since) {
|
|
27
|
+
if (typeof since !== 'string') { return null; }
|
|
28
|
+
const m = since.trim().match(/^(\d+)d$/i);
|
|
29
|
+
return m ? parseInt(m[1], 10) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function emptyTokens() {
|
|
33
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function addTokens(into, tokens) {
|
|
37
|
+
if (!tokens) { return; }
|
|
38
|
+
for (const k of Object.keys(into)) { into[k] += tokens[k] || 0; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Aggregate ledger rows into a total + per-model rollup, most-expensive-first.
|
|
43
|
+
* A row with a null cost.amount contributes 0 to totals but is still counted
|
|
44
|
+
* in `runs` and its source bucket — visibility into "how many runs are
|
|
45
|
+
* unpriced" matters as much as the dollar figure.
|
|
46
|
+
* @param {Array<object>} rows
|
|
47
|
+
*/
|
|
48
|
+
function aggregateSpend(rows) {
|
|
49
|
+
const total = { amount: 0, tokens: emptyTokens(), runs: rows.length, sourceMix: { reported: 0, estimated: 0, unknown: 0 } };
|
|
50
|
+
const byModelMap = new Map();
|
|
51
|
+
for (const r of rows) {
|
|
52
|
+
const model = r.model || 'unknown';
|
|
53
|
+
if (!byModelMap.has(model)) {
|
|
54
|
+
byModelMap.set(model, { model, amount: 0, tokens: emptyTokens(), runs: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } });
|
|
55
|
+
}
|
|
56
|
+
const bucket = byModelMap.get(model);
|
|
57
|
+
bucket.runs += 1;
|
|
58
|
+
addTokens(bucket.tokens, r.tokens);
|
|
59
|
+
addTokens(total.tokens, r.tokens);
|
|
60
|
+
const cost = r.cost || {};
|
|
61
|
+
const amount = typeof cost.amount === 'number' ? cost.amount : 0;
|
|
62
|
+
bucket.amount += amount;
|
|
63
|
+
total.amount += amount;
|
|
64
|
+
// Any source string outside {reported,estimated} buckets as unknown —
|
|
65
|
+
// covers 'unknown', a missing/malformed cost block, or a future source.
|
|
66
|
+
const src = (cost.source === 'reported' || cost.source === 'estimated') ? cost.source : 'unknown';
|
|
67
|
+
bucket.sourceMix[src] += 1;
|
|
68
|
+
total.sourceMix[src] += 1;
|
|
69
|
+
}
|
|
70
|
+
const byModel = [...byModelMap.values()].sort((a, b) => b.amount - a.amount);
|
|
71
|
+
return { total, byModel };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the `--json` spend document. schemaVersion reuses result-schema's
|
|
76
|
+
* SCHEMA_VERSION (not a forked counter) — see module docblock for why this
|
|
77
|
+
* builder lives here instead of alongside buildCatalogDoc/buildDoctorDoc.
|
|
78
|
+
* @param {{total:object, byModel:Array, windowDays:number|null, credit:object|null}} opts
|
|
79
|
+
*/
|
|
80
|
+
function buildSpendDoc({ total, byModel, windowDays, credit }) {
|
|
81
|
+
const { SCHEMA_VERSION } = require('./utils/result-schema');
|
|
82
|
+
return {
|
|
83
|
+
schemaVersion: SCHEMA_VERSION,
|
|
84
|
+
type: 'spend',
|
|
85
|
+
windowDays: windowDays !== undefined ? windowDays : null,
|
|
86
|
+
total,
|
|
87
|
+
byModel,
|
|
88
|
+
credit: credit || null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Real deps; tests override via the second handleSpend arg. */
|
|
93
|
+
function realDeps() {
|
|
94
|
+
return {
|
|
95
|
+
dir: undefined, // readSpendRows falls back to getConfigDir() itself
|
|
96
|
+
readApiKeyValues: () => require('./utils/api-key-store').readApiKeyValues(),
|
|
97
|
+
checkOpenRouterCredit: (key) => require('./utils/api-key-validation').checkOpenRouterCredit(key),
|
|
98
|
+
now: () => Date.now(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Race a promise against a hard timeout; resolves `fallback` on timeout — never rejects, never hangs the caller. */
|
|
103
|
+
function withTimeout(promise, ms, fallback) {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
let settled = false;
|
|
106
|
+
const timer = setTimeout(() => { if (!settled) { settled = true; resolve(fallback); } }, ms);
|
|
107
|
+
Promise.resolve(promise).then(
|
|
108
|
+
(v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } },
|
|
109
|
+
() => { if (!settled) { settled = true; clearTimeout(timer); resolve(fallback); } },
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Best-effort OpenRouter credit footer. Never throws; never blocks past
|
|
116
|
+
* ~CREDIT_CHECK_TIMEOUT_MS; skipped (returns null) when no key is configured.
|
|
117
|
+
*/
|
|
118
|
+
async function fetchCreditFooter(deps) {
|
|
119
|
+
let values = {};
|
|
120
|
+
try { values = deps.readApiKeyValues() || {}; } catch { /* best-effort */ }
|
|
121
|
+
const key = values.openrouter;
|
|
122
|
+
if (!key) { return null; }
|
|
123
|
+
const res = await withTimeout(
|
|
124
|
+
Promise.resolve().then(() => deps.checkOpenRouterCredit(key)),
|
|
125
|
+
CREDIT_CHECK_TIMEOUT_MS,
|
|
126
|
+
null,
|
|
127
|
+
);
|
|
128
|
+
return res || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderHuman({ total, byModel, windowDays, credit }) {
|
|
132
|
+
if (total.runs === 0) { return 'No spend recorded yet.\n'; }
|
|
133
|
+
let out = windowDays ? `amicus spend (last ${windowDays}d)\n\n` : 'amicus spend (all time)\n\n';
|
|
134
|
+
out += 'model runs tokens(in/out) cost sources\n';
|
|
135
|
+
for (const m of byModel) {
|
|
136
|
+
const mix = `r${m.sourceMix.reported}/e${m.sourceMix.estimated}/u${m.sourceMix.unknown}`;
|
|
137
|
+
const tokCol = `${m.tokens.input}/${m.tokens.output}`;
|
|
138
|
+
out += `${String(m.model).slice(0, 48).padEnd(48)} ${String(m.runs).padStart(4)} ` +
|
|
139
|
+
`${tokCol.padStart(15)} ` +
|
|
140
|
+
`${formatCost({ amount: m.amount, source: dominantSource(m.sourceMix) }).padStart(9)} ${mix}\n`;
|
|
141
|
+
}
|
|
142
|
+
out += `\nTotal: ${formatCost({ amount: total.amount, source: dominantSource(total.sourceMix) })} across ${total.runs} run(s)\n`;
|
|
143
|
+
if (credit && typeof credit.limitRemaining === 'number') {
|
|
144
|
+
out += `OpenRouter credit remaining: $${credit.limitRemaining}\n`;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Pick a representative source tag for formatCost's ~ prefix: mixed if >1 bucket populated. */
|
|
150
|
+
function dominantSource(mix) {
|
|
151
|
+
const populated = Object.entries(mix).filter(([, n]) => n > 0).map(([k]) => k);
|
|
152
|
+
if (populated.length === 0) { return 'unknown'; }
|
|
153
|
+
if (populated.length > 1) { return 'mixed'; }
|
|
154
|
+
return populated[0];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `amicus spend [--since 7d] [--json]`
|
|
159
|
+
* @param {{_:string[], json?:boolean, since?:string}} args
|
|
160
|
+
* @param {object} [depsOverride] test seam
|
|
161
|
+
* @returns {Promise<number>} exit code
|
|
162
|
+
*/
|
|
163
|
+
async function handleSpend(args, depsOverride = {}) {
|
|
164
|
+
const useJson = !!args.json;
|
|
165
|
+
const deps = { ...realDeps(), ...depsOverride };
|
|
166
|
+
|
|
167
|
+
let windowDays = null;
|
|
168
|
+
if (args.since !== undefined) {
|
|
169
|
+
windowDays = parseSinceDays(args.since);
|
|
170
|
+
if (windowDays === null) {
|
|
171
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `invalid --since '${args.since}'`,
|
|
172
|
+
hint: "amicus spend --since 7d (an integer followed by 'd')" });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let rows = readSpendRows(deps.dir);
|
|
177
|
+
if (windowDays !== null) {
|
|
178
|
+
const cutoff = deps.now() - windowDays * 86400000;
|
|
179
|
+
rows = rows.filter(r => {
|
|
180
|
+
const t = Date.parse(r.ts);
|
|
181
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { total, byModel } = aggregateSpend(rows);
|
|
186
|
+
// Nothing recorded (or nothing in the --since window): skip the network
|
|
187
|
+
// credit probe entirely — there's no rollup to attach it to either way.
|
|
188
|
+
const credit = total.runs === 0 ? null : await fetchCreditFooter(deps).catch(() => null);
|
|
189
|
+
|
|
190
|
+
if (useJson) {
|
|
191
|
+
process.stdout.write(JSON.stringify(buildSpendDoc({ total, byModel, windowDays, credit }), null, 2) + '\n');
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
process.stdout.write(renderHuman({ total, byModel, windowDays, credit }));
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { handleSpend, aggregateSpend, buildSpendDoc, parseSinceDays };
|
package/src/cli-handlers.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* under the 300-line limit.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
// handleAbort moved to src/cli-handlers-abort.js (B21-rest: --json branch
|
|
11
|
+
// needed headroom this file didn't have). Re-exported below for compatibility.
|
|
12
|
+
const { handleAbort } = require('./cli-handlers-abort');
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Handle 'amicus setup' command
|
|
@@ -64,123 +66,6 @@ async function handleSetup(args) {
|
|
|
64
66
|
await runInteractiveSetup();
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
/**
|
|
68
|
-
* Handle 'sidecar abort' command
|
|
69
|
-
* Marks a running session as aborted
|
|
70
|
-
*/
|
|
71
|
-
async function handleAbort(args) {
|
|
72
|
-
if (args.all) {
|
|
73
|
-
const project = args.cwd || process.cwd();
|
|
74
|
-
const { enumerateSessions } = require('./sidecar/read');
|
|
75
|
-
const { markAborted } = require('./utils/session-abort');
|
|
76
|
-
const { resolveExistingSessionDir } = require('./session-manager');
|
|
77
|
-
// A session may complete between enumeration and the write (TOCTOU); the
|
|
78
|
-
// window is tiny for a local CLI and markAborted is best-effort, so we count
|
|
79
|
-
// only sessions actually marked aborted.
|
|
80
|
-
const running = enumerateSessions(project, { status: 'running' });
|
|
81
|
-
if (running.length === 0) {
|
|
82
|
-
console.log('No running sessions to abort.');
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
let aborted = 0;
|
|
86
|
-
for (const s of running) {
|
|
87
|
-
if (markAborted(resolveExistingSessionDir(project, s.id), 'abort --all')) {
|
|
88
|
-
aborted++;
|
|
89
|
-
console.log(`Aborted ${s.id}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
console.log(`Aborted ${aborted} running session(s).`);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const taskId = args._[1];
|
|
97
|
-
|
|
98
|
-
if (!taskId) {
|
|
99
|
-
console.error('Error: task_id is required for abort');
|
|
100
|
-
console.error('Usage: amicus abort <task_id>');
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const taskIdCheck = validateTaskId(taskId);
|
|
105
|
-
if (!taskIdCheck.valid) {
|
|
106
|
-
console.error(taskIdCheck.error);
|
|
107
|
-
process.exit(1);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const project = args.cwd || process.cwd();
|
|
111
|
-
const sessionDir = safeSessionDir(project, taskId);
|
|
112
|
-
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
113
|
-
|
|
114
|
-
if (!fs.existsSync(metaPath)) {
|
|
115
|
-
console.error(`Session ${taskId} not found`);
|
|
116
|
-
process.exit(1);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
let meta;
|
|
120
|
-
try {
|
|
121
|
-
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
122
|
-
} catch (_err) {
|
|
123
|
-
console.error(`Session ${taskId} has malformed metadata`);
|
|
124
|
-
process.exit(1);
|
|
125
|
-
}
|
|
126
|
-
// Guard against a completed/terminal session: without this, metadata.pid
|
|
127
|
-
// still holds a value forever and `amicus abort <completed-task>` would
|
|
128
|
-
// wait the grace window then TerminateProcess whatever unrelated process
|
|
129
|
-
// now owns that (possibly recycled) pid. Mirrors MCP's amicus_abort guard
|
|
130
|
-
// (src/mcp-server.js) — same wording, no re-mark, no kill.
|
|
131
|
-
if (meta.status !== 'running') {
|
|
132
|
-
console.log(`Session ${taskId} is not running (status: ${meta.status}).`);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const { markAborted } = require('./utils/session-abort');
|
|
137
|
-
|
|
138
|
-
// F4: aborting a wave aborts every still-running leg too.
|
|
139
|
-
if (meta.type === 'wave') {
|
|
140
|
-
const { resolveExistingSessionDir } = require('./session-manager');
|
|
141
|
-
let aborted = 0;
|
|
142
|
-
for (const legId of meta.legs || []) {
|
|
143
|
-
const legDir = resolveExistingSessionDir(project, legId);
|
|
144
|
-
try {
|
|
145
|
-
const legMeta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8'));
|
|
146
|
-
// TOCTOU: a leg may complete between this read and markAborted —
|
|
147
|
-
// best-effort, same contract as abort --all above.
|
|
148
|
-
if (legMeta.status === 'running') {
|
|
149
|
-
if (markAborted(legDir, 'wave abort')) { aborted++; }
|
|
150
|
-
}
|
|
151
|
-
} catch { /* skip unreadable leg */ }
|
|
152
|
-
}
|
|
153
|
-
markAborted(sessionDir, 'manual abort');
|
|
154
|
-
console.log(`Wave ${taskId} marked as aborted (${aborted} running leg(s) aborted).`);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
markAborted(sessionDir, 'manual abort');
|
|
159
|
-
console.log(`Session ${taskId} marked as aborted.`);
|
|
160
|
-
|
|
161
|
-
// Phase 3: fallback direct-kill for a session that does not honor the
|
|
162
|
-
// marker. Headless loops poll the marker every ~2s and the interactive
|
|
163
|
-
// abort watch does too, so the normal outcome is a graceful exit during
|
|
164
|
-
// the grace window; only a wedged/legacy process gets SIGTERM. The wait is
|
|
165
|
-
// awaited on purpose — bin/amicus.js arms its force-exit watchdog only
|
|
166
|
-
// after this handler returns.
|
|
167
|
-
if (meta.pid) {
|
|
168
|
-
const { waitThenKill, abortGraceMs } = require('./utils/abort-coordinator');
|
|
169
|
-
const graceSec = Math.ceil(abortGraceMs() / 1000);
|
|
170
|
-
console.log(`Waiting up to ${graceSec}s for the session process (pid ${meta.pid}) to exit gracefully...`);
|
|
171
|
-
const { killed, exited } = await waitThenKill(meta.pid);
|
|
172
|
-
if (killed.length > 0) {
|
|
173
|
-
console.log(`Process ${meta.pid} did not exit in time — sent SIGTERM (a hard kill on Windows).`);
|
|
174
|
-
} else if (exited.length > 0) {
|
|
175
|
-
console.log('Process exited cleanly.');
|
|
176
|
-
} else {
|
|
177
|
-
// 3.1 contract: an EPERM-unkillable pid lands in NEITHER array —
|
|
178
|
-
// it is still alive and we could not signal it. Say so honestly.
|
|
179
|
-
console.log(`Process ${meta.pid} is still running — could not signal it (insufficient permission). It may require manual termination.`);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
69
|
/**
|
|
185
70
|
* Handle 'amicus update' command
|
|
186
71
|
* Updates amicus to the latest version
|