amicus 1.9.1 → 2.1.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.
Files changed (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +200 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +19 -107
  5. package/commands/council.md +7 -3
  6. package/electron/fold.js +10 -1
  7. package/electron/ipc-setup.js +10 -15
  8. package/electron/main.js +21 -16
  9. package/electron/preload-setup.js +0 -1
  10. package/electron/setup-ui-council.js +64 -10
  11. package/electron/setup-ui-styles.js +34 -3
  12. package/electron/setup-ui.js +44 -12
  13. package/package.json +2 -5
  14. package/skills/second-opinion/MODEL-NOTES.md +2 -2
  15. package/skills/second-opinion/SKILL.md +30 -28
  16. package/skills/sidecar/SKILL.md +20 -17
  17. package/src/cli-handlers-abort.js +244 -0
  18. package/src/cli-handlers-council.js +101 -1
  19. package/src/cli-handlers-doctor.js +20 -53
  20. package/src/cli-handlers-resume-continue.js +103 -0
  21. package/src/cli-handlers-run.js +9 -8
  22. package/src/cli-handlers-spend.js +198 -0
  23. package/src/cli-handlers.js +5 -120
  24. package/src/cli.js +55 -0
  25. package/src/council/presets-cli.js +141 -0
  26. package/src/headless.js +146 -38
  27. package/src/index.js +1 -9
  28. package/src/mcp-server.js +140 -113
  29. package/src/mcp-tools.js +58 -24
  30. package/src/mcp-wait.js +8 -5
  31. package/src/opencode-client.js +33 -10
  32. package/src/prompt-builder.js +32 -11
  33. package/src/session-manager.js +7 -14
  34. package/src/sidecar/continue.js +34 -12
  35. package/src/sidecar/conversation-mirror.js +22 -1
  36. package/src/sidecar/crash-handler.js +2 -1
  37. package/src/sidecar/fanout-leg.js +12 -3
  38. package/src/sidecar/fanout.js +27 -10
  39. package/src/sidecar/interactive-process.js +6 -17
  40. package/src/sidecar/interactive.js +5 -6
  41. package/src/sidecar/models.js +33 -4
  42. package/src/sidecar/progress.js +2 -1
  43. package/src/sidecar/read.js +4 -6
  44. package/src/sidecar/resume.js +41 -11
  45. package/src/sidecar/session-finalize.js +2 -1
  46. package/src/sidecar/session-utils.js +13 -35
  47. package/src/sidecar/setup-window.js +2 -3
  48. package/src/sidecar/start.js +22 -7
  49. package/src/utils/abort-coordinator.js +57 -7
  50. package/src/utils/abort-result.js +36 -0
  51. package/src/utils/api-key-store.js +2 -13
  52. package/src/utils/cli-preflight.js +43 -0
  53. package/src/utils/config.js +30 -43
  54. package/src/utils/council-presets.js +87 -0
  55. package/src/utils/doctor-mcp-checks.js +84 -0
  56. package/src/utils/env-loader.js +1 -2
  57. package/src/utils/fold-marker.js +79 -0
  58. package/src/utils/idle-watchdog.js +9 -12
  59. package/src/utils/input-validators.js +52 -1
  60. package/src/utils/lifecycle.js +1 -1
  61. package/src/utils/mcp-discovery.js +80 -19
  62. package/src/utils/mcp-self-identity.js +12 -5
  63. package/src/utils/model-catalog.js +54 -6
  64. package/src/utils/read-slice.js +73 -0
  65. package/src/utils/remediation-hints.js +9 -0
  66. package/src/utils/result-schema-version.js +14 -0
  67. package/src/utils/result-schema.js +18 -12
  68. package/src/utils/session-abort.js +1 -1
  69. package/src/utils/session-index-tmp-sweep.js +80 -0
  70. package/src/utils/session-index.js +4 -5
  71. package/src/utils/session-path.js +6 -10
  72. package/src/utils/shared-server.js +7 -5
  73. package/src/utils/spend-ledger.js +80 -0
  74. package/src/utils/updater.js +2 -3
  75. package/src/utils/env-compat.js +0 -38
@@ -12,7 +12,7 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
  const os = require('os');
14
14
  const { logger } = require('./logger');
15
- const { stripSelfMcpEntries } = require('./mcp-self-identity');
15
+ const { stripSelfMcpEntries, isAmicusMcpConfig } = require('./mcp-self-identity');
16
16
 
17
17
  /**
18
18
  * Normalize .mcp.json to a flat { name: config } map.
@@ -34,17 +34,16 @@ function normalizeMcpJson(raw) {
34
34
  }
35
35
 
36
36
  /**
37
- * Discover MCP servers from Claude Code's plugin chain AND ~/.claude.json.
38
- *
39
- * Discovery sources (merged, in priority order):
40
- * 1. ~/.claude.json mcpServers (servers added via `claude mcp add`)
41
- * 2. Enabled plugins → .mcp.json entries
37
+ * Read Claude Code's merged mcpServers map (~/.claude.json + plugin-chain
38
+ * .mcp.json files) WITHOUT the self-entry strip. Shared raw-read core for
39
+ * both discoverClaudeCodeMcps (strips) and hasAmicusRegistration (does not —
40
+ * it needs to SEE the amicus entry the strip would otherwise hide).
42
41
  *
43
42
  * @param {string} [claudeDir] - Path to ~/.claude directory (for testing)
44
43
  * @param {string} [claudeJsonPath] - Path to ~/.claude.json (for testing)
45
- * @returns {object|null} Merged MCP server configs, or null if none found
44
+ * @returns {object} Merged MCP server configs (never stripped); {} if none found
46
45
  */
47
- function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
46
+ function readRawClaudeCodeMcpServers(claudeDir, claudeJsonPath) {
48
47
  const baseDir = claudeDir || path.join(os.homedir(), '.claude');
49
48
  const jsonPath = claudeJsonPath || path.join(os.homedir(), '.claude.json');
50
49
 
@@ -71,14 +70,12 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
71
70
  const settingsPath = path.join(baseDir, 'settings.json');
72
71
  if (!fs.existsSync(settingsPath)) {
73
72
  // No settings.json — skip plugin discovery, may still have claude.json servers
74
- const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
75
- return Object.keys(merged).length > 0 ? merged : null;
73
+ return { ...claudeJsonServers };
76
74
  }
77
75
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
78
76
  const enabledPlugins = settings.enabledPlugins;
79
77
  if (!enabledPlugins || typeof enabledPlugins !== 'object') {
80
- const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
81
- return Object.keys(merged).length > 0 ? merged : null;
78
+ return { ...claudeJsonServers };
82
79
  }
83
80
 
84
81
  let installedPlugins = {};
@@ -133,12 +130,79 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
133
130
  }
134
131
 
135
132
  // Merge: plugin servers first, then claude.json overwrites (higher priority).
136
- // Recursive-spawn guard: drop every entry that resolves to amicus itself.
137
- const merged = stripSelfMcpEntries({ ...pluginServers, ...claudeJsonServers }, logger);
133
+ return { ...pluginServers, ...claudeJsonServers };
134
+ }
138
135
 
