amicus 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +108 -0
- package/README.md +16 -4
- package/bin/amicus.js +11 -93
- package/commands/council.md +14 -6
- package/package.json +1 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +68 -4
- package/skills/second-opinion/MODEL-NOTES.md +35 -2
- package/skills/second-opinion/SEAT-BRIEFS.md +190 -0
- package/skills/second-opinion/SKILL.md +78 -16
- package/skills/sidecar/SKILL.md +17 -14
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-doctor.js +13 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +5 -4
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +20 -0
- package/src/mcp-server.js +8 -5
- package/src/mcp-tools.js +31 -21
- package/src/sidecar/continue.js +23 -8
- package/src/sidecar/resume.js +23 -8
- package/src/utils/abort-result.js +36 -0
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/doctor-mcp-checks.js +84 -0
- package/src/utils/input-validators.js +52 -1
- package/src/utils/mcp-discovery.js +51 -14
- package/src/utils/result-schema-version.js +14 -0
- package/src/utils/result-schema.js +10 -10
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Abort Handler (B21-rest extraction)
|
|
3
|
+
*
|
|
4
|
+
* Split out of src/cli-handlers.js — that file was already near the 300-line
|
|
5
|
+
* size gate and had no headroom for the --json branch added here. Re-exported
|
|
6
|
+
* from src/cli-handlers.js so existing callers/tests are unaffected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { validateTaskId, safeSessionDir } = require('./utils/validators');
|
|
14
|
+
const { failJson, ERROR_CODES } = require('./utils/error-doc');
|
|
15
|
+
const { buildAbortResult } = require('./utils/result-schema');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle 'amicus abort --all --json': mark every running session aborted.
|
|
19
|
+
* @returns {number} exit code (always 0 — even a no-op --all is a success)
|
|
20
|
+
*/
|
|
21
|
+
function handleAbortAllJson(project) {
|
|
22
|
+
const { enumerateSessions } = require('./sidecar/read');
|
|
23
|
+
const { markAborted } = require('./utils/session-abort');
|
|
24
|
+
const { resolveExistingSessionDir } = require('./session-manager');
|
|
25
|
+
const running = enumerateSessions(project, { status: 'running' });
|
|
26
|
+
const aborted = [];
|
|
27
|
+
for (const s of running) {
|
|
28
|
+
if (markAborted(resolveExistingSessionDir(project, s.id), 'abort --all')) { aborted.push(s.id); }
|
|
29
|
+
}
|
|
30
|
+
console.log(JSON.stringify(buildAbortResult({ scope: 'all', taskId: null, aborted }), null, 2));
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle 'amicus abort <taskId> --json' for a single session or a wave.
|
|
36
|
+
* Mirrors the human-mode logic in handleAbort below but emits ONE doc on
|
|
37
|
+
* stdout instead of the multi-line console.log prose; the same waitThenKill
|
|
38
|
+
* fallback still runs, its narration routed to stderr instead of stdout.
|
|
39
|
+
* @returns {Promise<number>} exit code (always 0 for a resolved abort doc/error
|
|
40
|
+
* doc — both are "the command ran"; ok:false is signaled inside the doc)
|
|
41
|
+
*/
|
|
42
|
+
async function handleAbortTaskJson(args, taskId) {
|
|
43
|
+
const project = args.cwd || process.cwd();
|
|
44
|
+
const sessionDir = safeSessionDir(project, taskId);
|
|
45
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
46
|
+
|
|
47
|
+
if (!fs.existsSync(metaPath)) {
|
|
48
|
+
process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: `Session ${taskId} not found` }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let meta;
|
|
52
|
+
try {
|
|
53
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
54
|
+
} catch (_err) {
|
|
55
|
+
process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: `Session ${taskId} has malformed metadata` }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (meta.status !== 'running') {
|
|
59
|
+
// Not a hard error — the task exists — but nothing was aborted by this call.
|
|
60
|
+
// Scope must reflect the task's own type (wave vs session), not assume
|
|
61
|
+
// 'session' — a terminal wave still has meta.type === 'wave'.
|
|
62
|
+
const scope = meta.type === 'wave' ? 'wave' : 'session';
|
|
63
|
+
console.log(JSON.stringify(buildAbortResult({ scope, taskId, aborted: [] }), null, 2));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { markAborted } = require('./utils/session-abort');
|
|
68
|
+
|
|
69
|
+
if (meta.type === 'wave') {
|
|
70
|
+
const { resolveExistingSessionDir } = require('./session-manager');
|
|
71
|
+
const aborted = [];
|
|
72
|
+
for (const legId of meta.legs || []) {
|
|
73
|
+
const legDir = resolveExistingSessionDir(project, legId);
|
|
74
|
+
try {
|
|
75
|
+
const legMeta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8'));
|
|
76
|
+
if (legMeta.status === 'running') {
|
|
77
|
+
if (markAborted(legDir, 'wave abort')) { aborted.push(legId); }
|
|
78
|
+
}
|
|
79
|
+
} catch { /* skip unreadable leg */ }
|
|
80
|
+
}
|
|
81
|
+
// Only report the wave itself as aborted if its own markAborted write
|
|
82
|
+
// succeeded — mirrors the leg gating above and the --all/single-session
|
|
83
|
+
// gating (aborted[] must list ids ACTUALLY marked aborted per
|
|
84
|
+
// buildAbortResult's doc-comment).
|
|
85
|
+
if (markAborted(sessionDir, 'manual abort')) { aborted.unshift(taskId); }
|
|
86
|
+
console.log(JSON.stringify(buildAbortResult({ scope: 'wave', taskId, aborted }), null, 2));
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const wasMarked = markAborted(sessionDir, 'manual abort');
|
|
91
|
+
|
|
92
|
+
// Same fallback direct-kill as human mode (see the comment on the
|
|
93
|
+
// equivalent block in handleAbort below) — json mode still needs the
|
|
94
|
+
// process actually signalled, it just can't narrate it on stdout (stdout
|
|
95
|
+
// must carry ONLY the doc). Route the same chatter to stderr instead.
|
|
96
|
+
if (meta.pid) {
|
|
97
|
+
const { waitThenKill, abortGraceMs } = require('./utils/abort-coordinator');
|
|
98
|
+
const graceSec = Math.ceil(abortGraceMs() / 1000);
|
|
99
|
+
process.stderr.write(`Waiting up to ${graceSec}s for the session process (pid ${meta.pid}) to exit gracefully...\n`);
|
|
100
|
+
const { killed, exited } = await waitThenKill(meta.pid);
|
|
101
|
+
if (killed.length > 0) {
|
|
102
|
+
process.stderr.write(`Process ${meta.pid} did not exit in time — sent SIGTERM (a hard kill on Windows).\n`);
|
|
103
|
+
} else if (exited.length > 0) {
|
|
104
|
+
process.stderr.write('Process exited cleanly.\n');
|
|
105
|
+
} else {
|
|
106
|
+
process.stderr.write(`Process ${meta.pid} is still running — could not signal it (insufficient permission). It may require manual termination.\n`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// aborted[] must list ids ACTUALLY marked aborted (buildAbortResult's doc
|
|
111
|
+
// comment) — gate on markAborted's own return, matching --all/wave-leg gating.
|
|
112
|
+
console.log(JSON.stringify(buildAbortResult({ scope: 'session', taskId, aborted: wasMarked ? [taskId] : [] }), null, 2));
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Handle 'sidecar abort' command
|
|
118
|
+
* Marks a running session as aborted
|
|
119
|
+
* @returns {Promise<number|undefined>} exit code (json mode only; human mode
|
|
120
|
+
* uses process.exit internally on failure paths and implicitly returns 0)
|
|
121
|
+
*/
|
|
122
|
+
async function handleAbort(args) {
|
|
123
|
+
const useJson = !!args.json;
|
|
124
|
+
|
|
125
|
+
if (args.all) {
|
|
126
|
+
const project = args.cwd || process.cwd();
|
|
127
|
+
if (useJson) { return handleAbortAllJson(project); }
|
|
128
|
+
|
|
129
|
+
const { enumerateSessions } = require('./sidecar/read');
|
|
130
|
+
const { markAborted } = require('./utils/session-abort');
|
|
131
|
+
const { resolveExistingSessionDir } = require('./session-manager');
|
|
132
|
+
// A session may complete between enumeration and the write (TOCTOU); the
|
|
133
|
+
// window is tiny for a local CLI and markAborted is best-effort, so we count
|
|
134
|
+
// only sessions actually marked aborted.
|
|
135
|
+
const running = enumerateSessions(project, { status: 'running' });
|
|
136
|
+
if (running.length === 0) {
|
|
137
|
+
console.log('No running sessions to abort.');
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
let aborted = 0;
|
|
141
|
+
for (const s of running) {
|
|
142
|
+
if (markAborted(resolveExistingSessionDir(project, s.id), 'abort --all')) {
|
|
143
|
+
aborted++;
|
|
144
|
+
console.log(`Aborted ${s.id}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.log(`Aborted ${aborted} running session(s).`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const taskId = args._[1];
|
|
152
|
+
|
|
153
|
+
if (!taskId) {
|
|
154
|
+
if (useJson) { process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: 'Error: task_id is required for abort' })); }
|
|
155
|
+
console.error('Error: task_id is required for abort');
|
|
156
|
+
console.error('Usage: amicus abort <task_id>');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const taskIdCheck = validateTaskId(taskId);
|
|
161
|
+
if (!taskIdCheck.valid) {
|
|
162
|
+
if (useJson) { process.exit(failJson(true, { code: ERROR_CODES.BAD_SESSION, message: taskIdCheck.error })); }
|
|
163
|
+
console.error(taskIdCheck.error);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (useJson) { return await handleAbortTaskJson(args, taskId); }
|
|
168
|
+
|
|
169
|
+
const project = args.cwd || process.cwd();
|
|
170
|
+
const sessionDir = safeSessionDir(project, taskId);
|
|
171
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
172
|
+
|
|
173
|
+
if (!fs.existsSync(metaPath)) {
|
|
174
|
+
console.error(`Session ${taskId} not found`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let meta;
|
|
179
|
+
try {
|
|
180
|
+
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
181
|
+
} catch (_err) {
|
|
182
|
+
console.error(`Session ${taskId} has malformed metadata`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
// Guard against a completed/terminal session: without this, metadata.pid
|
|
186
|
+
// still holds a value forever and `amicus abort <completed-task>` would
|
|
187
|
+
// wait the grace window then TerminateProcess whatever unrelated process
|
|
188
|
+
// now owns that (possibly recycled) pid. Mirrors MCP's amicus_abort guard
|
|
189
|
+
// (src/mcp-server.js) — same wording, no re-mark, no kill.
|
|
190
|
+
if (meta.status !== 'running') {
|
|
191
|
+
console.log(`Session ${taskId} is not running (status: ${meta.status}).`);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { markAborted } = require('./utils/session-abort');
|
|
196
|
+
|
|
197
|
+
// F4: aborting a wave aborts every still-running leg too.
|
|
198
|
+
if (meta.type === 'wave') {
|
|
199
|
+
const { resolveExistingSessionDir } = require('./session-manager');
|
|
200
|
+
let aborted = 0;
|
|
201
|
+
for (const legId of meta.legs || []) {
|
|
202
|
+
const legDir = resolveExistingSessionDir(project, legId);
|
|
203
|
+
try {
|
|
204
|
+
const legMeta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8'));
|
|
205
|
+
// TOCTOU: a leg may complete between this read and markAborted —
|
|
206
|
+
// best-effort, same contract as abort --all above.
|
|
207
|
+
if (legMeta.status === 'running') {
|
|
208
|
+
if (markAborted(legDir, 'wave abort')) { aborted++; }
|
|
209
|
+
}
|
|
210
|
+
} catch { /* skip unreadable leg */ }
|
|
211
|
+
}
|
|
212
|
+
markAborted(sessionDir, 'manual abort');
|
|
213
|
+
console.log(`Wave ${taskId} marked as aborted (${aborted} running leg(s) aborted).`);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
markAborted(sessionDir, 'manual abort');
|
|
218
|
+
console.log(`Session ${taskId} marked as aborted.`);
|
|
219
|
+
|
|
220
|
+
// Phase 3: fallback direct-kill for a session that does not honor the
|
|
221
|
+
// marker. Headless loops poll the marker every ~2s and the interactive
|
|
222
|
+
// abort watch does too, so the normal outcome is a graceful exit during
|
|
223
|
+
// the grace window; only a wedged/legacy process gets SIGTERM. The wait is
|
|
224
|
+
// awaited on purpose — bin/amicus.js arms its force-exit watchdog only
|
|
225
|
+
// after this handler returns.
|
|
226
|
+
if (meta.pid) {
|
|
227
|
+
const { waitThenKill, abortGraceMs } = require('./utils/abort-coordinator');
|
|
228
|
+
const graceSec = Math.ceil(abortGraceMs() / 1000);
|
|
229
|
+
console.log(`Waiting up to ${graceSec}s for the session process (pid ${meta.pid}) to exit gracefully...`);
|
|
230
|
+
const { killed, exited } = await waitThenKill(meta.pid);
|
|
231
|
+
if (killed.length > 0) {
|
|
232
|
+
console.log(`Process ${meta.pid} did not exit in time — sent SIGTERM (a hard kill on Windows).`);
|
|
233
|
+
} else if (exited.length > 0) {
|
|
234
|
+
console.log('Process exited cleanly.');
|
|
235
|
+
} else {
|
|
236
|
+
// 3.1 contract: an EPERM-unkillable pid lands in NEITHER array —
|
|
237
|
+
// it is still alive and we could not signal it. Say so honestly.
|
|
238
|
+
console.log(`Process ${meta.pid} is still running — could not signal it (insufficient permission). It may require manual termination.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { handleAbort };
|
|
@@ -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: () => {
|
|
@@ -175,59 +182,12 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
175
182
|
: { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint: `${HINTS.reinstall} (re-runs the skill install)` }
|
|
176
183
|
)));
|
|
177
184
|
|
|
178
|
-
checks.push(guard('mcp', 'MCP registration', () =>
|
|
179
|
-
const code = d.discoverClaudeCodeMcps();
|
|
180
|
-
const cowork = d.discoverCoworkMcps();
|
|
181
|
-
const inCode = !!(code && code.amicus);
|
|
182
|
-
const inCowork = !!(cowork && cowork.amicus);
|
|
183
|
-
// Primary signal: Claude Code MCP registration. Cowork/Desktop is reported as bonus only.
|
|
184
|
-
if (!inCode) {
|
|
185
|
-
return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint: `${HINTS.reinstall} (or install the amicus plugin)` };
|
|
186
|
-
}
|
|
187
|
-
const extra = inCowork ? ', Cowork/Desktop' : '';
|
|
188
|
-
return { id: 'mcp', name: 'MCP registration', status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
|
|
189
|
-
}));
|
|
185
|
+
checks.push(guard('mcp', 'MCP registration', () => mcpChecks.evaluateMcpRegistration(d)));
|
|
190
186
|
|
|
191
|
-
// Duplicate legacy 'sidecar' MCP registration
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
checks.push(guard('mcp-legacy', 'Legacy sidecar MCP entry', () => {
|
|
196
|
-
const id = 'mcp-legacy'; const name = 'Legacy sidecar MCP entry';
|
|
197
|
-
const entries = d.inspectLegacyMcpEntries() || [];
|
|
198
|
-
const dupes = entries.filter(e => e.status === 'removable');
|
|
199
|
-
const custom = entries.filter(e => e.status === 'customized');
|
|
200
|
-
// An unreadable config is neither "no problem" nor a duplicate we can act
|
|
201
|
-
// on — reporting it as ok/'none' would hide a config doctor (and --fix)
|
|
202
|
-
// could not actually inspect. Always surface it, even alongside dupes.
|
|
203
|
-
const unreadable = entries.filter(e => e.status === 'unreadable');
|
|
204
|
-
const unreadableNote = unreadable.length
|
|
205
|
-
? `${unreadable.map(e => e.target).join(', ')} config unreadable — skipped`
|
|
206
|
-
: null;
|
|
207
|
-
if (dupes.length === 0) {
|
|
208
|
-
if (unreadableNote) {
|
|
209
|
-
const suffix = custom.length ? `; custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone` : '';
|
|
210
|
-
return { id, name, status: 'warn', message: `${unreadableNote}${suffix}`, hint: null };
|
|
211
|
-
}
|
|
212
|
-
const message = custom.length
|
|
213
|
-
? `custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone`
|
|
214
|
-
: 'none';
|
|
215
|
-
return { id, name, status: 'ok', message, hint: null };
|
|
216
|
-
}
|
|
217
|
-
if (d.fix) {
|
|
218
|
-
const removed = (d.migrateLegacyMcpEntries() || []).filter(r => r.result === 'removed');
|
|
219
|
-
if (removed.length >= dupes.length) {
|
|
220
|
-
const message = `removed legacy entry from: ${removed.map(r => r.target).join(', ')}`;
|
|
221
|
-
return unreadableNote
|
|
222
|
-
? { id, name, status: 'warn', message: `${message}; ${unreadableNote}`, hint: HINTS.removeLegacySidecar }
|
|
223
|
-
: { id, name, status: 'ok', message, hint: null };
|
|
224
|
-
}
|
|
225
|
-
const message = `removed ${removed.length}/${dupes.length} duplicate(s) — could not update every config`;
|
|
226
|
-
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
|
|
227
|
-
}
|
|
228
|
-
const message = `duplicate 'sidecar' entry in ${dupes.map(e => e.target).join(', ')} — doubles the MCP tool list`;
|
|
229
|
-
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
|
|
230
|
-
}));
|
|
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)));
|
|
231
191
|
|
|
232
192
|
checks.push(guard('sessions-index-tmp', 'Session index tmp files', () => tmpSweep.evaluateSessionIndexTmpSweep(d)));
|
|
233
193
|
|
|
@@ -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)) {
|
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
|
package/src/cli.js
CHANGED
|
@@ -442,6 +442,7 @@ Options for 'status':
|
|
|
442
442
|
abort: `
|
|
443
443
|
Options for 'abort':
|
|
444
444
|
--all Abort all running sessions in this project
|
|
445
|
+
--json Emit the abort result as stable JSON
|
|
445
446
|
`,
|
|
446
447
|
read: `
|
|
447
448
|
Options for 'read':
|
|
@@ -457,6 +458,7 @@ Options for 'continue':
|
|
|
457
458
|
--model <model> Optional. Override the model (alias or provider/model)
|
|
458
459
|
--cwd <path> Project directory (default: cwd)
|
|
459
460
|
--no-ui Run without GUI (autonomous mode)
|
|
461
|
+
--json With --no-ui: emit the run result as stable JSON
|
|
460
462
|
--timeout <minutes> Headless timeout (default: 15)
|
|
461
463
|
--context-turns <N> Max conversation turns (default: 50)
|
|
462
464
|
--context-max-tokens <N> Max context tokens (default: 80000)
|
|
@@ -466,6 +468,7 @@ Options for 'resume':
|
|
|
466
468
|
<task_id> Required. Session to reopen (positional)
|
|
467
469
|
--cwd <path> Project directory (default: cwd)
|
|
468
470
|
--no-ui Run without GUI (autonomous mode)
|
|
471
|
+
--json With --no-ui: emit the run result as stable JSON
|
|
469
472
|
--timeout <minutes> Headless timeout (default: 15)
|
|
470
473
|
`,
|
|
471
474
|
council: `
|
|
@@ -546,6 +549,22 @@ Examples:
|
|
|
546
549
|
amicus read abc123 --conversation
|
|
547
550
|
`;
|
|
548
551
|
|
|
552
|
+
// Commands handled directly in bin/amicus.js's switch that have no dedicated
|
|
553
|
+
// USAGE_COMMAND_BLOCKS entry (their usage is covered by USAGE_HEADER's command
|
|
554
|
+
// list only). Kept minimal and explicit rather than parsing the switch itself.
|
|
555
|
+
const SWITCH_ONLY_COMMANDS = ['update'];
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Canonical list of top-level command names, for did-you-mean suggestions and
|
|
559
|
+
* any other consumer that needs "every command amicus recognizes" without a
|
|
560
|
+
* second hand-maintained list. Derived from USAGE_COMMAND_BLOCKS (the existing
|
|
561
|
+
* per-command help source of truth) plus SWITCH_ONLY_COMMANDS.
|
|
562
|
+
* @returns {string[]}
|
|
563
|
+
*/
|
|
564
|
+
function getCommandNames() {
|
|
565
|
+
return [...Object.keys(USAGE_COMMAND_BLOCKS), ...SWITCH_ONLY_COMMANDS];
|
|
566
|
+
}
|
|
567
|
+
|
|
549
568
|
/**
|
|
550
569
|
* Get usage text.
|
|
551
570
|
*
|
|
@@ -570,5 +589,6 @@ module.exports = {
|
|
|
570
589
|
parseArgs,
|
|
571
590
|
validateStartArgs,
|
|
572
591
|
getUsage,
|
|
592
|
+
getCommandNames,
|
|
573
593
|
DEFAULTS
|
|
574
594
|
};
|