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.
@@ -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
- await pollOnce(); // final flush
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
 
@@ -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
- const fileConfig = loadMcpConfig(mcpConfig);
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({ mcp, mcpConfig, clientType: client, noMcp, excludeMcp });
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 };
@@ -11,7 +11,43 @@ const os = require('os');
11
11
  const path = require('path');
12
12
  const { logger } = require('./logger');
13
13
 
14
- const AUTH_JSON_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
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
- if (!fs.existsSync(AUTH_JSON_PATH)) { return {}; }
76
+ const authPath = resolveAuthJsonPath();
77
+ if (!fs.existsSync(authPath)) { return {}; }
41
78
  let parsed;
42
79
  try {
43
- parsed = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf-8'));
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
- if (!fs.existsSync(AUTH_JSON_PATH)) { return; }
93
- const parsed = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf-8'));
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(AUTH_JSON_PATH, JSON.stringify(parsed, null, 2), 'utf-8');
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
- try {
19
- // Use execFileSync with arguments array (safe from injection)
20
- const result = execFileSync('lsof', ['-ti', `:${port}`], {
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
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
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
  *