136
+ /**
137
+ * Discover MCP servers from Claude Code's plugin chain AND ~/.claude.json.
138
+ *
139
+ * Discovery sources (merged, in priority order):
140
+ * 1. ~/.claude.json → mcpServers (servers added via `claude mcp add`)
141
+ * 2. Enabled plugins → .mcp.json entries
142
+ *
143
+ * @param {string} [claudeDir] - Path to ~/.claude directory (for testing)
144
+ * @param {string} [claudeJsonPath] - Path to ~/.claude.json (for testing)
145
+ * @returns {object|null} Merged MCP server configs, or null if none found
146
+ */
147
+ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
148
+ // Recursive-spawn guard: drop every entry that resolves to amicus itself.
149
+ const merged = stripSelfMcpEntries(readRawClaudeCodeMcpServers(claudeDir, claudeJsonPath), logger);
139
150
  return Object.keys(merged).length > 0 ? merged : null;
140
151
  }
141
152
 
153
+ /**
154
+ * True when Claude Code already has a working amicus MCP registration —
155
+ * checked against the SAME raw sources discoverClaudeCodeMcps reads, but
156
+ * WITHOUT stripSelfMcpEntries. discoverClaudeCodeMcps strips every
157
+ * 'amicus'/'sidecar'-shaped entry as a recursive-spawn guard (src/utils/
158
+ * mcp-self-identity.js), so code.amicus is ALWAYS undefined downstream —
159
+ * that check is the wrong consumer to answer "is amicus registered?" (B14).
160
+ *
161
+ * True when any entry's key is literally 'amicus' (regardless of its value
162
+ * shape — an unrecognizable value under that key is still an amicus
163
+ * registration slot) OR its value passes isAmicusMcpConfig() (covers
164
+ * aliased keys, e.g. legacy 'sidecar' or a custom name, whose command/args
165
+ * resolve to an amicus MCP invocation).
166
+ *
167
+ * @param {string} [claudeDir] - Path to ~/.claude directory (for testing)
168
+ * @param {string} [claudeJsonPath] - Path to ~/.claude.json (for testing)
169
+ * @returns {boolean}
170
+ */
171
+ function hasAmicusRegistration(claudeDir, claudeJsonPath) {
172
+ const servers = readRawClaudeCodeMcpServers(claudeDir, claudeJsonPath) || {};
173
+ return Object.entries(servers).some(([name, config]) => (
174
+ name === 'amicus' || isAmicusMcpConfig(config)
175
+ ));
176
+ }
177
+
178
+ /**
179
+ * Resolve Claude Desktop's per-platform config directory.
180
+ * Mirrors the 3-way branch in src/environment.js getCoworkRoot (same
181
+ * APPDATA || homedir-fallback form on win32) but stops one level higher —
182
+ * getCoworkRoot resolves .../Claude/local-agent-mode-sessions, while this
183
+ * needs the parent .../Claude dir that holds claude_desktop_config.json.
184
+ * Kept local rather than imported to avoid depending on an internal
185
+ * implementation detail (stripping getCoworkRoot's trailing segment).
186
+ *
187
+ * @param {string} platform - OS platform (process.platform)
188
+ * @returns {string} Claude Desktop config directory
189
+ */
190
+ function getClaudeDesktopConfigDir(platform) {
191
+ const homedir = os.homedir();
192
+
193
+ if (platform === 'darwin') {
194
+ return path.join(homedir, 'Library', 'Application Support', 'Claude');
195
+ }
196
+
197
+ if (platform === 'win32') {
198
+ const appdata = process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming');
199
+ return path.join(appdata, 'Claude');
200
+ }
201
+
202
+ // Linux and other Unix-like systems
203
+ return path.join(homedir, '.config', 'Claude');
204
+ }
205
+
142
206
  /**
143
207
  * Discover MCP servers from Cowork / Claude Desktop config.
144
208
  *
@@ -146,11 +210,7 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
146
210
  * @returns {object|null} MCP server configs, or null if none found
147
211
  */
