amicus 4.0.0 → 4.0.1

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": "4.0.0",
3
+ "version": "4.0.1",
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,49 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [4.0.1] - 2026-07-20
9
+
10
+ Follow-up fixes to the v4.0.0 council engine: `amicus abort` and `amicus status` now see every
11
+ sub-wave a stage launched, a council run that dies before its first checkpoint is recoverable
12
+ instead of stranded, and neither command can be thrown by a malformed wave record.
13
+
14
+ ### Fixed
15
+
16
+ - **`amicus abort` now cascades to every in-flight council leg, not just the primary wave of
17
+ each stage.** A stage can own several sub-waves — the chair's `ch1..ch4` retry/fallback/repair
18
+ chain, one solo per lens, a critic solo beside the seat wave, and the bounded Stage-1/Stage-2
19
+ repair re-prompts — but only a single `waveId` was recorded per stage (and the chair stage
20
+ recorded none at all), so the targeted cascade skipped those legs and left them to the
21
+ `waitThenKill` process-tree fallback. They were still killed, so this was never a leak or a
22
+ hang; what was lost were the per-leg `aborted` markers, an accurate `legsAborted` count, and a
23
+ faithful per-stage audit trail in `run.json`. Stage entries now carry a `waveIds` array
24
+ recording every sub-wave at launch time (documented in `schemas/council-run.schema.json`), and
25
+ the cascade targets the union of `waveId` + `waveIds`. In lens mode `stage1` previously
26
+ advertised only a phantom `-s1` wave that never launches, so *no* Stage-1 leg was reachable;
27
+ lens runs no longer record that `waveId` at all.
28
+ - **`amicus status` now rolls up council legs across every sub-wave of the active stage.** It
29
+ counted only the stage's primary `waveId`, so a lens run — which has no seat wave — always
30
+ reported `legsTotal: null`, and a run with a critic omitted the critic's leg from the count
31
+ (e.g. `2` instead of `3` for two seats plus a critic). Note that `legsTotal` can now rise
32
+ mid-stage when a bounded repair re-prompt launches, which is a real additional model call.
33
+ - **A council run spawned through `amicus_council_run` that died before its first checkpoint no
34
+ longer strands an unrecoverable record.** The MCP handler wrote `run.json` with
35
+ `status: "running"` and no `pid`, leaving the spawned CLI child to record its own pid at
36
+ startup; a child that died inside that window left a pid-less `running` run that `amicus
37
+ status` skipped entirely (its crash detection is guarded on `run.pid`) and that `amicus abort`
38
+ could not fall back to killing, so the run was recoverable only by hand. The handler now
39
+ captures the pid from the spawned child and checkpoints it immediately — the same value the
40
+ engine writes itself, recorded a beat earlier. The pid is written to its own
41
+ `spawn.pid` file rather than patched into `run.json`: the spawning process and the engine
42
+ child both write `run.json`, and `checkpoint` is a read-merge-write with no cross-process
43
+ lock, so a pid patch could clobber (or be clobbered by) the child's first checkpoint. Readers
44
+ prefer `run.json`'s own pid and fall back to `spawn.pid`.
45
+ - **A malformed wave `metadata.json` no longer throws out of `amicus status` or `amicus abort`.**
46
+ `countWaveLegs` and `cascadeWave` both assumed the `legs` field was an array if it was present
47
+ at all, so a half-written or hand-edited record raised a `TypeError` past its caller. Both now
48
+ treat a non-array `legs` as no legs. `cascadeWave`'s wave-level abort mark is also guarded, so
49
+ a failure there can no longer discard the count of legs it had already marked.
50
+
8
51
  ## [4.0.0] - 2026-07-20
9
52
 
10
53
  The **headless council engine** release. `amicus council run` (CLI) and `amicus_council_run`
package/README.md CHANGED
@@ -329,7 +329,7 @@ $ amicus status demo123 --json
329
329
  "taskId": "demo123",
330
330
  "status": "complete",
331
331
  "elapsed": "5m 0s",
