amicus 1.7.7 → 1.8.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.7",
3
+ "version": "1.8.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.8.0] - 2026-07-02
9
+
10
+ ### Added
11
+ - **`amicus_wait` MCP tool: blocking wait for a session or fan-out wave.** Blocks inside one tool call until the
12
+ target reaches a terminal state or the wait window closes, replacing the sleep+`amicus_status` polling loop with
13
+ a single call. Returns the same JSON shape as `amicus_status` plus `waitedMs` and `{timedOut: true}` (with a
14
+ `hint`) on expiry — re-call it while it keeps returning `timedOut: true`. Works for sessions or waves started by
15
+ other processes, not just the caller. Torn-read tolerant: a transient read of `metadata.json` mid-write is
16
+ treated as a missed poll tick, not a hard failure. Legacy alias `sidecar_wait` is available under
17
+ `AMICUS_LEGACY_ALIASES=1`.
18
+ - **Agent-visible progress.** A new `amicus status <task_id>` (or `--wave <id>`) one-shot CLI command delegates
19
+ directly to the MCP status handler — same crash detection and wave-leg rollup, zero duplicated logic.
20
+ `amicus_status` and `amicus_list` are enriched with agent-facing `mode`, `phase`, `messageCount`,
21
+ `lastActivityAt`, and `latestPreview` (the pinned raw `stage` field is unchanged for back-compat; wave legs
22
+ additionally surface the raw `stage` alongside the coarse `phase`). Interactive (Electron GUI) runs now write
23
+ the same lifecycle progress stages headless runs always have (`initializing`, `server_ready`, `session_created`,
24
+ `prompt_sent`), and long-thinking turns emit periodic thinking-delta progress ticks instead of at most one ever
25
+ — so a live GUI run no longer reads "Starting up... | 0 messages" forever.
26
+ - **`amicus doctor` duplicate-registration check.** A new `mcp-legacy` check flags plugin-channel installs
27
+ (`AMICUS_SKIP_POSTINSTALL=1`) that never ran the postinstall migration and still carry a duplicate legacy
28
+ `sidecar` MCP registration; `doctor --fix` cleans it up.
29
+
30
+ ### Fixed
31
+ - **`amicus abort` now actually stops interactive sessions and wave legs.** Marker-first, honest output — reports
32
+ what really happened including the unkillable-pid case — and no-ops cleanly with a clear message when the
33
+ target isn't running.
34
+ - **Legacy-MCP remediation's `claude mcp add-json` (CLI) path no longer drops a user's custom `env`** on
35
+ re-registration — it now merges the previous registration's `env` the same way the file-fallback path already did.
36
+
37
+ ### Changed
38
+ - **Legacy `sidecar_*` MCP tool aliases are now opt-in** via `AMICUS_LEGACY_ALIASES=1` (breaking-adjacent —
39
+ carrying release must be a MINOR, v1.8.0). The default client-visible surface is the `amicus_*` toolset (14
40
+ tools as of this release); saved allowlists that still reference `mcp__amicus__sidecar_*` stop resolving unless
41
+ you opt back in.
42
+ - **Postinstall no longer registers a separate `sidecar` MCP server** and auto-removes a verified-identical
43
+ duplicate left over from pre-1.8 installs. A customized `sidecar` entry or a sole `sidecar` registration (no
44
+ `amicus` twin) is never touched.
45
+
8
46
  ## [1.7.7] - 2026-07-01
9
47
 