148
212
  function discoverCoworkMcps(configDir) {
149
- const baseDir = configDir || (
150
- process.platform === 'darwin'
151
- ? path.join(os.homedir(), 'Library', 'Application Support', 'Claude')
152
- : path.join(os.homedir(), '.config', 'Claude')
153
- );
213
+ const baseDir = configDir || getClaudeDesktopConfigDir(process.platform);
154
214
 
155
215
  try {
156
216
  const configPath = path.join(baseDir, 'claude_desktop_config.json');
@@ -187,5 +247,6 @@ module.exports = {
187
247
  discoverParentMcps,
188
248
  discoverClaudeCodeMcps,
189
249
  discoverCoworkMcps,
250
+ hasAmicusRegistration,
190
251
  normalizeMcpJson
191
252
  };
@@ -5,15 +5,22 @@
5
5
  * Recursive-spawn guard. A child sidecar that inherits an MCP entry launching
6
6
  * amicus itself would spawn amicus inside amicus, forever. The shipped server
7
7
  * registers as 'amicus' (scripts/postinstall.js, .claude-plugin/plugin.json)
8
- * plus a deprecated 'sidecar' shim — and users can alias it under ANY name —
9
- * so we exclude both reserved names AND any entry whose command+args resolve
10
- * to an amicus MCP invocation.
8
+ * — and users can alias it under ANY name — so we exclude both reserved
9
+ * names AND any entry whose command+args resolve to an amicus MCP invocation.
10
+ *
11
+ * 'sidecar'/'claude-sidecar' are recognized for BOTH lists even though
12
+ * package.json's "bin" field no longer ships them as of v2.0.0 (#19): a
13
+ * stale pre-rebrand global install can still have them linked on a user's
14
+ * PATH, and a stale claude.json/MCP config can still reference the old
15
+ * 'sidecar' server name. Recognizing them here only ever prevents a
16
+ * recursive self-spawn — it never breaks a legitimately different server —
17
+ * so there is no cost to keeping the wider net.
11
18
  */
12
19
 
13
- /** Server names amicus registers itself under. */
20
+ /** Server names amicus registers itself under (current + legacy). */
14
21
  const SELF_MCP_NAMES = Object.freeze(['amicus', 'sidecar']);
15
22
 
16
- /** Shipped bin aliases (package.json "bin") ./bin/amicus.js */
23
+ /** Bin names that resolve to ./bin/amicus.js (current package.json "bin" + legacy pre-v2.0.0 names). */
17
24
  const SELF_BIN_NAMES = new Set(['amicus', 'am', 'sidecar', 'claude-sidecar']);
18
25
 
19
26
  /**
@@ -39,19 +39,52 @@ function readCache() {
39
39
  return null;
40
40
  }
41
41
 
42
- /** Write the cache atomically (tmp+rename). Best-effort; never throws. @param {Array} models */
43
- function writeCache(models) {
42
+ /**
43
+ * Raw cache-doc read for refresh-outcome fields only (#13). Unlike readCache(),
44
+ * this does NOT require a `models` array — a fresh machine whose first refresh
45
+ * attempt failed writes a doc with only {lastRefreshAttempt, lastRefreshError}
46
+ * and no models/fetchedAt, and that outcome still needs to be readable.
47
+ * @returns {{lastRefreshAttempt?: number, lastRefreshError?: string}|null}
48
+ */
49
+ function readCacheDocLoose() {
50
+ try {
51
+ const raw = fs.readFileSync(catalogPath(), 'utf-8');
52
+ const parsed = JSON.parse(raw);
53
+ if (parsed && typeof parsed === 'object') { return parsed; }
54
+ } catch { /* missing/corrupt */ }
55
+ return null;
56
+ }
57
+
58
+ /** Write the cache atomically (tmp+rename). Best-effort; never throws. @param {object} doc full cache document */
59
+ function writeCacheDoc(doc) {
44
60
  const target = catalogPath();
45
61
  const tmp = `${target}.${process.pid}.tmp`;
46
62
  try {
47
63
  fs.mkdirSync(_getConfigDir(), { recursive: true, mode: 0o700 });
48
- fs.writeFileSync(tmp, JSON.stringify({ schemaVersion: CATALOG_SCHEMA_VERSION, fetchedAt: Date.now(), models }, null, 2), { mode: 0o600 });
64
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 });
49
65
  fs.renameSync(tmp, target);
50
66
  } catch {
51
67
  try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
52
68
  }
53
69
  }
54
70
 
71
+ /** Write a successful fetch: fresh models/fetchedAt, outcome fields cleared. @param {Array} models */
72
+ function writeCache(models) {
73
+ writeCacheDoc({ schemaVersion: CATALOG_SCHEMA_VERSION, fetchedAt: Date.now(), models });
74
+ }
75
+
76
+ /**
77
+ * Record a failed refresh attempt (#13): stamps lastRefreshAttempt/lastRefreshError
78
+ * onto the existing cache document WITHOUT touching models/fetchedAt — the good
79
+ * data (if any) stays byte-authoritative. With no prior cache, writes a doc that
80
+ * carries only the outcome fields (no models/fetchedAt to report).
81
+ * @param {string} reason short error-class string
82
+ */
83
+ function writeRefreshFailure(reason) {
84
+ const existing = readCache() || { schemaVersion: CATALOG_SCHEMA_VERSION };
85
+ writeCacheDoc({ ...existing, lastRefreshAttempt: Date.now(), lastRefreshError: reason });
86
+ }
87
+
55
88
  /**
56
89
  * Force a refresh from the provider APIs and update the cache.
57
90
  * @returns {Promise<Array<{id,name}>>} the fetched models (may be [] offline)
@@ -64,7 +97,13 @@ async function refreshCatalog() {
64
97
  // refresh — never clobber a previously-good cache with the floor (the
65
98
  // "stale cache stands" contract).
66
99
  const networkRows = (models || []).filter(m => m && typeof m.id === 'string' && !m.id.startsWith('anthropic/'));
67
- if (networkRows.length === 0) { return []; }
100
+ if (networkRows.length === 0) {
101
+ const reason = (models || []).length > 0
102
+ ? 'floor-only: all providers returned no network rows'
103
+ : 'network-error: all providers unreachable';
104
+ writeRefreshFailure(reason);
105
+ return [];
106
+ }
68
107
  writeCache(models);
69
108
  return models;
70
109
  }
@@ -92,12 +131,21 @@ async function getCatalog(opts = {}) {
92
131
 
93
132
  /**
94
133
  * Catalog rows plus cache timestamp (for UI display).
95
- * @returns {Promise<{models: Array, fetchedAt: number|null}>}
134
+ * #13: also threads the last-refresh outcome so callers can tell "current"
135
+ * apart from "stale because refreshing keeps failing" — null/null when the
136
+ * last attempt on record succeeded (or none has happened yet).
137
+ * @returns {Promise<{models: Array, fetchedAt: number|null, lastRefreshAttempt: number|null, lastRefreshError: string|null}>}
96
138
  */
97
139
  async function getCatalogInfo(opts = {}) {
98
140
  const models = await getCatalog(opts);
99
141
  const cache = readCache();
100
- return { models, fetchedAt: cache ? cache.fetchedAt : null };
142
+ const doc = readCacheDocLoose(); // outcome fields survive even a models-less doc
143
+ return {
144
+ models,
145
+ fetchedAt: cache ? cache.fetchedAt : null,
146
+ lastRefreshAttempt: (doc && doc.lastRefreshAttempt) || null,
147
+ lastRefreshError: (doc && doc.lastRefreshError) || null,
148
+ };
101
149
  }
102
150
 
103
151
  module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, readCache, CATALOG_SCHEMA_VERSION };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Byte-bounded slicing for amicus_read (15a.3 / B17).