332
- "version": "4.0.0",
332
+ "version": "4.0.1",
333
333
  "model": "google/gemini-2.5-flash",
334
334
  "phase": "terminal"
335
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "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.",
6
6
  "keywords": [
@@ -21,6 +21,7 @@
21
21
  "startedAt": { "type": ["string", "null"] },
22
22
  "completedAt": { "type": ["string", "null"] },
23
23
  "waveId": { "type": "string" },
24
+ "waveIds": { "type": "array", "items": { "type": "string" } },
24
25
  "taskIds": { "type": "array", "items": { "type": "string" } }
25
26
  }
26
27
  }
@@ -25,6 +25,7 @@ const briefings = require('./briefings');
25
25
  const stage2 = require('./briefings-stage2');
26
26
  const { parseJudgeOutput } = require('./parse-stage2');
27
27
  const { materializeReviews, sanitizeName } = require('./run-launch');
28
+ const runState = require('./run-state');
28
29
 
29
30
  function isAbortExit(code) { return code === 130 || code === 143; }
30
31
 
@@ -40,20 +41,30 @@ async function launchStage1(ctx) {
40
41
  noValidateModel: o.noValidateModel,
41
42
  };
42
43
  const launches = [];
44
+ // Record every sub-wave BEFORE it launches: `amicus abort` cascades over
45
+ // stages[].waveIds, so an id written after the launch leaves that leg
46
+ // reachable only by the pid kill (no per-leg abort marker).
47
+ const record = (waveId) => runState.appendStageWave(o.runDir, 'stage1', waveId);
43
48
  if (o.lenses) {
44
- o.models.forEach((m, i) => launches.push(launchers.launchSolo({
45
- ...common, model: m, waveId: `${o.runId}-l${i + 1}`,
46
- prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
47
- })));
49
+ o.models.forEach((m, i) => {
50
+ const waveId = `${o.runId}-l${i + 1}`;
51
+ record(waveId);
52
+ launches.push(launchers.launchSolo({
53
+ ...common, model: m, waveId,
54
+ prompt: briefings.buildLensBriefing({ lens: o.lenses[i], briefing: o.briefing, date: o.date }),
55
+ }));
56
+ });
48
57
  } else {
49
58
  const seats = o.models.filter(m => m !== o.critic);
50
59
  if (seats.length > 0) {
60
+ record(`${o.runId}-s1`);
51
61
  launches.push(launchers.launchWave({
52
62
  ...common, models: seats, waveId: `${o.runId}-s1`,
53
63
  prompt: briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }),
54
64
  }));
55
65
  }