10
48
  Correctness patch from the 2026-07-01 full product review (multi-agent review, every finding adversarially
package/README.md CHANGED
@@ -259,6 +259,7 @@ amicus update
259
259
  | `amicus resume` | Reopen a previous session with full history. |
260
260
  | `amicus continue` | Start a new session building on a previous one. |
261
261
  | `amicus read` | Output a session's summary / conversation / metadata. |
262
+ | `amicus status <id>` | One-shot status for a session or fan-out wave (human or `--json`; `--wave <id>` alternative spelling). |
262
263
  | `amicus models` | List, search, refresh the catalog, or audit aliases. |
263
264
  | `amicus abort` | Abort a running session (or `--all`). |
264
265
  | `amicus setup` | Configure default model, API keys, and aliases. |
@@ -329,6 +330,10 @@ amicus read <id> --conversation # full conversation
329
330
  amicus read <id> --metadata # session metadata
330
331
  amicus read <id> --json # stable JSON (run or wave document)
331
332
 
333
+ amicus status <id> # one-shot status for a session or wave
334
+ amicus status --wave <id> # alternative spelling for a wave ID
335
+ amicus status <id> --json # machine-readable output
336
+
332
337
  amicus resume <id> # reopen with full history
333
338
  amicus continue <id> --prompt "..." # new session, previous one as read-only context
334
339
 
@@ -371,12 +376,13 @@ amicus models --check # audit your aliases against the catalog
371
376
 
372
377
  ## MCP integration
373
378
 
374
- The MCP server is auto-registered on install (Claude Code and Claude Desktop / Cowork). It exposes ten tools:
379
+ The MCP server is auto-registered on install (Claude Code and Claude Desktop / Cowork). It exposes fourteen tools:
375
380
 
376
381
  | Tool | What it does |
377
382
  |------|--------------|
378
383
  | `amicus_start` | Spawn a session; returns a task ID immediately. |
379
384
  | `amicus_status` | Poll a task (or a fanout wave) for completion. |
385
+ | `amicus_wait` | Block inside one tool call until a session/wave finishes or the wait window closes. |
380
386
  | `amicus_read` | Read results: summary, conversation, metadata, or JSON. |
381
387
  | `amicus_list` | List past sessions. |
382
388
  | `amicus_resume` | Reopen a session. |
@@ -385,8 +391,11 @@ The MCP server is auto-registered on install (Claude Code and Claude Desktop / C
385
391
  | `amicus_setup` | Open the setup wizard. |
386
392
  | `amicus_guide` | Return usage guidance (model choice, briefings, polling). |
387
393
  | `amicus_fanout` | Launch a same-prompt wave; returns `{ waveId, taskIds[] }`. |
394
+ | `amicus_council_tally` | Aggregate a council wave's reviews into a scored tally. |
395
+ | `amicus_council_stats` | Reviewer-reliability stats from past council runs. |
396
+ | `amicus_verdict` | Build the final council verdict from a tally + decisions. |
388
397
 
389
- The async pattern is **start → status → read**: `amicus_start` (or `amicus_fanout`) returns immediately, you poll `amicus_status`, then `amicus_read` once it's done — so the calling agent never blocks.
398
+ The async pattern is **start → status → read**: `amicus_start` (or `amicus_fanout`) returns immediately, you poll `amicus_status`, then `amicus_read` once it's done — so the calling agent never blocks. Prefer `amicus_wait` over manual sleep+status polling for headless runs: it collapses the poll loop into a single blocking call that returns as soon as the run finishes (or the wait window closes).
390
399
 
391
400
  To register manually (user scope):
392
401
 
@@ -394,7 +403,7 @@ To register manually (user scope):
394
403
  claude mcp add-json amicus '{"command":"npx","args":["-y","amicus@latest","mcp"]}' --scope user
395
404
  ```
396
405
 
397
- > Legacy `sidecar_*` tool names are still registered as aliases of each `amicus_*` tool for backward compatibility.
406
+ > Legacy `sidecar_*` tool names are no longer registered by default (v1.8.0). To restore them, add `"env": {"AMICUS_LEGACY_ALIASES": "1"}` to the server entry. They will be removed entirely in the next major.
398
407
 
399
408
  ---
400
409
 
package/bin/amicus.js CHANGED
@@ -94,6 +94,11 @@ async function main() {
94
94
  case 'list':
95
95
  await handleList(args);
96
96
  break;
97
+ case 'status': {
98
+ const { handleStatus } = require('../src/cli-handlers-status');
99
+ exitCode = await handleStatus(args);
100
+ break;
101
+ }
97
102
  case 'resume':
98
103
  exitCode = await handleResume(args);
99
104
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.7",
3
+ "version": "1.8.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "keywords": [
6
6
  "claude",
@@ -15,6 +15,7 @@ const { execFileSync } = require('child_process');
15
15
 
16
16
  const { repairElectron } = require('../src/sidecar/electron-install');
17
17
  const HINTS = require('../src/utils/remediation-hints');
18
+ const { isAmicusMcpConfig } = require('../src/utils/mcp-self-identity');
18
19
 
19
20
  const SETUP_HOOKS_SCRIPT = path.join(__dirname, 'setup-hooks.js');
20
21
 
@@ -41,7 +42,18 @@ const MCP_CONFIG = { command: 'npx', args: ['-y', 'amicus@latest', 'mcp'] };
41
42
 
42
43
  /**
43
44
  * Add or update an MCP server in a JSON config file.
44
- * Always overwrites the entry to ensure upgrades apply the latest config.
45
+ *
46
+ * Refreshes command/args on every install/upgrade. If the EXISTING entry at
47
+ * this key is already amicus-shaped (per isAmicusMcpConfig — Phase 1's single
48
+ * source of truth for "this is amicus"), we MERGE instead of overwrite: the
49
+ * user's `env` (and any other extra keys, e.g. a future `cwd`) survive the
50
+ * refresh. Without this, `npm i -g amicus` silently wiped
51
+ * "env": {"AMICUS_LEGACY_ALIASES":"1"} — the exact opt-in escape hatch Phase 4
52
+ * tells users to add — on every upgrade.
53
+ *
54
+ * A NON-amicus-shaped entry at this key is overwritten as before: 'amicus' is
55
+ * a reserved registration name, so a foreign entry there is reclaimed rather
56
+ * than merged with.
45
57
  *
46
58
  * @param {string} configPath - Path to the JSON config file
47
59
  * @param {string} name - MCP server name
@@ -59,9 +71,10 @@ function addMcpToConfigFile(configPath, name, config) {
59
71
  if (!existing.mcpServers) { existing.mcpServers = {}; }
60
72
 
61
73
  const prev = existing.mcpServers[name];
62
- const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(config) ? 'updated' : 'unchanged';
74
+ const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...config } : config;
75
+ const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(nextConfig) ? 'updated' : 'unchanged';
63
76
 
64
- existing.mcpServers[name] = config;
77
+ existing.mcpServers[name] = nextConfig;
65
78
  if (status !== 'unchanged') {
66
79
  const dir = path.dirname(configPath);
67
80
  if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); }
@@ -111,28 +124,42 @@ function installCouncilSkill(sourceDir = COUNCIL_SOURCE_DIR) {
111
124
  }
112
125
  }
113
126
 
127
+ /**
128
+ * Read the previous 'amicus' entry from ~/.claude.json, if any — used by the
129
+ * CLI add-json path to merge env the same way the file-fallback path does.
130
+ * Never throws: a missing/unreadable file just means "no previous entry".
131
+ * @returns {object|undefined}
132
+ */
133
+ function readPrevClaudeCodeAmicusEntry() {
134
+ try {
135
+ const parsed = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf-8'));
136
+ return parsed && parsed.mcpServers ? parsed.mcpServers.amicus : undefined;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
114
142
  /** Register MCP server in Claude Code config */
115
143
  function registerClaudeCode() {
116
144
  // Try the CLI first
117
145
  try {
118
- const mcpJson = JSON.stringify(MCP_CONFIG);
146
+ // Merge the PREVIOUS registration's env into the add-json payload — same
147
+ // merge semantics as addMcpToConfigFile's file-fallback path (`{ ...prev,
148
+ // ...config }`: prev env keys survive, canonical MCP_CONFIG keys win on
149
+ // collision). Without this, a user's custom env (API key, AMICUS_* tuning
150
+ // knobs) on the old registration was silently dropped whenever the
151
+ // `claude` CLI was present, because the CLI path built its JSON payload
152
+ // from the bare MCP_CONFIG and delegated overwrite semantics to the
153
+ // claude binary — which has no idea about the user's previous entry.
154
+ const prev = readPrevClaudeCodeAmicusEntry();
155
+ const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...MCP_CONFIG } : MCP_CONFIG;
156
+ const mcpJson = JSON.stringify(nextConfig);
119
157
  execFileSync('claude', ['mcp', 'add-json', 'amicus', mcpJson, '--scope', 'user'], {
120
158
  stdio: 'pipe',
121
159
  timeout: 10000,
122
160
  });
123
161
  console.log('[amicus] MCP registered in Claude Code (via CLI).');
124
162
 
125
- // DEPRECATED(amicus-shim): also register 'sidecar' so existing clients that
126
- // reference the old server name keep resolving. Remove in next major.
127
- try {
128
- execFileSync('claude', ['mcp', 'add-json', 'sidecar', mcpJson, '--scope', 'user'], {
129
- stdio: 'pipe',
130
- timeout: 10000,
131
- });
132
- } catch {
133
- // Best-effort; ignore failures for the shim registration
134
- }
135
-
136
163
  return;
137
164
  } catch {
138
165
  // CLI not available or failed — fall back to file edit
@@ -148,10 +175,6 @@ function registerClaudeCode() {
148
175
  } else {
149
176
  console.log('[amicus] MCP already registered in Claude Code.');
150
177
  }
151
-
152
- // DEPRECATED(amicus-shim): also register 'sidecar' entry so existing clients
153
- // that reference the old server name keep resolving. Remove in next major.
154
- addMcpToConfigFile(claudeConfigPath, 'sidecar', MCP_CONFIG);
155
178
  }
156
179
 
157
180
  /** Register MCP server in Claude Desktop / Cowork config */
@@ -174,10 +197,33 @@ function registerClaudeDesktop() {
174
197
  } else {
175
198
  console.log('[amicus] MCP already registered in Claude Desktop.');
176
199
  }
200
+ }
177
201
 