3
+ *
4
+ * amicus_read's conversation/summary/wave-summary/metadata bodies were
5
+ * previously returned whole and unbounded — a large conversation.jsonl could
6
+ * flood the calling agent's context. This module applies a default ~50KB cap
7
+ * to the BODY of every response and exposes offset/limit/tail paging so an
8
+ * agent can page through the rest.
9
+ *
10
+ * Slicing is byte-based (matches the "~50KB" contract agents reason about)
11
+ * but implemented with JS string slicing over UTF-16 code units, NOT a
12
+ * Buffer byte slice. A slice boundary landing mid multibyte-character is
13
+ * tolerated (the string still round-trips through JSON safely; a stray
14
+ * replacement character at a cut edge is an acceptable trade-off for keeping
15
+ * this a plain string operation with no encode/decode step). Because the cut
16
+ * is code-unit based, the returned slice's real UTF-8 byte length can differ
17
+ * from READ_CAP_BYTES for multibyte content — so the truncation notice
18
+ * always reports the ACTUAL Buffer.byteLength of the returned slice (never
19
+ * the nominal cap), alongside the true total byte count. Both figures in the
20
+ * notice are real, measured byte counts.
21
+ */
22
+ 'use strict';
23
+
24
+ /** Default cap on the BODY of every amicus_read response mode, in bytes. */
25
+ const READ_CAP_BYTES = 51200; // 50 * 1024
26
+
27
+ /**
28
+ * Slice `text` per the offset/limit/tail paging params, applying the default
29
+ * cap when no explicit params are given and the content exceeds it.
30
+ *
31
+ * Precedence when both `offset` and `tail` are given: `offset` wins (`tail`
32
+ * is ignored) — an explicit offset is a more specific request than "give me
33
+ * the end".
34
+ *
35
+ * @param {string} text - raw content (pre-fence).
36
+ * @param {{offset?: number, limit?: number, tail?: boolean}} params
37
+ * @returns {{body: string, truncated: boolean}} body is ready to fence/return.
38
+ */
39
+ function sliceForRead(text, params = {}) {
40
+ const totalBytes = Buffer.byteLength(text, 'utf-8');
41
+ const limit = clampLimit(params.limit);
42
+ const hasOffset = typeof params.offset === 'number' && params.offset >= 0;
43
+
44
+ if (hasOffset) {
45
+ const slice = text.slice(params.offset, params.offset + limit);
46
+ return { body: slice, truncated: false };
47
+ }
48
+
49
+ if (params.tail) {
50
+ const sliceLen = Math.min(limit, text.length);
51
+ const slice = text.slice(text.length - sliceLen);
52
+ return { body: slice, truncated: false };
53
+ }
54
+
55
+ // No explicit slicing params: apply the default cap. Under-cap content is
56
+ // untouched (byte-identical to pre-15a.3 behavior).
57
+ if (totalBytes <= READ_CAP_BYTES) {
58
+ return { body: text, truncated: false };
59
+ }
60
+ const sliceLen = Math.min(READ_CAP_BYTES, text.length);
61
+ const tailSlice = text.slice(text.length - sliceLen);
62
+ const actualSliceBytes = Buffer.byteLength(tailSlice, 'utf-8');
63
+ const notice = `[truncated: showing last ${actualSliceBytes} of ${totalBytes} bytes — use offset/limit to page]`;
64
+ return { body: `${notice}\n${tailSlice}`, truncated: true };
65
+ }
66
+
67
+ /** Clamp an optional caller-supplied limit into [1, READ_CAP_BYTES]. */
68
+ function clampLimit(limit) {
69
+ if (typeof limit !== 'number' || !Number.isFinite(limit)) { return READ_CAP_BYTES; }
70
+ return Math.max(1, Math.min(READ_CAP_BYTES, Math.floor(limit)));
71
+ }
72
+
73
+ module.exports = { sliceForRead, READ_CAP_BYTES };
@@ -72,6 +72,15 @@ const REMEDIATION_HINTS = Object.freeze({
72
72
  */
73
73
  removeLegacySidecar:
74
74
  "amicus doctor --fix (removes the duplicate legacy 'sidecar' MCP entry — same server registered twice; the 'amicus' entry stays)",
75
+
76
+ /**
77
+ * Orphaned sessions-index.json.*.tmp files (15a.1/B15): a kill between the
78
+ * atomic tmp-write and rename leaves a stray temp file in the config dir
79
+ * forever. `doctor --fix` sweeps files older than 60s (never a live writer's
80
+ * ms-lived tmp).
81
+ */
82
+ sweepSessionIndexTmp:
83
+ 'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
75
84
  });
