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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +149 -0
- package/README.md +40 -170
- package/bin/amicus.js +14 -20
- package/commands/council.md +3 -1
- 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 +24 -23
- package/skills/sidecar/SKILL.md +3 -3
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-run.js +4 -4
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli.js +35 -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 +132 -108
- package/src/mcp-tools.js +27 -3
- 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 +12 -5
- 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 +19 -4
- 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/api-key-store.js +2 -13
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -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/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +29 -5
- 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.js +8 -2
- 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
package/src/utils/env-loader.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
26
|
+
/** @type {Object.<string, string>} Per-mode env var names */
|
|
30
27
|
const MODE_ENV_MAP = {
|
|
31
|
-
headless: '
|
|
32
|
-
interactive: '
|
|
33
|
-
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
|
|
45
|
-
if (
|
|
46
|
-
const modeEnv =
|
|
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 =
|
|
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;
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -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
|
-
*
|
|
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;
|
|
@@ -234,9 +234,13 @@ function buildWaveResultFromSession(project, waveId) {
|
|
|
234
234
|
|
|
235
235
|
/**
|
|
236
236
|
* Build a model-catalog document (`models [--search] [--refresh] --json`).
|
|
237
|
-
*
|
|
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
|
-
//
|
|
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)) {
|