56
66
  if (o.critic) {
67
+ record(`${o.runId}-c1`);
57
68
  launches.push(launchers.launchSolo({
58
69
  ...common, model: o.critic, waveId: `${o.runId}-c1`,
59
70
  prompt: briefings.buildCriticBriefing({ briefing: o.briefing, date: o.date }),
@@ -102,9 +113,11 @@ async function runStage1(ctx) {
102
113
  while (!res.ok && attempts < 2 && !ctx.overBudget()) {
103
114
  attempts += 1;
104
115
  repairSeq += 1;
116
+ const waveId = `${o.runId}-p${repairSeq}`;
117
+ runState.appendStageWave(o.runDir, 'stage1', waveId);
105
118
  const solo = await ctx.launchers.launchSolo({
106
119
  model: m.modelInput, prompt: briefings.buildFindingsRepairPrompt({ errors: res.errors }),
107
- project: o.runDir, waveId: `${o.runId}-p${repairSeq}`, timeout: o.timeout,
120
+ project: o.runDir, waveId, timeout: o.timeout,
108
121
  gateway: o.gateway, noValidateModel: o.noValidateModel,
109
122
  });
110
123
  ctx.addWave(solo.wave);
@@ -141,6 +154,7 @@ async function runStage2(ctx, { reviews, labels, globalFindings }) {
141
154
  labels: labels.entries.map(e => e.label),
142
155
  findingIds: globalFindings.map(f => f.id),
143
156
  };
157
+ runState.appendStageWave(o.runDir, 'stage2', `${o.runId}-s2`);
144
158
  const { wave, exitCode } = await ctx.launchers.launchWave({
145
159
  models: judges, prompt: bundle, project: ctx.scratchDir, waveId: `${o.runId}-s2`,
146
160
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
@@ -163,9 +177,11 @@ async function runStage2(ctx, { reviews, labels, globalFindings }) {
163
177
  while (!parsed.ok && leg.status === 'complete' && leg.summary && attempts < 2 && !ctx.overBudget()) {
164
178
  attempts += 1;
165
179
  repairSeq += 1;
180
+ const waveId = `${o.runId}-q${repairSeq}`;
181
+ runState.appendStageWave(o.runDir, 'stage2', waveId);
166
182
  const solo = await ctx.launchers.launchSolo({
167
183
  model: judge, prompt: stage2.buildJudgeRepairPrompt({ errors: parsed.errors }),
168
- project: ctx.scratchDir, waveId: `${o.runId}-q${repairSeq}`, timeout: o.timeout,
184
+ project: ctx.scratchDir, waveId, timeout: o.timeout,
169
185
  gateway: o.gateway, noValidateModel: o.noValidateModel,
170
186
  });
171
187
  ctx.addWave(solo.wave);
@@ -20,8 +20,31 @@ const { writeFileAtomic } = require('../utils/atomic-write');
20
20
  const { SESSIONS_DIR } = require('../session-manager');
21
21
 
22
22
  const RUN_FILE = 'run.json';
23
+ const SPAWN_PID_FILE = 'spawn.pid';
23
24
 
24
25
  function runPath(runDir) { return path.join(runDir, RUN_FILE); }
26
+ function spawnPidPath(runDir) { return path.join(runDir, SPAWN_PID_FILE); }
27
+
28
+ /**
29
+ * Record the engine child's pid in its own file rather than patching run.json.
30
+ * The spawning process (the MCP handler) and the engine child both write
31
+ * run.json, and `checkpoint` is a read-merge-write with no cross-process lock —
32
+ * so a pid patch from the parent can clobber, or be clobbered by, whatever the
33
+ * child wrote in the same window. A standalone single-write file has no read
34
+ * side, so there is no race to lose. Readers fall back to it whenever run.json
35
+ * carries no pid (see readSpawnPid).
36
+ */
37
+ function writeSpawnPid(runDir, pid) {
38
+ writeFileAtomic(spawnPidPath(runDir), String(pid), { mode: 0o600 });
39
+ }
40
+
41
+ /** @returns {number|null} the recorded spawn pid, or null when absent/corrupt */
42
+ function readSpawnPid(runDir) {
43
+ try {
44
+ const pid = Number.parseInt(fs.readFileSync(spawnPidPath(runDir), 'utf-8').trim(), 10);
45
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
46
+ } catch { return null; }
47
+ }
25
48
 
26
49
  /** @returns {object|null} parsed run.json, or null when missing/corrupt */
27
50
  function readRun(runDir) {
@@ -78,6 +101,21 @@ function updateStage(runDir, name, patch) {
78
101
  return checkpoint(runDir, { stages });
79
102
  }
80
103
 
104
+ /**
105
+ * Append a sub-wave id to one stage's `waveIds` (dedup, launch order preserved).
106
+ * A stage can have several sub-waves in flight at once (lens solos, a critic
107
+ * solo alongside the seat wave) or in sequence (the chair's ch1..ch4 chain), so
108
+ * the single `waveId` field cannot describe them. `amicus abort` cascades over
109
+ * this list to mark every in-flight leg instead of relying on the pid kill.
110
+ */
111
+ function appendStageWave(runDir, name, waveId) {
112
+ const existing = readRun(runDir) || {};
113
+ const stage = (existing.stages || []).find(s => s && s.name === name) || {};
114
+ const waveIds = Array.isArray(stage.waveIds) ? stage.waveIds : [];
115
+ if (waveIds.includes(waveId)) { return existing; }
116
+ return updateStage(runDir, name, { waveIds: [...waveIds, waveId] });
117
+ }
118
+
81
119
  function stripPrefix(runId) { return String(runId).replace(/^council-/, ''); }
82
120
 
83
121
  /** `<project>/.claude/amicus_sessions/council-<runId>.json` */
@@ -117,6 +155,7 @@ function listPointers(project) {
117
155
  }
118
156
 
119
157
  module.exports = {
120
- RUN_FILE, readRun, initRun, checkpoint, updateStage,
158
+ RUN_FILE, readRun, initRun, checkpoint, updateStage, appendStageWave,
159
+ writeSpawnPid, readSpawnPid,
121
160
  pointerPath, writePointer, readPointer, listPointers,
122
161
  };
@@ -110,8 +110,14 @@ async function runCouncil(options, deps = {}) {
110
110
  briefings.buildSeatBriefing({ briefing: o.briefing, date: o.date }), { mode: 0o600 });
111
111
 
112
112
  // ---- Stage 1: independent reviews ----
113
- runState.updateStage(o.runDir, 'stage1',
114
- { status: 'running', startedAt: now(), waveId: `${o.runId}-s1`, project: o.runDir });
113
+ // Lens mode launches one solo per seat instead of a `-s1` seat wave, so it
114
+ // has no primary wave to name — run-stages records each real sub-wave into
115
+ // waveIds at launch. Advertising a `-s1` that never exists made both the
116
+ // abort cascade and the status leg rollup chase a phantom.
117
+ runState.updateStage(o.runDir, 'stage1', {
118
+ status: 'running', startedAt: now(), project: o.runDir,
119
+ ...(o.lenses ? {} : { waveId: `${o.runId}-s1` }),
120
+ });
115
121
  const s1 = await runStage1(ctx);
116
122
  runState.updateStage(o.runDir, 'stage1', {
117
123
  status: 'complete', completedAt: now(),
@@ -173,6 +179,7 @@ async function runCouncil(options, deps = {}) {
173
179
  });
174
180
  fs.writeFileSync(path.join(o.runDir, 'chair-packet.md'), packet, { mode: 0o600 });
175
181
  const attemptChair = async (model, waveId) => {
182
+ runState.appendStageWave(o.runDir, 'chair', waveId);
176
183
  const solo = await launchers.launchSolo({
177
184
  model, prompt: packet, project: o.runDir, waveId,
178
185
  timeout: o.timeout, gateway: o.gateway, noValidateModel: o.noValidateModel,
@@ -228,6 +235,7 @@ async function runCouncil(options, deps = {}) {
228
235
  // ---- Chair VERDICT line (one repair re-prompt, spec §5) ----
229
236
  let overallVerdict = chairText ? parseChairVerdict(chairText) : null;
230
237
  if (chairText && !overallVerdict && !overBudget()) {
238
+ runState.appendStageWave(o.runDir, 'chair', `${o.runId}-ch4`);
231
239
  const repair = await launchers.launchSolo({
232
240
  model: actualChair, prompt: stage2.buildChairRepairPrompt(),
233
241
  project: o.runDir, waveId: `${o.runId}-ch4`,
@@ -0,0 +1,187 @@
1
+ // src/mcp-council-awareness.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module mcp-council-awareness
6
+ * The council-awareness helpers behind amicus_status / amicus_list /
7
+ * amicus_abort: they resolve a council runId through the sessions-dir pointer
8
+ * file and read the run directory directly, so the generic session handlers do
9
+ * not need to know anything about council run layout.
10
+ *
11
+ * Split out of mcp-council-run.js, which owns the amicus_council_run handler;
12
+ * that file re-exports these so existing require paths keep working.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const runState = require('./council/run-state');
18
+ const { RUNNING_VERSION } = require('./utils/version-info');
19
+
20
+ /**
21
+ * Every wave a stage launched: the primary `waveId` plus the recorded
22
+ * `waveIds` sub-waves (chair ch1..ch4, lens solos, critic solo, repairs).
23
+ */
24
+ function subWaveIds(stage) {
25
+ return [...new Set(
26
+ [stage.waveId, ...(Array.isArray(stage.waveIds) ? stage.waveIds : [])].filter(Boolean))];
27
+ }
28
+
29
+ /**
30
+ * The pid to probe for liveness. run.json's own pid is authoritative once the
31
+ * engine has checkpointed it; before that, the spawning process's record is all
32
+ * there is (see run-state.writeSpawnPid).
33
+ */
34
+ function enginePid(run, runDir) {
35
+ return run.pid || runState.readSpawnPid(runDir);
36
+ }
37
+
38
+ /** @returns {{total: number, complete: number}|null} null when not on disk yet */
39
+ function countWaveLegs(project, waveId) {
40
+ const { getSessionDir } = require('./session-manager');
41
+ const { TERMINAL_STATUSES } = require('./utils/result-schema');
42
+ let legs;
43
+ try {
44
+ legs = JSON.parse(fs.readFileSync(
45
+ path.join(getSessionDir(project, waveId), 'metadata.json'), 'utf-8')).legs;
46
+ } catch { return null; }
47
+ // A hand-edited or half-written metadata.json can carry a non-array `legs`;
48
+ // treat anything that is not an array as no legs rather than throwing out of
49
+ // a status read.
50
+ if (!Array.isArray(legs)) { return { total: 0, complete: 0 }; }
51
+ const complete = legs.filter((id) => {
52
+ try {
53
+ const m = JSON.parse(fs.readFileSync(
54
+ path.join(getSessionDir(project, id), 'metadata.json'), 'utf-8'));
55
+ return TERMINAL_STATUSES.includes(m.status);
56
+ } catch { return false; }
57
+ }).length;
58
+ return { total: legs.length, complete };
59
+ }
60
+
61
+ function elapsedOf(run) {
62
+ const end = run.completedAt || new Date().toISOString();
63
+ const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
64
+ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
65
+ }
66
+
67
+ /** Status payload for a council runId, or null when the id is not a council run. */
68
+ function buildCouncilStatusPayload(project, taskId) {
69
+ const ptr = runState.readPointer(project, taskId);
70
+ if (!ptr) { return null; }
71
+ const run = runState.readRun(ptr.runDir);
72
+ if (!run) { return null; }
73
+
74
+ // Crash detection: a running run.json whose engine pid is gone is 'error'.
75
+ const pid = run.status === 'running' ? enginePid(run, ptr.runDir) : null;
76
+ if (pid) {
77
+ try { process.kill(pid, 0); } catch (err) {
78
+ if (err.code !== 'EPERM') {
79
+ runState.checkpoint(ptr.runDir, {
80
+ status: 'error', completedAt: new Date().toISOString(),
81
+ error: { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' },
82
+ });
83
+ run.status = 'error';
84
+ run.error = { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' };
85
+ }
86
+ }
87
+ }
88
+
89
+ const stages = (run.stages || []).map(s => ({
90
+ name: s.name, status: s.status, waveId: s.waveId || null,
91
+ }));
92
+ const active = (run.stages || []).find(s => s.status === 'running') || null;
93
+ let legsTotal = null; let legsComplete = null;
94
+ // Sum across every sub-wave the active stage launched: a lens stage1 has no
95
+ // seat wave at all, and a critic solo runs beside one. Stays null until at
96
+ // least one sub-wave record exists on disk.
97
+ for (const waveId of active && active.project ? subWaveIds(active) : []) {
98
+ const c = countWaveLegs(active.project, waveId);
99
+ if (!c) { continue; }
100
+ legsTotal = (legsTotal || 0) + c.total;
101
+ legsComplete = (legsComplete || 0) + c.complete;
102
+ }
103
+ const payload = {
104
+ taskId: run.runId, type: 'council-run', runId: run.runId, runDir: ptr.runDir,
105
+ status: run.status, currentStage: active ? active.name : null, stages,
106
+ legsTotal, legsComplete, elapsed: elapsedOf(run),
107
+ exitCode: run.exitCode !== undefined ? run.exitCode : null,
108
+ version: RUNNING_VERSION,
109
+ };
110
+ if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
111
+ return payload;
112
+ }
113
+
114
+ /** amicus_list entries for every council pointer in the project. */
115
+ function listCouncilRuns(project) {
116
+ const { sanitizePreview } = require('./sidecar/progress-fields');
117
+ const out = [];
118
+ for (const ptr of runState.listPointers(project)) {
119
+ const run = runState.readRun(ptr.runDir);
120
+ if (!run) { continue; }
121
+ let briefing = '';
122
+ try { briefing = fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); }
123
+ catch { /* optional */ }
124
+ const active = (run.stages || []).find(s => s.status === 'running');
125
+ out.push({
126
+ id: run.runId, type: 'council-run', status: run.status, mode: 'headless',
127
+ model: null, agent: 'Plan', createdAt: run.createdAt,
128
+ briefing: sanitizePreview(briefing, 80),
129
+ stage: active ? active.name : null,
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** Mark one sub-wave and its legs aborted. @returns {number} legs newly marked */
136
+ function cascadeWave(project, waveId) {
137
+ const { markAborted } = require('./utils/session-abort');
138
+ const { getSessionDir } = require('./session-manager');
139
+ const waveDir = getSessionDir(project, waveId);
140
+ let meta = {};
141
+ try { meta = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8')); }
142
+ catch { /* wave record may not exist yet */ }
143
+ let n = 0;
144
+ // Non-array `legs` (hand-edited or half-written) would throw out of the
145
+ // for..of and lose the wave-level mark below.
146
+ for (const legId of Array.isArray(meta.legs) ? meta.legs : []) {
147
+ try { if (markAborted(getSessionDir(project, legId), 'council abort')) { n++; } }
148
+ catch { /* skip leg */ }
149
+ }
150
+ // Guarded so a failure here cannot discard the legs already marked: the
151
+ // caller only learns the count through the return value.
152
+ try { markAborted(waveDir, 'council abort'); } catch { /* best-effort */ }
153
+ return n;
154
+ }
155
+
156
+ /**
157
+ * Abort a council run via its pointer: checkpoint run.json aborted (abort-wins)
158
+ * and cascade to every in-flight sub-wave + its legs so they settle.
159
+ * @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
160
+ */
161
+ function abortCouncilRun(project, taskId) {
162
+ const ptr = runState.readPointer(project, taskId);
163
+ if (!ptr) { return null; }
164
+ const run = runState.readRun(ptr.runDir);
165
+ if (!run) { return null; }
166
+ if (run.status !== 'running') { return { alreadyTerminal: true, status: run.status }; }
167
+
168
+ let cascaded = 0;
169
+ for (const s of run.stages || []) {
170
+ if (s.status !== 'running' || !s.project) { continue; }
171
+ for (const waveId of subWaveIds(s)) {
172
+ try { cascaded += cascadeWave(s.project, waveId); } catch { /* skip sub-wave */ }
173
+ }
174
+ }
175
+ runState.checkpoint(ptr.runDir, { status: 'aborted', completedAt: new Date().toISOString() });
176
+ const pid = enginePid(run, ptr.runDir);
177
+ if (pid) {
178
+ try { require('./utils/abort-coordinator').waitThenKill(pid).catch(() => {}); }
179
+ catch { /* best-effort */ }
180
+ }
181
+ return { aborted: true, cascaded };
182
+ }
183
+
184
+ module.exports = {
185
+ subWaveIds, countWaveLegs, elapsedOf, enginePid,
186
+ buildCouncilStatusPayload, listCouncilRuns, cascadeWave, abortCouncilRun,
187
+ };
@@ -4,18 +4,17 @@
4
4
  /**
5
5
  * @module mcp-council-run
6
6
  * MCP surface for headless council runs (spec §8): the amicus_council_run
7
- * handler (15th tool, born-fenced) plus the council-awareness helpers that
8
- * amicus_status / amicus_list / amicus_abort call through the sessions-dir
9
- * pointer file. Lives outside mcp-server.js (grandfathered-oversized); the
10
- * spawn helper is INJECTED by mcp-server at call time to avoid a require
11
- * cycle.
7
+ * handler (15th tool, born-fenced). Lives outside mcp-server.js
8
+ * (grandfathered-oversized); the spawn helper is INJECTED by mcp-server at call
9
+ * time to avoid a require cycle. The council-awareness helpers that
10
+ * amicus_status / amicus_list / amicus_abort call now live in
11
+ * mcp-council-awareness.js and are re-exported from here.
12
12
  */
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
  const runState = require('./council/run-state');
17
17
  const { fenceSidecarOutput } = require('./utils/untrusted-fence');
18
- const { RUNNING_VERSION } = require('./utils/version-info');
19
18
  const { isPathInside } = require('./project-root-allowlist');
20
19
 
21
20
  function textResult(text, isError) {
@@ -125,12 +124,20 @@ async function handleCouncilRunTool(input, project, helpers) {
125
124
  if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
126
125
  if (input.gateway) { args.push('--gateway', input.gateway); }
127
126
 
128
- try { helpers.spawnFn(args, runDir); } catch (err) {
127
+ let child;
128
+ try { child = helpers.spawnFn(args, runDir); } catch (err) {
129
129
  try {
130
130
  runState.checkpoint(runDir, { status: 'error', error: { code: 'INTERNAL', message: err.message }, completedAt: new Date().toISOString() });
131
131
  } catch { /* best-effort */ }
132
132
  return textResult(`Failed to start council run: ${err.message}`, true);
133
133
  }
134
+ // Record the child's pid NOW: the engine writes its own pid at startup, but a
135
+ // child that dies before that leaves a pid-less status:'running' run.json that
136
+ // crash detection skips and abort cannot signal. Written to its own file, not
137
+ // patched into run.json — the child owns run.json and a cross-process
138
+ // read-merge-write has no lock (see run-state.writeSpawnPid).
139
+ try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
140
+ catch { /* best-effort */ }
134
141
 
135
142
  const body = JSON.stringify({
136
143
  schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
@@ -142,126 +149,13 @@ async function handleCouncilRunTool(input, project, helpers) {
142
149
  return textResult(fenceSidecarOutput(body));
143
150
  }
144
151
 
145
- /** ---- council-awareness helpers (consumed by mcp-server status/list/abort) ---- */
146
-
147
- function elapsedOf(run) {
148
- const end = run.completedAt || new Date().toISOString();
149
- const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
150
- return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
151
- }
152
-
153
- /** Status payload for a council runId, or null when the id is not a council run. */
154
- function buildCouncilStatusPayload(project, taskId) {
155
- const ptr = runState.readPointer(project, taskId);
156
- if (!ptr) { return null; }
157
- const run = runState.readRun(ptr.runDir);
158
- if (!run) { return null; }
159
-
160
- // Crash detection: a running run.json whose engine pid is gone is 'error'.
161
- if (run.status === 'running' && run.pid) {
162
- try { process.kill(run.pid, 0); } catch (err) {
163
- if (err.code !== 'EPERM') {
164
- runState.checkpoint(ptr.runDir, {
165
- status: 'error', completedAt: new Date().toISOString(),
166
- error: { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' },
167
- });
168
- run.status = 'error';
169
- run.error = { code: 'INTERNAL', message: 'Council engine process exited unexpectedly' };
170
- }
171
- }
172
- }
173
-
174
- const stages = (run.stages || []).map(s => ({
175
- name: s.name, status: s.status, waveId: s.waveId || null,
176
- }));
177
- const active = (run.stages || []).find(s => s.status === 'running') || null;
178
- let legsTotal = null; let legsComplete = null;
179
- if (active && active.waveId && active.project) {
180
- try {
181
- const { getSessionDir } = require('./session-manager');
182
- const { TERMINAL_STATUSES } = require('./utils/result-schema');
183
- const meta = JSON.parse(fs.readFileSync(
184
- path.join(getSessionDir(active.project, active.waveId), 'metadata.json'), 'utf-8'));
185
- const legs = meta.legs || [];
186
- legsTotal = legs.length;
187
- legsComplete = legs.filter((id) => {
188
- try {
189
- const m = JSON.parse(fs.readFileSync(
190
- path.join(getSessionDir(active.project, id), 'metadata.json'), 'utf-8'));
191
- return TERMINAL_STATUSES.includes(m.status);
192
- } catch { return false; }
193
- }).length;
194
- } catch { /* stage wave not on disk yet */ }
195
- }
196
- const payload = {
197
- taskId: run.runId, type: 'council-run', runId: run.runId, runDir: ptr.runDir,
198
- status: run.status, currentStage: active ? active.name : null, stages,
199
- legsTotal, legsComplete, elapsed: elapsedOf(run),
200
- exitCode: run.exitCode !== undefined ? run.exitCode : null,
201
- version: RUNNING_VERSION,
202
- };
203
- if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
204
- return payload;
205
- }
206
-
207
- /** amicus_list entries for every council pointer in the project. */
208
- function listCouncilRuns(project) {
209
- const { sanitizePreview } = require('./sidecar/progress-fields');
210
- const out = [];
211
- for (const ptr of runState.listPointers(project)) {
212
- const run = runState.readRun(ptr.runDir);
213
- if (!run) { continue; }
214
- let briefing = '';
215
- try { briefing = fs.readFileSync(path.join(ptr.runDir, 'briefing.md'), 'utf-8'); }
216
- catch { /* optional */ }
217
- const active = (run.stages || []).find(s => s.status === 'running');
218
- out.push({
219
- id: run.runId, type: 'council-run', status: run.status, mode: 'headless',
220
- model: null, agent: 'Plan', createdAt: run.createdAt,
221
- briefing: sanitizePreview(briefing, 80),
222
- stage: active ? active.name : null,
223
- });
224
- }
225
- return out;
226
- }
227
-
228
- /**
229
- * Abort a council run via its pointer: checkpoint run.json aborted (abort-wins)
230
- * and cascade to the active stage's wave + legs so in-flight legs settle.
231
- * @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
232
- */
233
- function abortCouncilRun(project, taskId) {
234
- const ptr = runState.readPointer(project, taskId);
235
- if (!ptr) { return null; }
236
- const run = runState.readRun(ptr.runDir);
237
- if (!run) { return null; }
238
- if (run.status !== 'running') { return { alreadyTerminal: true, status: run.status }; }
239
-
240
- const { markAborted } = require('./utils/session-abort');
241
- const { getSessionDir } = require('./session-manager');
242
- let cascaded = 0;
243
- for (const s of run.stages || []) {
244
- if (s.status !== 'running' || !s.waveId || !s.project) { continue; }
245
- try {
246
- const waveDir = getSessionDir(s.project, s.waveId);
247
- let meta = {};
248
- try { meta = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8')); }
249
- catch { /* wave record may not exist yet */ }
250
- for (const legId of meta.legs || []) {
251
- try { if (markAborted(getSessionDir(s.project, legId), 'council abort')) { cascaded++; } }
252
- catch { /* skip leg */ }
253
- }
254
- markAborted(waveDir, 'council abort');
255
- } catch { /* skip stage */ }
256
- }
257
- runState.checkpoint(ptr.runDir, { status: 'aborted', completedAt: new Date().toISOString() });
258
- if (run.pid) {
259
- try { require('./utils/abort-coordinator').waitThenKill(run.pid).catch(() => {}); }
260
- catch { /* best-effort */ }
261
- }
262
- return { aborted: true, cascaded };
263
- }
152
+ // The council-awareness helpers live in their own module; re-exported here so
153
+ // mcp-server and cli-handlers-abort keep requiring one council MCP entry point.
154
+ const awareness = require('./mcp-council-awareness');
264
155
 
265
156
  module.exports = {
266
- handleCouncilRunTool, buildCouncilStatusPayload, listCouncilRuns, abortCouncilRun,
157
+ handleCouncilRunTool,
158
+ buildCouncilStatusPayload: awareness.buildCouncilStatusPayload,
159
+ listCouncilRuns: awareness.listCouncilRuns,
160
+ abortCouncilRun: awareness.abortCouncilRun,
267
161
  };