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.
Files changed (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +200 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +19 -107
  5. package/commands/council.md +7 -3
  6. package/electron/fold.js +10 -1
  7. package/electron/ipc-setup.js +10 -15
  8. package/electron/main.js +21 -16
  9. package/electron/preload-setup.js +0 -1
  10. package/electron/setup-ui-council.js +64 -10
  11. package/electron/setup-ui-styles.js +34 -3
  12. package/electron/setup-ui.js +44 -12
  13. package/package.json +2 -5
  14. package/skills/second-opinion/MODEL-NOTES.md +2 -2
  15. package/skills/second-opinion/SKILL.md +30 -28
  16. package/skills/sidecar/SKILL.md +20 -17
  17. package/src/cli-handlers-abort.js +244 -0
  18. package/src/cli-handlers-council.js +101 -1
  19. package/src/cli-handlers-doctor.js +20 -53
  20. package/src/cli-handlers-resume-continue.js +103 -0
  21. package/src/cli-handlers-run.js +9 -8
  22. package/src/cli-handlers-spend.js +198 -0
  23. package/src/cli-handlers.js +5 -120
  24. package/src/cli.js +55 -0
  25. package/src/council/presets-cli.js +141 -0
  26. package/src/headless.js +146 -38
  27. package/src/index.js +1 -9
  28. package/src/mcp-server.js +140 -113
  29. package/src/mcp-tools.js +58 -24
  30. package/src/mcp-wait.js +8 -5
  31. package/src/opencode-client.js +33 -10
  32. package/src/prompt-builder.js +32 -11
  33. package/src/session-manager.js +7 -14
  34. package/src/sidecar/continue.js +34 -12
  35. package/src/sidecar/conversation-mirror.js +22 -1
  36. package/src/sidecar/crash-handler.js +2 -1
  37. package/src/sidecar/fanout-leg.js +12 -3
  38. package/src/sidecar/fanout.js +27 -10
  39. package/src/sidecar/interactive-process.js +6 -17
  40. package/src/sidecar/interactive.js +5 -6
  41. package/src/sidecar/models.js +33 -4
  42. package/src/sidecar/progress.js +2 -1
  43. package/src/sidecar/read.js +4 -6
  44. package/src/sidecar/resume.js +41 -11
  45. package/src/sidecar/session-finalize.js +2 -1
  46. package/src/sidecar/session-utils.js +13 -35
  47. package/src/sidecar/setup-window.js +2 -3
  48. package/src/sidecar/start.js +22 -7
  49. package/src/utils/abort-coordinator.js +57 -7
  50. package/src/utils/abort-result.js +36 -0
  51. package/src/utils/api-key-store.js +2 -13
  52. package/src/utils/cli-preflight.js +43 -0
  53. package/src/utils/config.js +30 -43
  54. package/src/utils/council-presets.js +87 -0
  55. package/src/utils/doctor-mcp-checks.js +84 -0
  56. package/src/utils/env-loader.js +1 -2
  57. package/src/utils/fold-marker.js +79 -0
  58. package/src/utils/idle-watchdog.js +9 -12
  59. package/src/utils/input-validators.js +52 -1
  60. package/src/utils/lifecycle.js +1 -1
  61. package/src/utils/mcp-discovery.js +80 -19
  62. package/src/utils/mcp-self-identity.js +12 -5
  63. package/src/utils/model-catalog.js +54 -6
  64. package/src/utils/read-slice.js +73 -0
  65. package/src/utils/remediation-hints.js +9 -0
  66. package/src/utils/result-schema-version.js +14 -0
  67. package/src/utils/result-schema.js +18 -12
  68. package/src/utils/session-abort.js +1 -1
  69. package/src/utils/session-index-tmp-sweep.js +80 -0
  70. package/src/utils/session-index.js +4 -5
  71. package/src/utils/session-path.js +6 -10
  72. package/src/utils/shared-server.js +7 -5
  73. package/src/utils/spend-ledger.js +80 -0
  74. package/src/utils/updater.js +2 -3
  75. 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 (same server twice doubles
187
- // the client-visible tool list). Detection reads the raw config files via
188
- // legacy-mcp-migration: mcp-discovery can't see it (it strips 'sidecar' as
189
- // its own recursion guard). --fix removes only identical-in-effect twins.
190
- checks.push(guard('mcp-legacy', 'Legacy sidecar MCP entry', () => {
191
- const id = 'mcp-legacy'; const name = 'Legacy sidecar MCP entry';
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 };
@@ -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
- * Remaining inline in bin/amicus.js: handleList, handleResume, handleContinue
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
- if (args.json && !args['no-ui']) {
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 { startSidecar } = require('./index');
72
+ const { startAmicus } = require('./index');
72
73
 
73
- return await startSidecar({
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 { readSidecar } = require('./index');
216
+ const { readAmicus } = require('./index');
216
217
 
217
- await readSidecar({
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 };
@@ -5,9 +5,11 @@
5
5
  * under the 300-line limit.
6
6
  */
7
7
 
8
- const fs = require('fs');
9
- const path = require('path');
10
- const { validateTaskId, safeSessionDir } = require('./utils/validators');
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