amicus 1.7.6 → 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.
- package/.claude-plugin/plugin.json +27 -6
- package/CHANGELOG.md +65 -0
- package/LICENSE +2 -22
- package/README.md +38 -5
- package/bin/amicus.js +9 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +72 -23
- package/src/cli-handlers-doctor.js +43 -0
- package/src/cli-handlers-status.js +76 -0
- package/src/cli-handlers.js +32 -0
- package/src/cli.js +8 -0
- package/src/mcp-server.js +124 -21
- package/src/mcp-tools.js +28 -1
- package/src/mcp-wait.js +163 -0
- package/src/sidecar/continue.js +24 -6
- package/src/sidecar/conversation-mirror.js +17 -4
- package/src/sidecar/interactive-abort.js +112 -0
- package/src/sidecar/interactive.js +32 -2
- package/src/sidecar/progress-fields.js +60 -0
- package/src/sidecar/progress.js +52 -48
- package/src/sidecar/resume.js +23 -7
- package/src/sidecar/start.js +6 -7
- package/src/utils/abort-coordinator.js +91 -0
- package/src/utils/error-doc.js +3 -0
- package/src/utils/legacy-mcp-migration.js +119 -0
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +6 -9
- package/src/utils/mcp-self-identity.js +69 -0
- package/src/utils/remediation-hints.js +8 -0
- package/src/utils/shared-server.js +50 -18
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { logger } = require('../utils/logger');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_INTERVAL_MS = 2000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Watch a session's metadata.json for an external abort marker
|
|
11
|
+
* (status === 'aborted', written by `amicus abort` or MCP amicus_abort) and
|
|
12
|
+
* tear the interactive session down when it appears:
|
|
13
|
+
* 1. best-effort server-side abortSession (stops token spend immediately),
|
|
14
|
+
* 2. SIGTERM the Electron process (killIfAlive).
|
|
15
|
+
* Teardown then completes through the EXISTING Electron close handler
|
|
16
|
+
* (mirror.stop → usage persist, server.close) — this watcher triggers
|
|
17
|
+
* teardown but never owns it. Best-effort: read/parse errors keep polling.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {string} opts.sessionDir
|
|
21
|
+
* @param {() => Promise<void>} opts.abortOpenCodeSession
|
|
22
|
+
* @param {() => void} opts.killElectron
|
|
23
|
+
* @param {number} [opts.intervalMs=2000]
|
|
24
|
+
* @returns {{ stop: () => void, wasAborted: () => boolean }}
|
|
25
|
+
*/
|
|
26
|
+
function startAbortWatch({ sessionDir, abortOpenCodeSession, killElectron, intervalMs = DEFAULT_INTERVAL_MS }) {
|
|
27
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
28
|
+
let timer = null;
|
|
29
|
+
let stopped = false;
|
|
30
|
+
let aborted = false;
|
|
31
|
+
|
|
32
|
+
const schedule = () => {
|
|
33
|
+
if (stopped) { return; }
|
|
34
|
+
timer = setTimeout(tick, intervalMs);
|
|
35
|
+
if (timer.unref) { timer.unref(); }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function tick() {
|
|
39
|
+
if (stopped) { return; }
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(metaPath)) {
|
|
42
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
43
|
+
if (meta.status === 'aborted') {
|
|
44
|
+
aborted = true;
|
|
45
|
+
stopped = true;
|
|
46
|
+
logger.info('External abort marker detected — tearing down interactive session', { sessionDir });
|
|
47
|
+
try { await abortOpenCodeSession(); } catch (err) {
|
|
48
|
+
logger.warn('abortSession failed during interactive abort', { error: err.message });
|
|
49
|
+
}
|
|
50
|
+
try { killElectron(); } catch (err) {
|
|
51
|
+
logger.warn('Electron kill failed during interactive abort', { error: err.message });
|
|
52
|
+
}
|
|
53
|
+
return; // teardown continues via the Electron close handler
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
logger.debug('Abort watch poll failed (best-effort)', { error: err.message });
|
|
58
|
+
}
|
|
59
|
+
schedule();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
schedule();
|
|
63
|
+
return {
|
|
64
|
+
stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null; } },
|
|
65
|
+
wasAborted() { return aborted; },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fold the abort-watch outcome into the runInteractive result so
|
|
71
|
+
* resolveTerminalState() maps a marker-triggered GUI teardown to 'aborted' —
|
|
72
|
+
* never 'error' (SIGTERM'd Electron exits non-zero) and never 'complete'
|
|
73
|
+
* (Electron exiting 0 after the marker landed).
|
|
74
|
+
*/
|
|
75
|
+
function markResultAborted(result, wasAborted) {
|
|
76
|
+
if (wasAborted) {
|
|
77
|
+
result.aborted = true;
|
|
78
|
+
result.completed = false;
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Best-effort, ONE-SHOT read of metadata.json to check for a durable
|
|
85
|
+
* 'aborted' marker written by `amicus abort` / MCP amicus_abort.
|
|
86
|
+
*
|
|
87
|
+
* Closes a race the poll-based startAbortWatch cannot: the marker can land
|
|
88
|
+
* and Electron can exit naturally before the watch's next ~2s tick (or the
|
|
89
|
+
* close handler can already be past markResultAborted, awaiting
|
|
90
|
+
* mirror.stop()). In that window abortWatch.wasAborted() reads false even
|
|
91
|
+
* though the session WAS aborted, so resolveTerminalState resolves
|
|
92
|
+
* 'complete' and finalizeSession clobbers the on-disk 'aborted' status.
|
|
93
|
+
* Callers should OR this into their aborted flag immediately before
|
|
94
|
+
* markResultAborted. Missing/corrupt metadata reads as false (best-effort,
|
|
95
|
+
* matches startAbortWatch's own error handling).
|
|
96
|
+
*
|
|
97
|
+
* @param {string} sessionDir
|
|
98
|
+
* @returns {boolean} true iff metadata.json exists and status === 'aborted'
|
|
99
|
+
*/
|
|
100
|
+
function readAbortedMarker(sessionDir) {
|
|
101
|
+
try {
|
|
102
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
103
|
+
if (!fs.existsSync(metaPath)) { return false; }
|
|
104
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
105
|
+
return meta.status === 'aborted';
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logger.debug('readAbortedMarker: metadata read failed (best-effort)', { error: err.message });
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { startAbortWatch, markResultAborted, readAbortedMarker, DEFAULT_INTERVAL_MS };
|
|
@@ -4,17 +4,20 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
7
8
|
const { spawn } = require('child_process');
|
|
8
9
|
|
|
9
10
|
const { startOpenCodeServer } = require('./session-utils');
|
|
10
|
-
const { createSession, sendPromptAsync, getMessages } = require('../opencode-client');
|
|
11
|
+
const { createSession, sendPromptAsync, getMessages, abortSession } = require('../opencode-client');
|
|
11
12
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
12
13
|
const { logger } = require('../utils/logger');
|
|
13
14
|
const { getCompatEnv } = require('../utils/env-compat');
|
|
14
15
|
const { startInteractiveMirror } = require('./interactive-mirror');
|
|
16
|
+
const { startAbortWatch, markResultAborted, readAbortedMarker } = require('./interactive-abort');
|
|
15
17
|
const { getSessionDir } = require('../session-manager');
|
|
16
18
|
const { canonicalProjectPath } = require('../utils/project-path');
|
|
17
19
|
const { ensureElectron } = require('./electron-ensure');
|
|
20
|
+
const { writeProgress } = require('./progress');
|
|
18
21
|
|
|
19
22
|
/** Resolve the Electron binary path ONLY when the exe actually exists on disk.
|
|
20
23
|
* #54: path.txt surviving (require('electron') resolving) is NOT enough — a
|
|
@@ -115,6 +118,14 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
115
118
|
|
|
116
119
|
const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client } = options;
|
|
117
120
|
|
|
121
|
+
// F6c: mirror headless's lifecycle stages (best-effort — a write failure must
|
|
122
|
+
// never break the GUI) so the heartbeat/status never read "Starting up...".
|
|
123
|
+
const sessionDir = getSessionDir(project, taskId);
|
|
124
|
+
const progressStage = (stage) => {
|
|
125
|
+
try { fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 }); writeProgress(sessionDir, stage); } catch { /* best-effort */ }
|
|
126
|
+
};
|
|
127
|
+
progressStage('initializing');
|
|
128
|
+
|
|
118
129
|
// Scope the OpenCode session to the project/--cwd (#45). The SDK Session
|
|
119
130
|
// object echoes a `directory`, but createSession() returns only the id, so we
|
|
120
131
|
// use the canonicalized --cwd CONSISTENTLY for BOTH the create scope and the
|
|
@@ -133,6 +144,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
133
144
|
});
|
|
134
145
|
ocClient = result.client;
|
|
135
146
|
server = result.server;
|
|
147
|
+
progressStage('server_ready');
|
|
136
148
|
} catch (error) {
|
|
137
149
|
logger.error('Failed to start OpenCode server', { error: error.message });
|
|
138
150
|
return {
|
|
@@ -148,9 +160,11 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
148
160
|
// Resume: reconnect to existing OpenCode session
|
|
149
161
|
sessionId = opencodeSessionId;
|
|
150
162
|
logger.info('Reconnecting to existing session', { sessionId });
|
|
163
|
+
progressStage('session_created');
|
|
151
164
|
} else {
|
|
152
165
|
// New session: create and send initial prompt, scoped to --cwd (#45).
|
|
153
166
|
sessionId = await createSession(ocClient, sessionDirectory);
|
|
167
|
+
progressStage('session_created');
|
|
154
168
|
|
|
155
169
|
// System prompt is set on agent config (hidden from UI).
|
|
156
170
|
// Do NOT pass system here — promptAsync's system field is visible in the UI.
|
|
@@ -165,6 +179,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
165
179
|
if (reasoning) { promptOptions.reasoning = reasoning; }
|
|
166
180
|
|
|
167
181
|
await sendPromptAsync(ocClient, sessionId, promptOptions);
|
|
182
|
+
progressStage('prompt_sent');
|
|
168
183
|
}
|
|
169
184
|
logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
|
|
170
185
|
} catch (error) {
|
|
@@ -199,13 +214,21 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
199
214
|
onActivity: () => watchdog.touch(),
|
|
200
215
|
});
|
|
201
216
|
|
|
202
|
-
const sessionDir = getSessionDir(project, taskId);
|
|
203
217
|
const mirror = startInteractiveMirror({
|
|
204
218
|
getMessages: () => getMessages(ocClient, sessionId, sessionDirectory),
|
|
205
219
|
sessionDir,
|
|
206
220
|
onActivity: () => watchdog.touch(),
|
|
207
221
|
});
|
|
208
222
|
|
|
223
|
+
// Phase 3: external aborts (CLI `amicus abort` / MCP amicus_abort) write a
|
|
224
|
+
// metadata marker; nothing in the GUI path watched it before. On marker:
|
|
225
|
+
// server-side abort + SIGTERM Electron; the close handler finishes teardown.
|
|
226
|
+
const abortWatch = startAbortWatch({
|
|
227
|
+
sessionDir,
|
|
228
|
+
abortOpenCodeSession: () => abortSession(ocClient, sessionId, sessionDirectory),
|
|
229
|
+
killElectron: () => killIfAlive(electronProcess),
|
|
230
|
+
});
|
|
231
|
+
|
|
209
232
|
return new Promise((resolve, _reject) => {
|
|
210
233
|
// Prefer the path ensureElectron() resolved: a same-process first-use
|
|
211
234
|
// provision can leave require('electron') cached as a stale null (#55).
|
|
@@ -248,6 +271,13 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
248
271
|
process.removeListener('SIGTERM', killChildOnParentDeath);
|
|
249
272
|
watchdog.cancel();
|
|
250
273
|
activityPoller.stop();
|
|
274
|
+
abortWatch.stop();
|
|
275
|
+
// Close a race the poll-based watch can miss: the marker can land and
|
|
276
|
+
// Electron can exit naturally before the next ~2s tick. Re-read the
|
|
277
|
+
// on-disk marker ONCE here and OR it in so a durable 'aborted' status
|
|
278
|
+
// is never clobbered to 'complete' below (phase-3 final review, FIX 2).
|
|
279
|
+
const wasAborted = abortWatch.wasAborted() || readAbortedMarker(sessionDir);
|
|
280
|
+
markResultAborted(result, wasAborted);
|
|
251
281
|
try {
|
|
252
282
|
const { usage } = await mirror.stop();
|
|
253
283
|
if (usage) { result.usage = usage; }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module progress-fields
|
|
3
|
+
* Derived, agent-facing progress fields shared by the MCP status/list
|
|
4
|
+
* handlers, the `amicus status` CLI, and readProgress(): a sanitized preview
|
|
5
|
+
* of the newest assistant text, and a coarse lifecycle stage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/** Coarse stages surfaced to agents. */
|
|
11
|
+
const COARSE_STAGES = ['starting', 'generating', 'folding', 'terminal'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collapse whitespace and defang fence/tag characters so the preview can be
|
|
15
|
+
* embedded in a one-line JSON status without opening a code fence or tag
|
|
16
|
+
* (prompt-injection hygiene: the FULL text is only available via amicus_read,
|
|
17
|
+
* which wraps it in the untrusted-output fence).
|
|
18
|
+
* @param {string} text @param {number} [max=120] @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function sanitizePreview(text, max = 120) {
|
|
21
|
+
const collapsed = String(text).replace(/[`<>]/g, '').replace(/\s+/g, ' ').trim();
|
|
22
|
+
return collapsed.length > max ? collapsed.slice(0, max) + '…' : collapsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The newest assistant TEXT content from parsed conversation.jsonl entries,
|
|
27
|
+
* sanitized to ~120 chars. Tool-use/result lines are skipped. Null when no
|
|
28
|
+
* assistant text exists yet.
|
|
29
|
+
* @param {object[]} entries @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
function latestAssistantPreview(entries) {
|
|
32
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
33
|
+
const e = entries[i];
|
|
34
|
+
if (e && e.role === 'assistant' && typeof e.content === 'string' && e.content.trim()) {
|
|
35
|
+
return sanitizePreview(e.content);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Map (metadata.status, progress.stage) to the coarse agent-facing stage.
|
|
43
|
+
* - terminal metadata status -> 'terminal'
|
|
44
|
+
* - progress 'receiving' -> 'generating'
|
|
45
|
+
* - progress 'complete' while metadata still says running -> 'folding'
|
|
46
|
+
* (mirror stopped; summary/conflict finalize in flight)
|
|
47
|
+
* - anything else -> 'starting'
|
|
48
|
+
* @param {string|undefined} metadataStatus @param {string|undefined} progressStage
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function deriveStage(metadataStatus, progressStage) {
|
|
52
|
+
if (metadataStatus && metadataStatus !== 'running' && metadataStatus !== 'unknown') {
|
|
53
|
+
return 'terminal';
|
|
54
|
+
}
|
|
55
|
+
if (progressStage === 'receiving') { return 'generating'; }
|
|
56
|
+
if (progressStage === 'complete') { return 'folding'; }
|
|
57
|
+
return 'starting';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { sanitizePreview, latestAssistantPreview, deriveStage, COARSE_STAGES };
|
package/src/sidecar/progress.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* Sidecar Progress Reader
|
|
3
3
|
*
|
|
4
4
|
* Reads conversation.jsonl and progress.json from a session directory
|
|
5
|
-
* and returns progress info: message count, last activity
|
|
6
|
-
* and
|
|
5
|
+
* and returns progress info: message count, last activity (relative string,
|
|
6
|
+
* raw ms, and absolute ISO), latest action, a sanitized preview of the
|
|
7
|
+
* newest assistant text, and lifecycle stage.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
const fs = require('fs');
|
|
10
11
|
const path = require('path');
|
|
12
|
+
const { latestAssistantPreview } = require('./progress-fields');
|
|
11
13
|
|
|
12
14
|
/** Lifecycle stage labels */
|
|
13
15
|
const STAGE_LABELS = {
|
|
@@ -122,7 +124,9 @@ function writeProgress(sessionDir, stage, extra = {}) {
|
|
|
122
124
|
* Read progress from a session's conversation.jsonl and progress.json files.
|
|
123
125
|
*
|
|
124
126
|
* @param {string} sessionDir - Path to the session directory
|
|
125
|
-
* @returns {{ messages: number, lastActivity: string, latest: string,
|
|
127
|
+
* @returns {{ messages: number, lastActivity: string, latest: string,
|
|
128
|
+
* lastActivityMs: number|null, lastActivityAt: string|null,
|
|
129
|
+
* latestPreview: string|null, stage?: string }}
|
|
126
130
|
*/
|
|
127
131
|
function readProgress(sessionDir) {
|
|
128
132
|
const convPath = path.join(sessionDir, 'conversation.jsonl');
|
|
@@ -159,63 +163,63 @@ function readProgress(sessionDir) {
|
|
|
159
163
|
? computeLastActivity(convStat.mtime)
|
|
160
164
|
: 'never';
|
|
161
165
|
|
|
162
|
-
// Read progress.json
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
// Read + parse progress.json ONCE; stage/latest/lastActivity below and the
|
|
167
|
+
// newestActivity computation all derive from this single parse.
|
|
168
|
+
let progress = null;
|
|
165
169
|
if (fs.existsSync(progressPath)) {
|
|
166
170
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
progress = JSON.parse(fs.readFileSync(progressPath, 'utf-8'));
|
|
172
|
+
} catch {
|
|
173
|
+
// Ignore malformed progress file
|
|
174
|
+
}
|
|
175
|
+
}
|
|
169
176
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
latest = progress.stageLabel;
|
|
173
|
-
}
|
|
177
|
+
// Lifecycle stage info from progress.json
|
|
178
|
+
let stage;
|
|
174
179
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (messages > 0 && progress.latestTool && (latest === 'Working...' || latest === 'Executing tool call...')) {
|
|
178
|
-
latest = `Calling tool: ${progress.latestTool}`;
|
|
179
|
-
}
|
|
180
|
+
if (progress) {
|
|
181
|
+
stage = progress.stage;
|
|
180
182
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
// Use progress stage label when no assistant entries exist yet
|
|
184
|
+
if (messages === 0 && progress.stageLabel) {
|
|
185
|
+
latest = progress.stageLabel;
|
|
186
|
+
}
|
|
185
187
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
lastActivity = computeLastActivity(progressTime);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
} catch {
|
|
194
|
-
// Ignore malformed progress file
|
|
188
|
+
// Use progress.json latestTool for better latest when extractLatest
|
|
189
|
+
// returns a generic fallback (tool_use entries without name)
|
|
190
|
+
if (messages > 0 && progress.latestTool && (latest === 'Working...' || latest === 'Executing tool call...')) {
|
|
191
|
+
latest = `Calling tool: ${progress.latestTool}`;
|
|
195
192
|
}
|
|
196
|
-
}
|
|
197
193
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const progressMs = Date.now() - new Date(progress.updatedAt).getTime();
|
|
209
|
-
if (lastActivityMs === null || progressMs < lastActivityMs) {
|
|
210
|
-
lastActivityMs = progressMs;
|
|
211
|
-
}
|
|
194
|
+
// Use messagesReceived from progress when conversation has no assistant entries
|
|
195
|
+
if (messages === 0 && progress.messagesReceived !== undefined) {
|
|
196
|
+
messages = progress.messagesReceived;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Use progress updatedAt for lastActivity if more recent
|
|
200
|
+
if (progress.updatedAt) {
|
|
201
|
+
const progressTime = new Date(progress.updatedAt);
|
|
202
|
+
if (!convStat || progressTime > convStat.mtime) {
|
|
203
|
+
lastActivity = computeLastActivity(progressTime);
|
|
212
204
|
}
|
|
213
|
-
} catch {
|
|
214
|
-
// Ignore — already handled above
|
|
215
205
|
}
|
|
216
206
|
}
|
|
217
207
|
|
|
218
|
-
|
|
208
|
+
// Newest activity timestamp across BOTH sources (conv mtime, progress.updatedAt):
|
|
209
|
+
// feeds lastActivityMs (stall detection) AND lastActivityAt (absolute ISO for
|
|
210
|
+
// agents) from one value so they can never disagree.
|
|
211
|
+
let newestActivity = convStat ? convStat.mtime : null;
|
|
212
|
+
if (progress && progress.updatedAt) {
|
|
213
|
+
const t = new Date(progress.updatedAt);
|
|
214
|
+
if (!newestActivity || t > newestActivity) { newestActivity = t; }
|
|
215
|
+
}
|
|
216
|
+
const lastActivityMs = newestActivity ? Date.now() - newestActivity.getTime() : null;
|
|
217
|
+
|
|
218
|
+
const result = {
|
|
219
|
+
messages, lastActivity, latest, lastActivityMs,
|
|
220
|
+
lastActivityAt: newestActivity ? newestActivity.toISOString() : null,
|
|
221
|
+
latestPreview: latestAssistantPreview(entries),
|
|
222
|
+
};
|
|
219
223
|
if (stage !== undefined) {
|
|
220
224
|
result.stage = stage;
|
|
221
225
|
}
|
package/src/sidecar/resume.js
CHANGED
|
@@ -110,7 +110,10 @@ function updateSessionStatus(sessionDir, status) {
|
|
|
110
110
|
return meta;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Resume a previous sidecar session - Spec Reference: §4.3, §8.3
|
|
115
|
+
* @returns {Promise<number>} process exit code
|
|
116
|
+
*/
|
|
114
117
|
async function resumeSidecar(options) {
|
|
115
118
|
const {
|
|
116
119
|
taskId, project = process.cwd(), headless = false, timeout = 15,
|
|
@@ -163,6 +166,7 @@ async function resumeSidecar(options) {
|
|
|
163
166
|
heartbeat = createHeartbeat();
|
|
164
167
|
|
|
165
168
|
let summary;
|
|
169
|
+
let result;
|
|
166
170
|
const effectiveAgent = metadata.agent || 'Build';
|
|
167
171
|
|
|
168
172
|
// Load conversation for both paths (interactive already did this, headless didn't)
|
|
@@ -173,7 +177,7 @@ async function resumeSidecar(options) {
|
|
|
173
177
|
|
|
174
178
|
if (headless) {
|
|
175
179
|
const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
|
|
176
|
-
|
|
180
|
+
result = await runHeadless(
|
|
177
181
|
metadata.model, resumePrompt, userMessage,
|
|
178
182
|
taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
|
|
179
183
|
);
|
|
@@ -184,7 +188,7 @@ async function resumeSidecar(options) {
|
|
|
184
188
|
} else {
|
|
185
189
|
logger.info('Launching interactive resume', { taskId, model: metadata.model });
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
result = await runInteractive(
|
|
188
192
|
metadata.model, resumePrompt, metadata.briefing || '',
|
|
189
193
|
taskId, project,
|
|
190
194
|
{
|
|
@@ -202,10 +206,22 @@ async function resumeSidecar(options) {
|
|
|
202
206
|
// Output summary
|
|
203
207
|
outputSummary(summary);
|
|
204
208
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
+
// Map the run result to the canonical terminal status + exit code —
|
|
210
|
+
// mirrors start.js. Explicit status preserves the interactive
|
|
211
|
+
// empty-summary carve-out (the #36 guard never re-classifies it).
|
|
212
|
+
const { resolveTerminalState } = require('./session-finalize');
|
|
213
|
+
const terminal = resolveTerminalState(result);
|
|
214
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
215
|
+
if (terminal.status === 'error') {
|
|
216
|
+
updatedMetadata.status = 'error';
|
|
217
|
+
updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
218
|
+
updatedMetadata.completedAt = new Date().toISOString();
|
|
219
|
+
fs.writeFileSync(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
|
|
220
|
+
logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
|
|
221
|
+
} else {
|
|
222
|
+
finalizeSession(sessionDir, summary, project, updatedMetadata, { status: terminal.status });
|
|
223
|
+
}
|
|
224
|
+
return terminal.exitCode; // finally below still releases the lock first
|
|
209
225
|
} finally {
|
|
210
226
|
if (heartbeat) { heartbeat.stop(); }
|
|
211
227
|
releaseLock(sessionDir);
|
package/src/sidecar/start.js
CHANGED
|
@@ -23,6 +23,7 @@ const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
|
23
23
|
const { loadMcpConfig, parseMcpSpec } = require('../opencode-client');
|
|
24
24
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
25
25
|
const { discoverParentMcps } = require('../utils/mcp-discovery');
|
|
26
|
+
const { stripSelfMcpEntries } = require('../utils/mcp-self-identity');
|
|
26
27
|
|
|
27
28
|
/** Generate a unique 8-character hex task ID */
|
|
28
29
|
function generateTaskId() {
|
|
@@ -117,13 +118,11 @@ function buildMcpConfig(options) {
|
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
// Always exclude
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
logger.debug('Auto-excluded sidecar MCP (recursive spawn prevention)');
|
|
126
|
-
}
|
|
121
|
+
// Always exclude amicus itself — under ANY registered name or aliased
|
|
122
|
+
// invocation — to prevent recursive spawning. When launched from Cowork or
|
|
123
|
+
// Claude Code the discovered list includes 'amicus'/'sidecar' (and possibly
|
|
124
|
+
// a user alias), which would cause an infinite spawn loop.
|
|
125
|
+
if (mcpServers) { stripSelfMcpEntries(mcpServers, logger); }
|
|
127
126
|
|
|
128
127
|
// Apply explicit exclusions
|
|
129
128
|
if (excludeMcp && Array.isArray(excludeMcp) && mcpServers) {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Marker-first abort coordination (Phase 3 abort overhaul).
|
|
5
|
+
*
|
|
6
|
+
* Contract: the caller writes the metadata marker (status='aborted') FIRST,
|
|
7
|
+
* gives the running process a grace window to honor it (the headless loop and
|
|
8
|
+
* the interactive abort watch both poll metadata every ~2s and tear down
|
|
9
|
+
* gracefully — mirror flush, usage persist, server-side abortSession), and
|
|
10
|
+
* only SIGTERMs a process that is STILL alive after the grace window.
|
|
11
|
+
*
|
|
12
|
+
* Windows: process.kill() is TerminateProcess — no handlers run — but libuv
|
|
13
|
+
* job objects kill non-detached children with the parent, so the fallback
|
|
14
|
+
* kill still reaps the tree. The grace window is what keeps the common path
|
|
15
|
+
* graceful.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { logger } = require('./logger');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Grace window before the fallback SIGTERM. Env-overridable (tests).
|
|
22
|
+
* AMICUS_ABORT_GRACE_MS='0' is treated as UNSET and falls back to the 5000ms
|
|
23
|
+
* default — Number.isFinite(0) && 0 > 0 is false, so an explicit "no grace"
|
|
24
|
+
* is not currently expressible via this env var (adjudicated deviation, 3.3).
|
|
25
|
+
*/
|
|
26
|
+
function abortGraceMs() {
|
|
27
|
+
const n = Number(process.env.AMICUS_ABORT_GRACE_MS);
|
|
28
|
+
return (Number.isFinite(n) && n > 0) ? n : 5000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {boolean} true when a process with this pid exists. EPERM means
|
|
33
|
+
* the pid exists but the caller lacks permission to signal it — that's
|
|
34
|
+
* ALIVE, not dead; only ESRCH (and other non-EPERM errors) mean dead.
|
|
35
|
+
*/
|
|
36
|
+
function isAlive(pid, kill = process.kill.bind(process)) {
|
|
37
|
+
if (!pid) { return false; }
|
|
38
|
+
try {
|
|
39
|
+
kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return err && err.code === 'EPERM' ? true : false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** SIGTERM a pid, swallowing ESRCH. @returns {boolean} signal was sent */
|
|
47
|
+
function killPidBestEffort(pid, kill = process.kill.bind(process)) {
|
|
48
|
+
if (!pid) { return false; }
|
|
49
|
+
try { kill(pid, 'SIGTERM'); return true; } catch (err) {
|
|
50
|
+
if (err.code !== 'ESRCH') {
|
|
51
|
+
logger.warn('Failed to kill process', { pid, error: err.message });
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wait up to graceMs for the pids to exit on their own (marker-honoring
|
|
59
|
+
* teardown), then SIGTERM any survivor. Early-exits as soon as every target
|
|
60
|
+
* is gone, so a process that honors the marker in ~2s never sees a signal.
|
|
61
|
+
*
|
|
62
|
+
* NOTE: the poll timer is deliberately REF'D. The CLI awaits this call and
|
|
63
|
+
* must stay alive through the grace window; callers that must not block
|
|
64
|
+
* (MCP handler) fire-and-forget the returned promise instead.
|
|
65
|
+
*
|
|
66
|
+
* @param {number|null|Array<number|null>} pids
|
|
67
|
+
* @param {{graceMs?:number, pollMs?:number, deps?:{kill?:Function, sleep?:Function}}} [opts]
|
|
68
|
+
* @returns {Promise<{killed:number[], exited:number[]}>}
|
|
69
|
+
*/
|
|
70
|
+
async function waitThenKill(pids, opts = {}) {
|
|
71
|
+
const graceMs = opts.graceMs !== undefined ? opts.graceMs : abortGraceMs();
|
|
72
|
+
const pollMs = opts.pollMs || 250;
|
|
73
|
+
const deps = opts.deps || {};
|
|
74
|
+
const kill = deps.kill || process.kill.bind(process);
|
|
75
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
76
|
+
|
|
77
|
+
const targets = [...new Set((Array.isArray(pids) ? pids : [pids]).filter(Boolean))];
|
|
78
|
+
const deadline = Date.now() + graceMs;
|
|
79
|
+
let remaining = targets.filter((pid) => isAlive(pid, kill));
|
|
80
|
+
while (remaining.length > 0 && Date.now() < deadline) {
|
|
81
|
+
await sleep(pollMs);
|
|
82
|
+
remaining = remaining.filter((pid) => isAlive(pid, kill));
|
|
83
|
+
}
|
|
84
|
+
const killed = remaining.filter((pid) => killPidBestEffort(pid, kill));
|
|
85
|
+
return {
|
|
86
|
+
killed,
|
|
87
|
+
exited: targets.filter((pid) => !remaining.includes(pid)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { abortGraceMs, isAlive, killPidBestEffort, waitThenKill };
|
package/src/utils/error-doc.js
CHANGED
|
@@ -48,6 +48,9 @@ function failJson(useJson, { code, message, hint = null, command = null }) {
|
|
|
48
48
|
process.stdout.write(JSON.stringify(buildErrorDoc({ code, message, hint, command }), null, 2) + '\n');
|
|
49
49
|
} else {
|
|
50
50
|
process.stderr.write(message + '\n');
|
|
51
|
+
// Parity with --json (whose envelope carries error.hint): surface the
|
|
52
|
+
// actionable hint to humans too, in doctor's arrow style.
|
|
53
|
+
if (hint) { process.stderr.write(` → ${hint}\n`); }
|
|
51
54
|
}
|
|
52
55
|
return 1;
|
|
53
56
|
}
|