76
85
 
77
86
  module.exports = REMEDIATION_HINTS;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @module result-schema-version
3
+ * The single SCHEMA_VERSION constant shared by result-schema.js and
4
+ * abort-result.js (split out to avoid a circular require between them).
5
+ *
6
+ * Stability contract: fields on any doc built from this version are only
7
+ * ADDED within a SCHEMA_VERSION; any rename/removal bumps SCHEMA_VERSION.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const SCHEMA_VERSION = 2;
13
+
14
+ module.exports = { SCHEMA_VERSION };
@@ -1,15 +1,13 @@
1
- // src/utils/result-schema.js
2
1
  'use strict';
3
2
 
4
3
  /**
5
4
  * @module result-schema
6
5
  * Versioned, machine-parseable result documents for `--json` output (F4).
7
- *
8
- * Stability contract: fields are only ADDED within a SCHEMA_VERSION;
9
- * any rename/removal bumps SCHEMA_VERSION.
6
+ * Stability contract: fields are only ADDED within a SCHEMA_VERSION; any
7
+ * rename/removal bumps SCHEMA_VERSION (defined in ./result-schema-version.js,
8
+ * split out so ./abort-result.js can depend on it without a circular require).
10
9
  */
11
-
12
- const SCHEMA_VERSION = 2;
10
+ const { SCHEMA_VERSION } = require('./result-schema-version');
13
11
 
