amicus 1.7.4 → 1.7.6
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 +76 -0
- package/bin/amicus.js +13 -0
- package/electron/ipc-guard.js +66 -0
- package/electron/main.js +40 -9
- package/electron/preload-content.js +37 -0
- package/electron/session-route.js +8 -1
- package/package.json +1 -1
- package/src/cli-handlers-run.js +4 -0
- package/src/cli.js +22 -0
- package/src/context-compression.js +2 -0
- package/src/context.js +2 -1
- package/src/council/report-html.js +2 -1
- package/src/council/report.js +2 -1
- package/src/council/tally.js +18 -3
- package/src/headless.js +35 -12
- package/src/jsonl-parser.js +31 -2
- package/src/mcp-server.js +85 -11
- package/src/opencode-client.js +63 -7
- package/src/project-root-allowlist.js +75 -0
- package/src/session-manager.js +16 -5
- package/src/session.js +0 -12
- package/src/sidecar/continue.js +5 -0
- package/src/sidecar/conversation-mirror.js +15 -2
- package/src/sidecar/fanout-leg.js +42 -29
- package/src/sidecar/fanout-output.js +3 -5
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/interactive-mirror.js +14 -2
- package/src/sidecar/interactive.js +12 -0
- package/src/sidecar/setup-window.js +8 -0
- package/src/sidecar/start.js +10 -4
- package/src/utils/atomic-write.js +39 -0
- package/src/utils/auth-json.js +47 -7
- package/src/utils/format-duration.js +24 -0
- package/src/utils/server-setup.js +4 -13
- package/src/utils/session-abort.js +3 -1
- package/src/utils/shared-server.js +31 -0
|
@@ -7,6 +7,9 @@ const { writeProgress } = require('./progress');
|
|
|
7
7
|
const { sumPerMessageUsage } = require('../utils/pricing');
|
|
8
8
|
const { logger } = require('../utils/logger');
|
|
9
9
|
|
|
10
|
+
// Cap on the final flush poll during stop() so a wedged server cannot hang teardown.
|
|
11
|
+
const STOP_FLUSH_TIMEOUT_MS = 3000;
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* Poll the OpenCode session and mirror it to conversation.jsonl + progress.json
|
|
12
15
|
* live, exactly like headless. Best-effort and non-blocking — a poll/write error
|
|
@@ -18,9 +21,10 @@ const { logger } = require('../utils/logger');
|
|
|
18
21
|
* @param {number} [opts.intervalMs=2000]
|
|
19
22
|
* @param {() => void} [opts.onActivity]
|
|
20
23
|
* @param {() => string} [opts.now]
|
|
24
|
+
* @param {number} [opts.stopFlushTimeoutMs=3000] - cap on the final flush poll in stop()
|
|
21
25
|
* @returns {{ stop: () => Promise<{usage: object|null}> }}
|
|
22
26
|
*/
|
|
23
|
-
function startInteractiveMirror({ getMessages, sessionDir, intervalMs = 2000, onActivity, now }) {
|
|
27
|
+
function startInteractiveMirror({ getMessages, sessionDir, intervalMs = 2000, onActivity, now, stopFlushTimeoutMs = STOP_FLUSH_TIMEOUT_MS }) {
|
|
24
28
|
const state = createMirrorState();
|
|
25
29
|
const conversationPath = path.join(sessionDir, 'conversation.jsonl');
|
|
26
30
|
let timer = null;
|
|
@@ -54,7 +58,15 @@ function startInteractiveMirror({ getMessages, sessionDir, intervalMs = 2000, on
|
|
|
54
58
|
async stop() {
|
|
55
59
|
stopped = true;
|
|
56
60
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
57
|
-
|
|
61
|
+
// Final flush, but never let a wedged server hang teardown: race the poll
|
|
62
|
+
// against a short timeout so stop() always resolves promptly.
|
|
63
|
+
await Promise.race([
|
|
64
|
+
pollOnce(),
|
|
65
|
+
new Promise(resolve => {
|
|
66
|
+
const t = setTimeout(resolve, stopFlushTimeoutMs);
|
|
67
|
+
if (t.unref) { t.unref(); }
|
|
68
|
+
}),
|
|
69
|
+
]);
|
|
58
70
|
try { writeProgress(sessionDir, 'complete'); } catch { /* best-effort */ }
|
|
59
71
|
let usage = null;
|
|
60
72
|
try { usage = sumPerMessageUsage(state.usageByMsg); } catch { /* best-effort */ }
|
|
@@ -229,11 +229,23 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
229
229
|
mainPath
|
|
230
230
|
], { cwd: project, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
231
231
|
|
|
232
|
+
// Best-effort: if the parent amicus process dies (exit / Ctrl-C / SIGTERM)
|
|
233
|
+
// before Electron exits, SIGTERM the orphaned child so it doesn't linger.
|
|
234
|
+
// Guarded against double-kill via killIfAlive(); removed in teardown below so
|
|
235
|
+
// the normal close path stays the sole owner of shutdown.
|
|
236
|
+
const killChildOnParentDeath = () => killIfAlive(electronProcess);
|
|
237
|
+
process.on('exit', killChildOnParentDeath);
|
|
238
|
+
process.on('SIGINT', killChildOnParentDeath);
|
|
239
|
+
process.on('SIGTERM', killChildOnParentDeath);
|
|
240
|
+
|
|
232
241
|
// Belt-and-suspenders: also touch on raw Electron stdout activity.
|
|
233
242
|
electronProcess.stdout.on('data', () => { watchdog.touch(); });
|
|
234
243
|
|
|
235
244
|
// Clean up server + timers when Electron exits.
|
|
236
245
|
handleElectronProcess(electronProcess, taskId, async (result) => {
|
|
246
|
+
process.removeListener('exit', killChildOnParentDeath);
|
|
247
|
+
process.removeListener('SIGINT', killChildOnParentDeath);
|
|
248
|
+
process.removeListener('SIGTERM', killChildOnParentDeath);
|
|
237
249
|
watchdog.cancel();
|
|
238
250
|
activityPoller.stop();
|
|
239
251
|
try {
|
|
@@ -55,6 +55,14 @@ async function launchSetupWindow() {
|
|
|
55
55
|
logger.debug('Setup window stderr', { data: chunk.trim() });
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
// A spawn failure (ENOENT/EACCES) emits 'error' and NEVER 'close'. Without
|
|
59
|
+
// this listener the Promise would hang forever and Node would treat the
|
|
60
|
+
// unhandled child 'error' as a crash. Resolve with a clear failure instead.
|
|
61
|
+
proc.on('error', (err) => {
|
|
62
|
+
logger.error('Setup window failed to spawn', { error: err.message });
|
|
63
|
+
resolve({ success: false, error: `Failed to start setup window: ${err.message}` });
|
|
64
|
+
});
|
|
65
|
+
|
|
58
66
|
proc.on('close', (code) => {
|
|
59
67
|
logger.info('Setup window closed', { code });
|
|
60
68
|
|
package/src/sidecar/start.js
CHANGED
|
@@ -79,10 +79,12 @@ function createSessionMetadata(taskId, project, options) {
|
|
|
79
79
|
* @param {string} [options.clientType] - Parent client type for discovery
|
|
80
80
|
* @param {boolean} [options.noMcp] - Skip MCP inheritance from parent
|
|
81
81
|
* @param {string[]} [options.excludeMcp] - Server names to exclude
|
|
82
|
+
* @param {string} [options.projectDir] - Target project directory used to resolve
|
|
83
|
+
* a project-scoped opencode.json (NOT process.cwd() under Claude Code/MCP/Cowork)
|
|
82
84
|
* @returns {object|null} MCP server configs or null
|
|
83
85
|
*/
|
|
84
86
|
function buildMcpConfig(options) {
|
|
85
|
-
const { mcp, mcpConfig, clientType, noMcp, excludeMcp } = options;
|
|
87
|
+
const { mcp, mcpConfig, clientType, noMcp, excludeMcp, projectDir } = options;
|
|
86
88
|
let mcpServers = null;
|
|
87
89
|
|
|
88
90
|
// Layer 1: Discover parent MCPs (unless --no-mcp)
|
|
@@ -94,8 +96,10 @@ function buildMcpConfig(options) {
|
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
// Layer 2: File config (opencode.json) overrides discovered
|
|
98
|
-
|
|
99
|
+
// Layer 2: File config (opencode.json) overrides discovered.
|
|
100
|
+
// Resolve the project-scoped opencode.json against the target project dir,
|
|
101
|
+
// not process.cwd() (which differs under Claude Code/MCP/Cowork).
|
|
102
|
+
const fileConfig = loadMcpConfig(mcpConfig, projectDir);
|
|
99
103
|
if (fileConfig) {
|
|
100
104
|
mcpServers = mcpServers ? { ...mcpServers, ...fileConfig } : { ...fileConfig };
|
|
101
105
|
logger.debug('Loaded MCP config from file', { serverCount: Object.keys(fileConfig).length });
|
|
@@ -154,7 +158,9 @@ async function startSidecar(options) {
|
|
|
154
158
|
const effectiveSession = sessionId || session;
|
|
155
159
|
const effectiveProject = cwd || project;
|
|
156
160
|
const effectiveHeadless = noUi !== undefined ? noUi : headless;
|
|
157
|
-
const mcpServers = buildMcpConfig({
|
|
161
|
+
const mcpServers = buildMcpConfig({
|
|
162
|
+
mcp, mcpConfig, clientType: client, noMcp, excludeMcp, projectDir: effectiveProject
|
|
163
|
+
});
|
|
158
164
|
const taskId = options.taskId || generateTaskId();
|
|
159
165
|
const reasoning = thinking ? { effort: thinking } : undefined;
|
|
160
166
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file write helper.
|
|
3
|
+
*
|
|
4
|
+
* Writes data to a unique temp file alongside the target, then renames it into
|
|
5
|
+
* place. Rename is atomic on a single filesystem, so a crash mid-write leaves
|
|
6
|
+
* the original file intact rather than a truncated/corrupt one. This is the
|
|
7
|
+
* pattern already used by council/verdict.js, session-index.js, and
|
|
8
|
+
* model-catalog.js; this module shares it for metadata writes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Atomically write `data` to `filePath` via temp file + rename.
|
|
17
|
+
*
|
|
18
|
+
* The temp name is unique per write (pid + random suffix) so concurrent writers
|
|
19
|
+
* never collide on the same temp file. On any failure the temp file is cleaned
|
|
20
|
+
* up best-effort. Errors propagate to the caller.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} filePath - Destination path.
|
|
23
|
+
* @param {string|Buffer} data - Content to write.
|
|
24
|
+
* @param {{mode?: number}} [opts] - Write options; `mode` sets file permissions.
|
|
25
|
+
*/
|
|
26
|
+
function writeFileAtomic(filePath, data, opts = {}) {
|
|
27
|
+
const dir = path.dirname(filePath);
|
|
28
|
+
const base = path.basename(filePath);
|
|
29
|
+
const tmp = path.join(dir, `.${base}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
30
|
+
try {
|
|
31
|
+
fs.writeFileSync(tmp, data, { mode: opts.mode });
|
|
32
|
+
fs.renameSync(tmp, filePath); // atomic on a single filesystem
|
|
33
|
+
} catch (err) {
|
|
34
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort cleanup */ }
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { writeFileAtomic };
|
package/src/utils/auth-json.js
CHANGED
|
@@ -11,7 +11,43 @@ const os = require('os');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { logger } = require('./logger');
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Ordered, de-duplicated candidate locations for OpenCode's auth.json, most
|
|
16
|
+
* specific first. OpenCode uses XDG-style data dirs; on Windows it still writes
|
|
17
|
+
* to ~/.local/share/opencode (verified), so that path stays FIRST after XDG.
|
|
18
|
+
* Mirrors the cross-platform precedence pattern in src/sidecar/electron-cache.js.
|
|
19
|
+
* @param {NodeJS.ProcessEnv} [env] - Environment (injectable for tests)
|
|
20
|
+
* @returns {string[]}
|
|
21
|
+
*/
|
|
22
|
+
function authJsonCandidates(env = process.env) {
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
const candidates = [];
|
|
25
|
+
if (env.XDG_DATA_HOME) { candidates.push(path.join(env.XDG_DATA_HOME, 'opencode', 'auth.json')); }
|
|
26
|
+
candidates.push(path.join(home, '.local', 'share', 'opencode', 'auth.json'));
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
29
|
+
candidates.push(path.join(appData, 'opencode', 'auth.json'));
|
|
30
|
+
}
|
|
31
|
+
return [...new Set(candidates)];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the auth.json path to use: first existing candidate, else the primary
|
|
36
|
+
* (~/.local/share) path so callers/writers have a stable default.
|
|
37
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function resolveAuthJsonPath(env = process.env) {
|
|
41
|
+
const candidates = authJsonCandidates(env);
|
|
42
|
+
for (const c of candidates) {
|
|
43
|
+
if (fs.existsSync(c)) { return c; }
|
|
44
|
+
}
|
|
45
|
+
const localShare = path.join('.local', 'share');
|
|
46
|
+
return candidates.find((c) => c.includes(localShare)) || candidates[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Backward-compat export: the resolved path at module load time.
|
|
50
|
+
const AUTH_JSON_PATH = resolveAuthJsonPath();
|
|
15
51
|
|
|
16
52
|
/** Known provider IDs that map to sidecar's PROVIDER_ENV_MAP */
|
|
17
53
|
const KNOWN_PROVIDERS = ['openrouter', 'google', 'openai', 'anthropic', 'deepseek'];
|
|
@@ -37,10 +73,11 @@ function extractKey(entry) {
|
|
|
37
73
|
* @returns {Object<string, string>} Map of provider -> key string (only providers with keys)
|
|
38
74
|
*/
|
|
39
75
|
function readAuthJsonKeys() {
|
|
40
|
-
|
|
76
|
+
const authPath = resolveAuthJsonPath();
|
|
77
|
+
if (!fs.existsSync(authPath)) { return {}; }
|
|
41
78
|
let parsed;
|
|
42
79
|
try {
|
|
43
|
-
parsed = JSON.parse(fs.readFileSync(
|
|
80
|
+
parsed = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
44
81
|
} catch (_err) {
|
|
45
82
|
logger.debug('auth.json is malformed, skipping import');
|
|
46
83
|
return {};
|
|
@@ -89,11 +126,12 @@ function checkAuthJson(provider) {
|
|
|
89
126
|
*/
|
|
90
127
|
function removeFromAuthJson(provider) {
|
|
91
128
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
129
|
+
const authPath = resolveAuthJsonPath();
|
|
130
|
+
if (!fs.existsSync(authPath)) { return; }
|
|
131
|
+
const parsed = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
94
132
|
if (!parsed[provider]) { return; }
|
|
95
133
|
delete parsed[provider];
|
|
96
|
-
fs.writeFileSync(
|
|
134
|
+
fs.writeFileSync(authPath, JSON.stringify(parsed, null, 2), 'utf-8');
|
|
97
135
|
} catch (_err) {
|
|
98
136
|
logger.debug('Failed to remove provider from auth.json', { provider });
|
|
99
137
|
}
|
|
@@ -105,5 +143,7 @@ module.exports = {
|
|
|
105
143
|
checkAuthJson,
|
|
106
144
|
removeFromAuthJson,
|
|
107
145
|
AUTH_JSON_PATH,
|
|
108
|
-
KNOWN_PROVIDERS
|
|
146
|
+
KNOWN_PROVIDERS,
|
|
147
|
+
resolveAuthJsonPath,
|
|
148
|
+
authJsonCandidates
|
|
109
149
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// src/utils/format-duration.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module utils/format-duration
|
|
6
|
+
* Single ms->human duration formatter shared by the fanout and council
|
|
7
|
+
* renderers. Rolls minutes up ("1m5s" / "42s") and lets each caller pick its
|
|
8
|
+
* own null placeholder ("-" for fanout stdout, "—" for the council report).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format a millisecond duration as "1m5s" / "42s".
|
|
13
|
+
* @param {number|null|undefined} ms - Duration in milliseconds.
|
|
14
|
+
* @param {string} [empty='-'] - Placeholder for null/undefined input.
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function formatDuration(ms, empty = '-') {
|
|
18
|
+
if (ms === null || ms === undefined) { return empty; }
|
|
19
|
+
const s = Math.round(ms / 1000);
|
|
20
|
+
const m = Math.floor(s / 60);
|
|
21
|
+
return m > 0 ? `${m}m${s % 60}s` : `${s}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { formatDuration };
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Handles port management and cleanup for the OpenCode server.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const { execFileSync } = require('child_process');
|
|
8
7
|
const { logger } = require('./logger');
|
|
8
|
+
const { findListenerPid } = require('./port-pid');
|
|
9
9
|
|
|
10
10
|
const DEFAULT_PORT = 4096;
|
|
11
11
|
|
|
@@ -15,18 +15,9 @@ const DEFAULT_PORT = 4096;
|
|
|
15
15
|
* @returns {number|null} PID or null if not in use
|
|
16
16
|
*/
|
|
17
17
|
function getPortPid(port) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
encoding: 'utf8',
|
|
22
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
23
|
-
});
|
|
24
|
-
const pid = parseInt(result.trim(), 10);
|
|
25
|
-
return isNaN(pid) ? null : pid;
|
|
26
|
-
} catch {
|
|
27
|
-
// lsof returns non-zero if no process found
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
18
|
+
// Delegate to the cross-platform lookup (netstat on Windows, lsof elsewhere)
|
|
19
|
+
// so the port-in-use check and kill path work off Unix too.
|
|
20
|
+
return findListenerPid(port);
|
|
30
21
|
}
|
|
31
22
|
|
|
32
23
|
/**
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
|
+
const { writeFileAtomic } = require('./atomic-write');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Synchronously write a terminal status to a session's metadata. Best-effort: never throws.
|
|
@@ -28,7 +29,8 @@ function markTerminal(sessionDir, status, reason) {
|
|
|
28
29
|
meta.status = status;
|
|
29
30
|
meta.reason = reason;
|
|
30
31
|
meta[status === 'aborted' ? 'abortedAt' : 'completedAt'] = new Date().toISOString();
|
|
31
|
-
|
|
32
|
+
// Atomic (temp + rename): a crash mid-write must not corrupt the marker.
|
|
33
|
+
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
32
34
|
return true;
|
|
33
35
|
} catch {
|
|
34
36
|
return false;
|
|
@@ -73,6 +73,7 @@ class SharedServerManager {
|
|
|
73
73
|
this.server = server;
|
|
74
74
|
this.client = client;
|
|
75
75
|
this._starting = null;
|
|
76
|
+
this._wireCrashListener(server);
|
|
76
77
|
this._serverWatchdog = new IdleWatchdog({
|
|
77
78
|
mode: 'server',
|
|
78
79
|
onTimeout: () => {
|
|
@@ -169,6 +170,36 @@ class SharedServerManager {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Wire crash detection onto a freshly started server handle so an unexpected
|
|
175
|
+
* exit triggers _onServerCrash (and the restart machinery). The OpenCode
|
|
176
|
+
* server handle is a plain wrapper; it may surface lifecycle events either on
|
|
177
|
+
* itself or on an underlying child `process`. Attach to whichever is an event
|
|
178
|
+
* emitter, guarding against double-wiring across restarts.
|
|
179
|
+
*
|
|
180
|
+
* @param {object} server - Server handle returned by _doStartServer
|
|
181
|
+
*/
|
|
182
|
+
_wireCrashListener(server) {
|
|
183
|
+
const emitter = (server && typeof server.on === 'function')
|
|
184
|
+
? server
|
|
185
|
+
: (server && server.process && typeof server.process.on === 'function')
|
|
186
|
+
? server.process
|
|
187
|
+
: null;
|
|
188
|
+
if (!emitter || emitter._amicusCrashWired) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
emitter._amicusCrashWired = true;
|
|
192
|
+
const onExit = (code) => {
|
|
193
|
+
// Ignore exits from a stale handle we have already replaced/closed.
|
|
194
|
+
if (this.server !== server) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this._onServerCrash(code);
|
|
198
|
+
};
|
|
199
|
+
emitter.on('exit', onExit);
|
|
200
|
+
emitter.on('close', onExit);
|
|
201
|
+
}
|
|
202
|
+
|
|
172
203
|
/**
|
|
173
204
|
* Handle a server crash: log, notify sessions, then schedule restart.
|
|
174
205
|
*
|