amicus 1.9.0 → 2.0.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +149 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +14 -20
  5. package/commands/council.md +3 -1
  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 +24 -23
  16. package/skills/sidecar/SKILL.md +3 -3
  17. package/src/cli-handlers-council.js +101 -1
  18. package/src/cli-handlers-doctor.js +7 -0
  19. package/src/cli-handlers-run.js +4 -4
  20. package/src/cli-handlers-spend.js +198 -0
  21. package/src/cli.js +35 -0
  22. package/src/council/presets-cli.js +141 -0
  23. package/src/headless.js +146 -38
  24. package/src/index.js +1 -9
  25. package/src/mcp-server.js +132 -108
  26. package/src/mcp-tools.js +27 -3
  27. package/src/mcp-wait.js +8 -5
  28. package/src/opencode-client.js +33 -10
  29. package/src/prompt-builder.js +32 -11
  30. package/src/session-manager.js +7 -14
  31. package/src/sidecar/continue.js +12 -5
  32. package/src/sidecar/conversation-mirror.js +22 -1
  33. package/src/sidecar/crash-handler.js +2 -1
  34. package/src/sidecar/fanout-leg.js +12 -3
  35. package/src/sidecar/fanout.js +27 -10
  36. package/src/sidecar/interactive-process.js +6 -17
  37. package/src/sidecar/interactive.js +5 -6
  38. package/src/sidecar/models.js +33 -4
  39. package/src/sidecar/progress.js +2 -1
  40. package/src/sidecar/read.js +4 -6
  41. package/src/sidecar/resume.js +19 -4
  42. package/src/sidecar/session-finalize.js +2 -1
  43. package/src/sidecar/session-utils.js +13 -35
  44. package/src/sidecar/setup-window.js +2 -3
  45. package/src/sidecar/start.js +22 -7
  46. package/src/utils/abort-coordinator.js +57 -7
  47. package/src/utils/api-key-store.js +2 -13
  48. package/src/utils/config.js +30 -43
  49. package/src/utils/council-presets.js +87 -0
  50. package/src/utils/env-loader.js +1 -2
  51. package/src/utils/fold-marker.js +79 -0
  52. package/src/utils/idle-watchdog.js +9 -12
  53. package/src/utils/lifecycle.js +1 -1
  54. package/src/utils/mcp-discovery.js +29 -5
  55. package/src/utils/mcp-self-identity.js +12 -5
  56. package/src/utils/model-catalog.js +54 -6
  57. package/src/utils/read-slice.js +73 -0
  58. package/src/utils/remediation-hints.js +9 -0
  59. package/src/utils/result-schema.js +8 -2
  60. package/src/utils/session-abort.js +1 -1
  61. package/src/utils/session-index-tmp-sweep.js +80 -0
  62. package/src/utils/session-index.js +4 -5
  63. package/src/utils/session-path.js +6 -10
  64. package/src/utils/shared-server.js +7 -5
  65. package/src/utils/spend-ledger.js +80 -0
  66. package/src/utils/updater.js +2 -3
  67. package/src/utils/env-compat.js +0 -38
@@ -16,11 +16,10 @@ const { readAuthJsonKeys } = require('./auth-json');
16
16
  * Sources (in priority order):
17
17
  * 1. process.env - already set, never overwritten
18
18
  * 2. ~/.config/amicus/.env - user-configured via `amicus setup`
19
- * (DEPRECATED(amicus-shim): falls back to ~/.config/sidecar/.env if the amicus .env absent)
20
19
  * 3. ~/.local/share/opencode/auth.json - OpenCode SDK fallback
21
20
  */
