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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +200 -0
- package/README.md +40 -170
- package/bin/amicus.js +19 -107
- package/commands/council.md +7 -3
- package/electron/fold.js +10 -1
- package/electron/ipc-setup.js +10 -15
- package/electron/main.js +21 -16
- package/electron/preload-setup.js +0 -1
- package/electron/setup-ui-council.js +64 -10
- package/electron/setup-ui-styles.js +34 -3
- package/electron/setup-ui.js +44 -12
- package/package.json +2 -5
- package/skills/second-opinion/MODEL-NOTES.md +2 -2
- package/skills/second-opinion/SKILL.md +30 -28
- package/skills/sidecar/SKILL.md +20 -17
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +20 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +9 -8
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +55 -0
- package/src/council/presets-cli.js +141 -0
- package/src/headless.js +146 -38
- package/src/index.js +1 -9
- package/src/mcp-server.js +140 -113
- package/src/mcp-tools.js +58 -24
- package/src/mcp-wait.js +8 -5
- package/src/opencode-client.js +33 -10
- package/src/prompt-builder.js +32 -11
- package/src/session-manager.js +7 -14
- package/src/sidecar/continue.js +34 -12
- package/src/sidecar/conversation-mirror.js +22 -1
- package/src/sidecar/crash-handler.js +2 -1
- package/src/sidecar/fanout-leg.js +12 -3
- package/src/sidecar/fanout.js +27 -10
- package/src/sidecar/interactive-process.js +6 -17
- package/src/sidecar/interactive.js +5 -6
- package/src/sidecar/models.js +33 -4
- package/src/sidecar/progress.js +2 -1
- package/src/sidecar/read.js +4 -6
- package/src/sidecar/resume.js +41 -11
- package/src/sidecar/session-finalize.js +2 -1
- package/src/sidecar/session-utils.js +13 -35
- package/src/sidecar/setup-window.js +2 -3
- package/src/sidecar/start.js +22 -7
- package/src/utils/abort-coordinator.js +57 -7
- package/src/utils/abort-result.js +36 -0
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/doctor-mcp-checks.js +84 -0
- package/src/utils/env-loader.js +1 -2
- package/src/utils/fold-marker.js +79 -0
- package/src/utils/idle-watchdog.js +9 -12
- package/src/utils/input-validators.js +52 -1
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +80 -19
- package/src/utils/mcp-self-identity.js +12 -5
- package/src/utils/model-catalog.js +54 -6
- package/src/utils/read-slice.js +73 -0
- package/src/utils/remediation-hints.js +9 -0
- package/src/utils/result-schema-version.js +14 -0
- package/src/utils/result-schema.js +18 -12
- package/src/utils/session-abort.js +1 -1
- package/src/utils/session-index-tmp-sweep.js +80 -0
- package/src/utils/session-index.js +4 -5
- package/src/utils/session-path.js +6 -10
- package/src/utils/shared-server.js +7 -5
- package/src/utils/spend-ledger.js +80 -0
- package/src/utils/updater.js +2 -3
- 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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
44
|
+
* @returns {object} Merged MCP server configs (never stripped); {} if none found
|
|
46
45
|
*/
|
|
47
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
43
|
-
|
|
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(
|
|
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) {
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
30
|
-
this.enabled =
|
|
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
|
-
|
|
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(
|
|
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)) {
|