178
- // DEPRECATED(amicus-shim): also register 'sidecar' entry so existing clients
179
- // that reference the old server name keep resolving. Remove in next major.
180
- addMcpToConfigFile(configPath, 'sidecar', MCP_CONFIG);
202
+ /**
203
+ * One-shot migration: drop the duplicate legacy 'sidecar' MCP entry that
204
+ * pre-1.8 postinstalls registered alongside 'amicus' (same server twice —
205
+ * doubled the client-visible tool list). Only removes an entry whose command
206
+ * is an amicus MCP invocation; a customized 'sidecar' entry is left alone.
207
+ * Covers both files the three legacy registration paths wrote to:
208
+ * ~/.claude.json (CLI + file fallback) and claude_desktop_config.json.
209
+ * Never throws (postinstall must always exit 0).
210
+ */
211
+ function migrateLegacyMcp(deps = {}) {
212
+ try {
213
+ const impl = deps.migrateLegacySidecar
214
+ || require('../src/utils/legacy-mcp-migration').migrateLegacySidecar;
215
+ for (const r of impl()) {
216
+ if (r.result === 'removed') {
217
+ console.log(`[amicus] Removed duplicate legacy 'sidecar' MCP entry from ${r.target} (same server — kept as 'amicus').`);
218
+ } else if (r.result === 'customized') {
219
+ console.log(`[amicus] Kept custom 'sidecar' MCP entry in ${r.target} (does not point at amicus).`);
220
+ } else if (r.result === 'write-failed') {
221
+ console.warn(`[amicus] Warning: could not remove the legacy 'sidecar' MCP entry from ${r.target} — run: amicus doctor --fix`);
222
+ }
223
+ }
224
+ } catch (err) {
225
+ console.warn(`[amicus] Warning: legacy MCP cleanup skipped: ${err && err.message}`);
226
+ }
181
227
  }