14
12
  /** Leg/run statuses that count as terminal for wave aggregation. */
15
13
  const TERMINAL_STATUSES = ['complete', 'error', 'timeout', 'aborted', 'crashed', 'idle-timeout'];
@@ -159,8 +157,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
159
157
  * @param {string} project - Project dir
160
158
  * @param {string} taskId
161
159
  * @returns {object} run document
162
- * @throws {Error} if the session does not exist
163
- * @throws {Error} if metadata.json is missing or corrupt
160
+ * @throws {Error} if the session does not exist or metadata.json is missing/corrupt
164
161
  */
165
162
  function buildRunResultFromSession(project, taskId) {
166
163
  const fs = require('fs');
@@ -189,8 +186,7 @@ function buildRunResultFromSession(project, taskId) {
189
186
  * @param {string} project
190
187
  * @param {string} waveId
191
188
  * @returns {object} wave document
192
- * @throws {Error} if the wave session does not exist
193
- * @throws {Error} if metadata.json is missing or corrupt
189
+ * @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
194
190
  */
195
191
  function buildWaveResultFromSession(project, waveId) {
196
192
  const fs = require('fs');
@@ -234,9 +230,13 @@ function buildWaveResultFromSession(project, waveId) {
234
230
 
235
231
  /**
236
232
  * Build a model-catalog document (`models [--search] [--refresh] --json`).
237
- * @param {{models: Array, fetchedAt: number|null, refreshed?: boolean, search?: string|null}} opts
233
+ * #13: lastRefreshAttempt/lastRefreshError are additive null/null when the
234
+ * last refresh attempt on record succeeded (or none has happened yet).
235
+ * @param {{models: Array, fetchedAt: number|null, refreshed?: boolean, search?: string|null,
236
+ * lastRefreshAttempt?: number|null, lastRefreshError?: string|null}} opts
238
237
  */
239
- function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null }) {
238
+ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
239
+ lastRefreshAttempt = null, lastRefreshError = null }) {
240
240
  return {
241
241
  schemaVersion: SCHEMA_VERSION,
242
242
  type: 'model-catalog',
@@ -245,6 +245,8 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null }
245
245
  search,
246
246
  count: models.length,
247
247
  models,
248
+ lastRefreshAttempt: lastRefreshAttempt || null,
249
+ lastRefreshError: lastRefreshError || null,
248
250
  };
249
251
  }
250
252
 
@@ -277,6 +279,9 @@ function buildDoctorDoc({ version, timestamp, checks }) {
277
279
  };
278
280
  }
279
281
 
282
+ // buildAbortResult lives in ./abort-result.js (size-gate split); re-exported below.
283
+ const { buildAbortResult } = require('./abort-result');
284
+
280
285
  module.exports = {
281
286
  SCHEMA_VERSION,
282
287
  TERMINAL_STATUSES,
@@ -291,4 +296,5 @@ module.exports = {
291
296
  buildCatalogDoc,
292
297
  buildAuditDoc,
293
298
  buildDoctorDoc,
299
+ buildAbortResult,
294
300
  };
@@ -75,7 +75,7 @@ function idleBackstopTeardown(sessionDir, server, externalServer) {
75
75
  fs.writeFileSync(path.join(sessionDir, 'summary.md'),
76
76
  'Session timed out — idle backstop fired before completion.\n', { mode: 0o600 });
77
77
  } catch { /* best-effort */ }
78
- if (!externalServer && server) { try { server.close(); } catch { /* best-effort */ } }
78
+ if (!externalServer && server) { try { server.close().catch(() => {}); } catch { /* best-effort */ } }
79
79
  return 2;
80
80
  }
81
81
 