22
21
  function loadCredentials() {
23
- // Step 1: Load from sidecar .env file
22
+ // Step 1: Load from amicus .env file
24
23
  const fileEntries = loadEnvEntries();
25
24
  for (const [, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
26
25
  if (!process.env[envVar]) {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Fold marker construction/parsing helpers — shared by prompt-builder.js
3
+ * (instructs the model), headless.js (writes + detects), electron/fold.js
4
+ * (writes), and src/sidecar/resume.js (re-derives the nonce from a saved
5
+ * prompt on resume). Centralized here so the marker CONTRACT lives in one
6
+ * place instead of being duplicated string-literal by string-literal.
7
+ *
8
+ * #BL-7 residual: the marker used to be the static string `[SIDECAR_FOLD]`,
9
+ * so model output that genuinely ends with a bare marker (echoing these
10
+ * instructions, a prior sidecar summary, or scraped content) could force a
11
+ * premature fold. A per-run nonce closes that gap — only the exact nonce
12
+ * generated for THIS run completes THIS run.
13
+ */
14
+ const crypto = require('crypto');
15
+
16
+ /** The fixed, public prefix. Intentionally still `[SIDECAR_FOLD` as a
17
+ * substring — anything that greps for the OLD literal string still finds
18
+ * the new nonced marker; it just no longer matches an ANCHORED bare-bracket
19
+ * string (`[SIDECAR_FOLD]`) because a real marker now always carries a
20
+ * `:<nonce>` suffix before the closing bracket. */
21
+ const FOLD_MARKER_PREFIX = 'SIDECAR_FOLD';
22
+
23
+ /** Generate a fresh per-run nonce. 16 hex chars (8 random bytes) — comfortably
24
+ * above the brief's 12+ hex char floor, cheap to embed in prompts. */
25
+ function generateFoldNonce() {
26
+ return crypto.randomBytes(8).toString('hex');
27
+ }
28
+
29
+ /**
30
+ * Build the full marker string for a given nonce: `[SIDECAR_FOLD:<nonce>]`.
31
+ * @param {string} nonce
32
+ * @returns {string}
33
+ */
34
+ function buildFoldMarker(nonce) {
35
+ return `[${FOLD_MARKER_PREFIX}:${nonce}]`;
36
+ }
37
+
38
+ /** Escape a string for safe embedding inside a RegExp source. */
39
+ function escapeRegExp(str) {
40
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ }
42
+
43
+ /**
44
+ * Build a RegExp that matches `buildFoldMarker(nonce)` as the FINAL
45
+ * non-empty line of a string (see headless.js findTrailingFoldMarker for the
46
+ * consuming semantics). Exported so headless.js doesn't hand-roll the same
47
+ * escaping logic.
48
+ * @param {string} nonce
49
+ * @returns {RegExp}
50
+ */
51
+ function trailingFoldMarkerRegex(nonce) {
52
+ const escaped = escapeRegExp(buildFoldMarker(nonce));
53
+ return new RegExp(`^[^\\S\\r\\n]*${escaped}[^\\S\\r\\n]*$(?![\\s\\S]*\\S)`, 'm');
54
+ }
55
+
56
+ /**
57
+ * Recover the nonce embedded in a previously-built system prompt (resume.js:
58
+ * a resumed session re-sends the ORIGINAL prompt text — which already
59
+ * instructs the model with the nonce baked in at initial `start`/`continue`
60
+ * time — rather than building a fresh one via buildPrompts). Matches the
61
+ * FIRST occurrence of the marker anywhere in the text (the prompt's own
62
+ * instruction line), not a final-line match — this is prompt text, not
63
+ * model output.
64
+ * @param {string} text
65
+ * @returns {string|null} the nonce, or null if no marker is present
66
+ */
67
+ function extractNonceFromText(text) {
68
+ if (!text) { return null; }
69
+ const m = new RegExp(`\\[${FOLD_MARKER_PREFIX}:([0-9a-f]+)\\]`).exec(text);
70
+ return m ? m[1] : null;
71
+ }
72
+
73
+ module.exports = {
74
+ FOLD_MARKER_PREFIX,
75
+ generateFoldNonce,
76
+ buildFoldMarker,
77
+ trailingFoldMarkerRegex,
78
+ extractNonceFromText,
79
+ };
@@ -9,16 +9,13 @@
9
9
  *
10
10
  * Timeout priority (highest to lowest):
11
11
  * 1. Per-mode env var (AMICUS_IDLE_TIMEOUT_HEADLESS, etc.) in minutes
12
- * (legacy SIDECAR_IDLE_TIMEOUT_* still honored via env-compat shim)
13
- * 2. Blanket env var AMICUS_IDLE_TIMEOUT / SIDECAR_IDLE_TIMEOUT in minutes
12
+ * 2. Blanket env var AMICUS_IDLE_TIMEOUT in minutes
14
13
  * 3. Constructor option `timeout` in milliseconds
15
14
  * 4. Mode default (headless=15m, interactive=60m, server=30m)
16
15
  */
17
16
 
18
17
  'use strict';
19
18
 
20
- const { getCompatEnv } = require('./env-compat');
21
-
22
19
  /** @type {Object.<string, number>} Default timeouts per mode in milliseconds */
23
20
  const MODE_TIMEOUTS = {
24
21
  headless: 15 * 60 * 1000,
@@ -26,11 +23,11 @@ const MODE_TIMEOUTS = {
26
23
  server: 30 * 60 * 1000,
27
24
  };
28
25
 
29
- /** @type {Object.<string, string>} Per-mode env-compat suffixes */
26
+ /** @type {Object.<string, string>} Per-mode env var names */
30
27
  const MODE_ENV_MAP = {
31
- headless: 'IDLE_TIMEOUT_HEADLESS',
32
- interactive: 'IDLE_TIMEOUT_INTERACTIVE',
33
- server: 'IDLE_TIMEOUT_SERVER',
28
+ headless: 'AMICUS_IDLE_TIMEOUT_HEADLESS',
29
+ interactive: 'AMICUS_IDLE_TIMEOUT_INTERACTIVE',
30
+ server: 'AMICUS_IDLE_TIMEOUT_SERVER',
34
31
  };
35
32
 
36
33
  /**
@@ -41,16 +38,16 @@ const MODE_ENV_MAP = {
41
38
  * @returns {number} Effective timeout in ms, or Infinity if disabled
42
39
  */
43
40
  function resolveTimeout(mode, optionTimeout) {
44
- const modeSuffix = MODE_ENV_MAP[mode];
45
- if (modeSuffix !== undefined) {
46
- const modeEnv = getCompatEnv(modeSuffix);
41
+ const modeEnvName = MODE_ENV_MAP[mode];
42
+ if (modeEnvName !== undefined) {
43
+ const modeEnv = process.env[modeEnvName];
47
44
  if (modeEnv !== undefined) {
48
45
  const mins = Number(modeEnv);
49
46
  return mins === 0 ? Infinity : mins * 60 * 1000;
50
47
  }
51
48
  }
52
49
 
53
- const blanket = getCompatEnv('IDLE_TIMEOUT');
50
+ const blanket = process.env.AMICUS_IDLE_TIMEOUT;
54
51
  if (blanket !== undefined) {
55
52
  const mins = Number(blanket);
56
53
  return mins === 0 ? Infinity : mins * 60 * 1000;
@@ -12,7 +12,7 @@
12
12
  // when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
13
13
  // `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
14
14
  // interactive Electron flow that must never be force-exited).
15
- const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
15
+ const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor', 'spend' /* local-only: no OpenCode server, no stray handles */]);
16
16
 
17
17
  /** @param {string} command @returns {boolean} */
18
18
  function isOneShotCommand(command) {
@@ -139,6 +139,34 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
139
139
  return Object.keys(merged).length > 0 ? merged : null;
140
140
  }
141
141
 
142
+ /**
143
+ * Resolve Claude Desktop's per-platform config directory.
144
+ * Mirrors the 3-way branch in src/environment.js getCoworkRoot (same
145
+ * APPDATA || homedir-fallback form on win32) but stops one level higher —
146
+ * getCoworkRoot resolves .../Claude/local-agent-mode-sessions, while this
147
+ * needs the parent .../Claude dir that holds claude_desktop_config.json.
148
+ * Kept local rather than imported to avoid depending on an internal
149
+ * implementation detail (stripping getCoworkRoot's trailing segment).
150
+ *
151
+ * @param {string} platform - OS platform (process.platform)
152
+ * @returns {string} Claude Desktop config directory
153
+ */
154
+ function getClaudeDesktopConfigDir(platform) {
155
+ const homedir = os.homedir();
156
+
157
+ if (platform === 'darwin') {
158
+ return path.join(homedir, 'Library', 'Application Support', 'Claude');
159
+ }
160
+
161
+ if (platform === 'win32') {
162
+ const appdata = process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming');
163
+ return path.join(appdata, 'Claude');
164
+ }
165
+
166
+ // Linux and other Unix-like systems
167
+ return path.join(homedir, '.config', 'Claude');
168
+ }
169
+
142
170
  /**
143
171
  * Discover MCP servers from Cowork / Claude Desktop config.
144
172
  *
@@ -146,11 +174,7 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
146
174
  * @returns {object|null} MCP server configs, or null if none found
147
175
  */
148
176
  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
- );
177
+ const baseDir = configDir || getClaudeDesktopConfigDir(process.platform);
154
178
 
155
179
  try {
156
180
  const configPath = path.join(baseDir, 'claude_desktop_config.json');
@@ -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;
@@ -234,9 +234,13 @@ function buildWaveResultFromSession(project, waveId) {
234
234
 
235
235
  /**
236
236
  * Build a model-catalog document (`models [--search] [--refresh] --json`).
237
- * @param {{models: Array, fetchedAt: number|null, refreshed?: boolean, search?: string|null}} opts
237
+ * #13: lastRefreshAttempt/lastRefreshError are additive null/null when the
238
+ * last refresh attempt on record succeeded (or none has happened yet).
239
+ * @param {{models: Array, fetchedAt: number|null, refreshed?: boolean, search?: string|null,
240
+ * lastRefreshAttempt?: number|null, lastRefreshError?: string|null}} opts
238
241
  */
239
- function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null }) {
242
+ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
243
+ lastRefreshAttempt = null, lastRefreshError = null }) {
240
244
  return {
241
245
  schemaVersion: SCHEMA_VERSION,
242
246
  type: 'model-catalog',
@@ -245,6 +249,8 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null }
245
249
  search,
246
250
  count: models.length,
247
251
  models,
252
+ lastRefreshAttempt: lastRefreshAttempt || null,
253
+ lastRefreshError: lastRefreshError || null,
248
254
  };
249
255
  }
250
256
 
@@ -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)) {