182
228
 
183
229
  /**
@@ -291,6 +337,7 @@ async function main(deps = {}) {
291
337
  const _installCouncilSkill = deps.installCouncilSkill || installCouncilSkill;
292
338
  const _registerClaudeCode = deps.registerClaudeCode || registerClaudeCode;
293
339
  const _registerClaudeDesktop = deps.registerClaudeDesktop || registerClaudeDesktop;
340
+ const _migrateLegacyMcp = deps.migrateLegacyMcp || migrateLegacyMcp;
294
341
  const _setupHooks = deps.setupHooks || setupHooks;
295
342
  const _provisionElectron = deps.provisionElectron || provisionElectron;
296
343
 
@@ -302,6 +349,7 @@ async function main(deps = {}) {
302
349
  _installCouncilSkill();
303
350
  _registerClaudeCode();
304
351
  _registerClaudeDesktop();
352
+ _migrateLegacyMcp(deps);
305
353
 
306
354
  // Non-fatal, cache-only: heal the optional Electron binary from local cache
307
355
  // or emit a deferred notice (GUI provisions on first use). Never throws.
@@ -336,4 +384,5 @@ if (require.main === module) {
336
384
  runCli();
337
385
  }
338
386
 
339
- module.exports = { main, runCli, addMcpToConfigFile, installSkill, installCouncilSkill, setupHooks, provisionElectron, COUNCIL_FILES };
387
+ module.exports = { main, runCli, addMcpToConfigFile, installSkill, installCouncilSkill,
388
+ setupHooks, provisionElectron, registerClaudeCode, registerClaudeDesktop, migrateLegacyMcp, COUNCIL_FILES };
@@ -41,6 +41,8 @@ function realDeps() {
41
41
  fix: false,
42
42
  discoverClaudeCodeMcps: () => require('./utils/mcp-discovery').discoverClaudeCodeMcps(),
43
43
  discoverCoworkMcps: () => require('./utils/mcp-discovery').discoverCoworkMcps(),
44
+ inspectLegacyMcpEntries: () => require('./utils/legacy-mcp-migration').inspectAllLegacySidecarEntries(),
45
+ migrateLegacyMcpEntries: () => require('./utils/legacy-mcp-migration').migrateLegacySidecar(),
44
46
  skillInstalled: () => {
45
47
  const dir = path.join(os.homedir(), '.claude', 'skills');
46
48
  return fs.existsSync(path.join(dir, 'sidecar', 'SKILL.md'))
@@ -181,6 +183,47 @@ async function runDoctorChecks(depsOverride = {}) {
181
183
  return { id: 'mcp', name: 'MCP registration', status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
182
184
  }));
183
185
 
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
+ }));
226
+
184
227
  // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
185
228
  checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
186
229
  const values = d.readApiKeyValues() || {};
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `amicus status <task_id>` — one-shot human/JSON status for a session or wave.
3
+ * Reads the SAME sources as the MCP amicus_status handler by calling it
4
+ * directly (requiring mcp-server does NOT start the server; the MCP SDK is
5
+ * only loaded inside startMcpServer()). Zero duplicated status logic — this
6
+ * inherits crash detection, wave leg rollup, and P6-3 enrichment for free.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { validateTaskId } = require('./utils/validators');
12
+
13
+ /** Render a key-value block for a single-session status payload. */
14
+ function formatRunHuman(d) {
15
+ const lines = [
16
+ `Task: ${d.taskId}`,
17
+ `Status: ${d.status}${d.phase ? ` (${d.phase})` : ''}`,
18
+ `Elapsed: ${d.elapsed}`,
19
+ ];
20
+ if (d.model) { lines.push(`Model: ${d.model}`); }
21
+ if (d.mode) { lines.push(`Mode: ${d.mode}`); }
22
+ if (d.messageCount !== undefined) { lines.push(`Messages: ${d.messageCount}`); }
23
+ if (d.lastActivity) { lines.push(`Activity: ${d.lastActivity}`); }
24
+ if (d.latestPreview) { lines.push(`Latest: ${d.latestPreview}`); }
25
+ else if (d.latest) { lines.push(`Latest: ${d.latest}`); }
26
+ if (d.stalled) { lines.push(`STALLED: no activity for ${d.stalledForSeconds}s (see --json for recovery)`); }
27
+ if (d.reason) { lines.push(`Reason: ${d.reason}`); }
28
+ return lines.join('\n');
29
+ }
30
+
31
+ /** Render a wave payload: header + one line per leg. */
32
+ function formatWaveHumanStatus(d) {
33
+ const head = `Wave ${d.taskId}: ${d.status} — ${d.legsComplete}/${d.legsTotal} legs done (${d.elapsed})`;
34
+ const legLines = (d.legs || []).map((l) => {
35
+ const label = String(l.model || l.taskId || '').padEnd(28);
36
+ const st = String(l.status || 'unknown').padEnd(10);
37
+ const msgs = l.messages !== undefined ? `${l.messages} msg` : '';
38
+ const latest = l.latestPreview || l.latestActivity || '';
39
+ const flag = l.stalled ? ' ⏳stalled' : '';
40
+ return ` ${label} ${st} ${msgs} | ${latest}${flag}`;
41
+ });
42
+ return [head, ...legLines].join('\n');
43
+ }
44
+
45
+ /**
46
+ * Handle 'amicus status'. Exit code 0 = status retrieved (any run state, even
47
+ * a failed/crashed run — the QUERY succeeded); 1 = missing/invalid/unknown id.
48
+ * @param {object} args parsed CLI args
49
+ * @returns {Promise<number>}
50
+ */
51
+ async function handleStatus(args) {
52
+ const taskId = args.wave || args._[1];
53
+ if (!taskId || taskId === true) {
54
+ process.stderr.write('Error: task_id is required for status\n');
55
+ process.stderr.write('Usage: amicus status <task_id> [--json] (or: amicus status --wave <wave_id>)\n');
56
+ return 1;
57
+ }
58
+ const check = validateTaskId(String(taskId));
59
+ if (!check.valid) { process.stderr.write(`${check.error}\n`); return 1; }
60
+
61
+ const project = args.cwd || process.cwd();
62
+ const { handlers } = require('./mcp-server');
63
+ const result = await handlers.amicus_status({ taskId: String(taskId) }, project);
64
+ const text = result.content[0].text;
65
+ if (result.isError) { process.stderr.write(`${text}\n`); return 1; }
66
+
67
+ let data;
68
+ try { data = JSON.parse(text); } catch { process.stdout.write(`${text}\n`); return 0; }
69
+ delete data.next_poll; // MCP-agent polling guidance, not CLI output
70
+
71
+ if (args.json) { process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return 0; }
72
+ process.stdout.write(`${data.type === 'wave' ? formatWaveHumanStatus(data) : formatRunHuman(data)}\n`);
73
+ return 0;
74
+ }
75
+
76
+ module.exports = { handleStatus, formatRunHuman, formatWaveHumanStatus };
@@ -123,6 +123,16 @@ async function handleAbort(args) {
123
123
  console.error(`Session ${taskId} has malformed metadata`);
124
124
  process.exit(1);
125
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
+
126
136
  const { markAborted } = require('./utils/session-abort');
127
137
 
128
138
  // F4: aborting a wave aborts every still-running leg too.
@@ -147,6 +157,28 @@ async function handleAbort(args) {
147
157
 
148
158
  markAborted(sessionDir, 'manual abort');
149
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
+ }
150
182
  }
151
183
 
152
184
  /**
package/src/cli.js CHANGED
@@ -330,6 +330,7 @@ Commands:
330
330
  start Launch a new amicus session
331
331
  fanout Run N models on the same prompt in parallel (headless)
332
332
  list Show previous sessions
333
+ status One-shot status for a session or wave (--json)
333
334
  resume Reopen a previous session
334
335
  continue New session building on previous
335
336
  read Output session summary/conversation
@@ -416,6 +417,13 @@ Options for 'list':
416
417
  --status <filter> Filter by status (running, complete)
417
418
  --all Show all projects
418
419
  --json Output as JSON
420
+ `,
421
+ status: `
422
+ Options for 'status':
423
+ <task_id> Required. Session or wave ID (positional)
424
+ --wave <wave_id> Alternative to the positional ID for waves
425
+ --json Machine-readable output
426
+ --cwd <path> Project directory (default: cwd)
419
427
  `,
420
428
  abort: `
421
429
  Options for 'abort':