@@ -0,0 +1,80 @@
1
+ // src/utils/session-index-tmp-sweep.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * 15a.1/B15: orphaned sessions-index.json.*.tmp sweep for `amicus doctor --fix`.
6
+ *
7
+ * A kill between writeFileAtomic's tmp-write and rename (src/utils/session-index.js
8
+ * recordSession) leaves a stray `.sessions-index.json.<pid>.<hex>.tmp` file in the
9
+ * config dir forever — 60-73 were observed accumulating. This module lists and
10
+ * removes them; src/cli-handlers-doctor.js composes the result into a check line.
11
+ *
12
+ * The glob matches BOTH writeFileAtomic's naming and the (identical)
13
+ * pre-consolidation hand-rolled scheme, so orphans from either era are found.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const HINTS = require('./remediation-hints');
19
+
20
+ /** Files older than this survive to the next --fix, never a live writer's ms-lived tmp. */
21
+ const AGE_THRESHOLD_MS = 60 * 1000;
22
+
23
+ /**
24
+ * List orphaned sessions-index.json.*.tmp files in the config dir.
25
+ * @returns {Array<{name: string, mtimeMs: number}>}
26
+ */
27
+ function listSessionIndexTmpFiles() {
28
+ const { INDEX_FILENAME } = require('./session-index');
29
+ const dir = require('./config').getConfigDir();
30
+ let entries;
31
+ try { entries = fs.readdirSync(dir); } catch { return []; }
32
+ const prefix = `.${INDEX_FILENAME}.`;
33
+ return entries
34
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
35
+ .map((name) => {
36
+ let mtimeMs = null;
37
+ try { mtimeMs = fs.statSync(path.join(dir, name)).mtimeMs; } catch { /* raced away — skip below */ }
38
+ return { name, mtimeMs };
39
+ })
40
+ .filter((f) => f.mtimeMs !== null);
41
+ }
42
+
43
+ /** Delete one orphaned tmp file by name (relative to the config dir). */
44
+ function unlinkSessionIndexTmp(name) {
45
+ const dir = require('./config').getConfigDir();
46
+ fs.unlinkSync(path.join(dir, name));
47
+ }
48
+
49
+ /**
50
+ * Compose the doctor check line for the tmp-orphan sweep. Pure decision logic
51
+ * (list/sweep side effects come in via `d`); src/cli-handlers-doctor.js wraps
52
+ * this in guard() the same way it wires the mcp-legacy check's inspect/migrate.
53
+ * @param {{listSessionIndexTmpFiles: () => Array<{name:string, mtimeMs:number}>,
54
+ * fix?: boolean, now: () => number, unlinkSessionIndexTmp: (name: string) => void}} d
55
+ */
56
+ function evaluateSessionIndexTmpSweep(d) {
57
+ const id = 'sessions-index-tmp'; const name = 'Session index tmp files';
58
+ const files = d.listSessionIndexTmpFiles() || [];
59
+ if (files.length === 0) {
60
+ return { id, name, status: 'ok', message: '0 orphaned tmp files', hint: null };
61
+ }
62
+ if (!d.fix) {
63
+ return { id, name, status: 'warn', message: `${files.length} orphaned tmp file(s) — run with --fix`, hint: HINTS.sweepSessionIndexTmp };
64
+ }
65
+ const nowMs = d.now();
66
+ const sweepable = files.filter((f) => (nowMs - f.mtimeMs) > AGE_THRESHOLD_MS);
67
+ let swept = 0;
68
+ for (const f of sweepable) {
69
+ try { d.unlinkSessionIndexTmp(f.name); swept += 1; } catch { /* best-effort — report what we got */ }
70
+ }
71
+ const remaining = files.length - swept;
72
+ if (remaining === 0) {
73
+ return { id, name, status: 'ok', message: `swept ${swept} orphaned tmp file(s)`, hint: null };
74
+ }
75
+ return { id, name, status: 'warn', message: `swept ${swept}, ${remaining} remaining (too fresh or unremovable)`, hint: HINTS.sweepSessionIndexTmp };
76
+ }
77
+
78
+ module.exports = {
79
+ AGE_THRESHOLD_MS, listSessionIndexTmpFiles, unlinkSessionIndexTmp, evaluateSessionIndexTmpSweep,
80
+ };
@@ -21,9 +21,9 @@
21
21
 
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
- const crypto = require('crypto');
25
24
  const { getConfigDir } = require('./config');
26
25
  const { canonicalProjectPath } = require('./project-path');
26
+ const { writeFileAtomic } = require('./atomic-write');
27
27
 
28
28
  /** Index filename under the config dir. */
29
29
  const INDEX_FILENAME = 'sessions-index.json';
@@ -70,10 +70,9 @@ function recordSession(taskId, project) {
70
70
  const index = readIndex(); // already guarded; corrupt -> {}
71
71
  index[taskId] = canonical;
72
72
  const target = path.join(dir, INDEX_FILENAME);
73
- // Unique temp name so concurrent writers never clobber the same temp file.
74
- const tmp = path.join(dir, `.${INDEX_FILENAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
75
- fs.writeFileSync(tmp, JSON.stringify(index, null, 2), { mode: 0o600 });
76
- fs.renameSync(tmp, target); // atomic on a single filesystem
73
+ // Atomic (temp + rename); writeFileAtomic mints a unique temp name per call
74
+ // so concurrent writers never clobber the same temp file.
75
+ writeFileAtomic(target, JSON.stringify(index, null, 2), { mode: 0o600 });
77
76
  } catch {
78
77
  // Index is a navigation aid; never let its failure break a session start.
79
78
  }
@@ -2,16 +2,16 @@
2
2
  * Session path resolution.
3
3
  *
4
4
  * Resolves the on-disk directory for a session taskId under a project, with the
5
- * path-traversal guard, the legacy sidecar_sessions shim, and (issue #40) a
6
- * cross-project fallback via the global session index when the session is not
7
- * found under the project this lookup defaulted to.
5
+ * path-traversal guard and (issue #40) a cross-project fallback via the global
6
+ * session index when the session is not found under the project this lookup
7
+ * defaulted to.
8
8
  *
9
9
  * Extracted from validators.js to keep that module under the size gate.
10
10
  */
11
11
 
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
- const { SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('../session-manager');
14
+ const { SESSIONS_DIR } = require('../session-manager');
15
15
  const { lookupSessionProject } = require('./session-index');
16
16
  const { canonicalProjectPath } = require('./project-path');
17
17
 
@@ -27,19 +27,15 @@ function safeSessionDirUnder(project, root, taskId) {
27
27
  return resolved;
28
28
  }
29
29
 
30
- /** Probe both roots (canonical, then legacy) under one project; null if neither exists. */
30
+ /** Probe the canonical root under one project; null if it doesn't exist. */
31
31
  function existingDirUnderProject(project, taskId) {
32
32
  const canonical = safeSessionDirUnder(project, SESSIONS_DIR, taskId);
33
33
  if (fs.existsSync(canonical)) { return canonical; }
34
- const legacy = safeSessionDirUnder(project, LEGACY_SESSIONS_DIR, taskId);
35
- if (fs.existsSync(legacy)) { return legacy; }
36
34
  return null;
37
35
  }
38
36
 
39
37
  /**
40
- * Resolve an EXISTING session path: prefer canonical amicus, fall back to the
41
- * legacy sidecar_sessions dir (shim). The traversal guard runs against BOTH
42
- * roots, so a malicious taskId is rejected regardless of root.
38
+ * Resolve an EXISTING session path under the canonical amicus_sessions dir.
43
39
  *
44
40
  * On a per-project MISS (#40), consult the global index for the project the
45
41
  * taskId was actually recorded under and probe there — so a session created
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  const { IdleWatchdog } = require('./idle-watchdog');
9
- const { getCompatEnv } = require('./env-compat');
10
9
 
11
10
  const MAX_RESTARTS = 3;
12
11
  const RESTART_WINDOW = 5 * 60 * 1000;
@@ -26,8 +25,8 @@ class SharedServerManager {
26
25
  */
27
26
  constructor(options = {}) {
28
27
  this.logger = options.logger || console;
29
- this.maxSessions = Number(process.env.SIDECAR_MAX_SESSIONS) || 20;
30
- this.enabled = getCompatEnv('SHARED_SERVER') !== '0';
28
+ this.maxSessions = Number(process.env.AMICUS_MAX_SESSIONS) || 20;
29
+ this.enabled = process.env.AMICUS_SHARED_SERVER !== '0';
31
30
 
32
31
  /** @type {object|null} Active server handle */
33
32
  this.server = null;
@@ -177,7 +176,10 @@ class SharedServerManager {
177
176
  this._stopCrashPoll();
178
177
  if (this._restartTimer) { clearTimeout(this._restartTimer); this._restartTimer = null; }
179
178
  if (this.server) {
180
- this.server.close();
179
+ // close() is async (B06 escalation); shutdown() stays sync so this
180
+ // remains fire-and-forget — guard against an unhandled rejection.
181
+ const closeResult = this.server.close();
182
+ if (closeResult && typeof closeResult.catch === 'function') { closeResult.catch(() => {}); }
181
183
  this.server = null;
182
184
  this.client = null;
183
185
  }
@@ -215,7 +217,7 @@ class SharedServerManager {
215
217
  /** Poll the Go engine pid; pid death IS the crash signal (H7). */
216
218
  _startCrashPoll(server) {
217
219
  this._stopCrashPoll();
218
- const interval = Number(getCompatEnv('CRASH_POLL_MS')) || CRASH_POLL_INTERVAL;
220
+ const interval = Number(process.env.AMICUS_CRASH_POLL_MS) || CRASH_POLL_INTERVAL;
219
221
  this._crashPoll = setInterval(() => {
220
222
  if (this.server !== server) { this._stopCrashPoll(); return; }
221
223
  if (!this._isProcessAlive(server.goPid)) {