amicus 1.7.4 → 1.7.5
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 +37 -0
- package/bin/amicus.js +13 -0
- package/package.json +1 -1
- package/src/cli-handlers-run.js +4 -0
- package/src/cli.js +22 -0
- package/src/context-compression.js +2 -0
- package/src/context.js +2 -1
- package/src/headless.js +35 -12
- package/src/mcp-server.js +30 -9
- package/src/opencode-client.js +15 -1
- package/src/session-manager.js +9 -1
- package/src/sidecar/conversation-mirror.js +15 -2
- package/src/utils/auth-json.js +47 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.5",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"author": { "name": "Christian Wagner" },
|
|
6
6
|
"homepage": "https://bourbondog.github.io/amicus/",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.5] - 2026-07-01
|
|
9
|
+
|
|
10
|
+
A batch of fixes from an independent DeepSeek V4 Pro code review, each verified against source.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Long prompts no longer truncate on Windows.** The `amicus_start` and `amicus_continue` MCP
|
|
14
|
+
handlers passed the full prompt inline on the spawned command line, which silently truncated once
|
|
15
|
+
it crossed Windows's ~32 KB argument cap — so a sidecar could run against a corrupted briefing with
|
|
16
|
+
no error. Both paths now write the prompt to a `briefing.md` in the session directory and pass
|
|
17
|
+
`--prompt-file`, matching the existing fanout handler; the CLI `continue` command learned
|
|
18
|
+
`--prompt-file` as well.
|
|
19
|
+
- **`getMessages` no longer masks SDK error responses.** An error-shaped response with no `data`
|
|
20
|
+
array was indistinguishable from "zero messages" in the poll loop; it now logs a warning carrying
|
|
21
|
+
the session id and surfaced error while still returning `[]`.
|
|
22
|
+
- **`getSessionDir` rejects path-traversal task ids.** A defense-in-depth containment guard (the same
|
|
23
|
+
check style used elsewhere in the codebase) throws on a task id that would escape the sessions dir.
|
|
24
|
+
- **Cross-platform `auth.json` discovery.** The one-time OpenCode key-import path was hardcoded to the
|
|
25
|
+
Unix XDG location; it now probes `$XDG_DATA_HOME`, `~/.local/share`, and `%APPDATA%` (Windows) and
|
|
26
|
+
uses the first that exists.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **The fold-completion marker is harder to spoof.** A bare `[SIDECAR_FOLD]` echoed mid-output (e.g. a
|
|
30
|
+
model reproducing these instructions or summarizing a prior sidecar session) no longer forces a
|
|
31
|
+
premature fold — the marker now completes a run only when it is the final non-empty line of output,
|
|
32
|
+
with the existing idle/timeout fallbacks unchanged so a run can never hang.
|
|
33
|
+
- **The conversation-mirror tool-call buffer is bounded.** Capped at 2000 entries with a separate
|
|
34
|
+
dedup set, so a very long tool-heavy session can't grow it without limit.
|
|
35
|
+
- **Unknown `--no-*` flags are treated as boolean.** They no longer swallow the following positional
|
|
36
|
+
argument (`--no-x=value` still records its inline value; allowlisted flags are unchanged).
|
|
37
|
+
- **`--prompt-file` validation is order-independent.** `validateStartArgs` now resolves the prompt
|
|
38
|
+
source itself, so validation no longer depends on the handler having resolved it first.
|
|
39
|
+
|
|
40
|
+
### Docs
|
|
41
|
+
- **Corrected the `tiktoken` dependency note.** It is declared but unused; token sizing uses a
|
|
42
|
+
`length/4` heuristic. Added caveat comments at both estimators. (Removing the unused dependency is
|
|
43
|
+
tracked as a follow-up.)
|
|
44
|
+
|
|
8
45
|
## [1.7.4] - 2026-06-30
|
|
9
46
|
|
|
10
47
|
### Fixed
|
package/bin/amicus.js
CHANGED
|
@@ -215,6 +215,19 @@ async function handleContinue(args) {
|
|
|
215
215
|
process.exit(1);
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
|
|
219
|
+
// follow-up prompt via file, dodging the ~32KB Windows command-line cap.
|
|
220
|
+
if (args['prompt-file'] !== undefined) {
|
|
221
|
+
const { resolvePromptSource } = require('../src/utils/prompt-source');
|
|
222
|
+
const promptRes = resolvePromptSource(args);
|
|
223
|
+
if (promptRes.error) {
|
|
224
|
+
console.error(promptRes.error);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
args.prompt = promptRes.prompt;
|
|
228
|
+
delete args['prompt-file'];
|
|
229
|
+
}
|
|
230
|
+
|
|
218
231
|
if (!args.prompt && !args.briefing) {
|
|
219
232
|
console.error('Error: --prompt is required for continue');
|
|
220
233
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.5",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -28,6 +28,10 @@ async function handleStart(args) {
|
|
|
28
28
|
const promptRes = resolvePromptSource(args);
|
|
29
29
|
if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
|
|
30
30
|
args.prompt = promptRes.prompt;
|
|
31
|
+
// Drop --prompt-file now that it's resolved: validateStartArgs' self-contained
|
|
32
|
+
// guard would otherwise re-run resolvePromptSource with both prompt and
|
|
33
|
+
// prompt-file set and trip its mutually-exclusive branch.
|
|
34
|
+
delete args['prompt-file'];
|
|
31
35
|
}
|
|
32
36
|
if (args.json && !args['no-ui']) {
|
|
33
37
|
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
validateApiKey,
|
|
17
17
|
validateThinkingLevel
|
|
18
18
|
} = require('./utils/validators');
|
|
19
|
+
const { resolvePromptSource } = require('./utils/prompt-source');
|
|
19
20
|
const { logger } = require('./utils/logger');
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -78,6 +79,14 @@ function parseArgs(argv) {
|
|
|
78
79
|
continue;
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
// Unknown negation flags: known --no-* flags are already handled by
|
|
83
|
+
// isBooleanFlag() above; treat any *unregistered* --no-* token as a
|
|
84
|
+
// boolean so it can never swallow the following positional as a value.
|
|
85
|
+
if (key.startsWith('no-')) {
|
|
86
|
+
result[key] = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
// Options with values
|
|
82
91
|
if (next && !next.startsWith('--')) {
|
|
83
92
|
result[key] = parseValue(key, next);
|
|
@@ -153,6 +162,19 @@ function parseValue(key, value) {
|
|
|
153
162
|
* @returns {{ valid: boolean, error?: string }}
|
|
154
163
|
*/
|
|
155
164
|
function validateStartArgs(args) {
|
|
165
|
+
// Resolve the prompt source (--prompt XOR --prompt-file) here so validation
|
|
166
|
+
// is self-contained and order-independent: a caller need not have run
|
|
167
|
+
// resolvePromptSource() first. Skipped when args.prompt is already a plain
|
|
168
|
+
// string (the classic path / already-resolved by handleStart), so the empty
|
|
169
|
+
// '' case still falls through to the presence/content checks below.
|
|
170
|
+
if (args['prompt-file'] !== undefined || args.prompt === undefined || args.prompt === true) {
|
|
171
|
+
const res = resolvePromptSource(args);
|
|
172
|
+
if (res.error) {
|
|
173
|
+
return { valid: false, code: 'MISSING_PROMPT', error: res.error };
|
|
174
|
+
}
|
|
175
|
+
args.prompt = res.prompt;
|
|
176
|
+
}
|
|
177
|
+
|
|
156
178
|
// Required: --prompt (presence check)
|
|
157
179
|
if (!args.prompt) {
|
|
158
180
|
return { valid: false, error: 'Error: --prompt is required' };
|
|
@@ -28,6 +28,8 @@ function estimateTokenCount(text) {
|
|
|
28
28
|
return 0;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Intentional cheap heuristic — not a real BPE tokenizer; under/over-counts
|
|
32
|
+
// for CJK, code, and punctuation-dense text. (ceil, vs floor in context.js.)
|
|
31
33
|
return Math.ceil(text.length / 4);
|
|
32
34
|
}
|
|
33
35
|
|
package/src/context.js
CHANGED
|
@@ -52,7 +52,8 @@ function estimateTokens(text) {
|
|
|
52
52
|
if (!text || typeof text !== 'string') {
|
|
53
53
|
return 0;
|
|
54
54
|
}
|
|
55
|
-
//
|
|
55
|
+
// Intentional cheap heuristic (spec §5.3: ~4 chars/token). Not a real BPE
|
|
56
|
+
// tokenizer — under/over-counts for CJK, code, and punctuation-dense text.
|
|
56
57
|
return Math.floor(text.length / 4);
|
|
57
58
|
}
|
|
58
59
|
|
package/src/headless.js
CHANGED
|
@@ -21,6 +21,27 @@ const { createMirrorState, mirrorMessages, logMessage } = require('./sidecar/con
|
|
|
21
21
|
const FOLD_MARKER = '[SIDECAR_FOLD]';
|
|
22
22
|
const COMPLETE_MARKER = FOLD_MARKER; // backward compat
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* #BL-7: the fold marker is the fixed public string [SIDECAR_FOLD]. A model can
|
|
26
|
+
* legitimately emit it on its own line mid-output — summarizing a prior sidecar,
|
|
27
|
+
* reproducing these instructions, or from scraped content — which used to force a
|
|
28
|
+
* PREMATURE fold. Harden by requiring the marker to be the FINAL non-empty line
|
|
29
|
+
* of the output: a standalone marker followed by MORE content is treated as
|
|
30
|
+
* echoed prose, not a completion signal. Only the true trailing marker folds.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} output - Accumulated assistant output
|
|
33
|
+
* @returns {number} char index where the trailing marker line begins, or -1
|
|
34
|
+
*/
|
|
35
|
+
function findTrailingFoldMarker(output) {
|
|
36
|
+
if (!output) { return -1; }
|
|
37
|
+
// The marker must be the last non-empty line: it sits alone on its line
|
|
38
|
+
// (only intra-line whitespace around it) and NOTHING but whitespace follows
|
|
39
|
+
// to the end of the string. The `(?![\s\S]*\S)` lookahead pins it to the true
|
|
40
|
+
// end — a bare marker followed by more prose is echoed content, not a signal.
|
|
41
|
+
const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
|
|
42
|
+
return m ? m.index : -1;
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
/**
|
|
25
46
|
* Default timeout: 15 minutes per spec §6.2
|
|
26
47
|
*/
|
|
@@ -399,10 +420,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
399
420
|
elapsed: Date.now() - startTime
|
|
400
421
|
});
|
|
401
422
|
|
|
402
|
-
// Check for completion marker
|
|
403
|
-
// Models may
|
|
404
|
-
//
|
|
405
|
-
|
|
423
|
+
// Check for the completion marker as the FINAL non-empty line (#BL-7).
|
|
424
|
+
// Models may emit [SIDECAR_FOLD] on its own line mid-output (echoing a
|
|
425
|
+
// prior sidecar, these instructions, or scraped content) — only treat
|
|
426
|
+
// it as a completion signal when nothing but blank lines follow it.
|
|
427
|
+
if (findTrailingFoldMarker(mirror.output) !== -1) {
|
|
406
428
|
completed = true;
|
|
407
429
|
break;
|
|
408
430
|
}
|
|
@@ -608,7 +630,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
608
630
|
}
|
|
609
631
|
|
|
610
632
|
/**
|
|
611
|
-
* Extract summary from output (everything before [SIDECAR_FOLD])
|
|
633
|
+
* Extract summary from output (everything before the trailing [SIDECAR_FOLD])
|
|
612
634
|
* Spec Reference: §6.2 - Return summary (everything before [SIDECAR_FOLD])
|
|
613
635
|
*
|
|
614
636
|
* @param {string} output - Raw output from OpenCode
|
|
@@ -619,13 +641,13 @@ function extractSummary(output) {
|
|
|
619
641
|
return '';
|
|
620
642
|
}
|
|
621
643
|
|
|
622
|
-
// Split on the fold marker only when it
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
if (
|
|
628
|
-
return output.slice(0,
|
|
644
|
+
// Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
|
|
645
|
+
// A [SIDECAR_FOLD] echoed mid-output (describing code, reproducing these
|
|
646
|
+
// instructions, or from scraped content) is NOT a delimiter — keep it as
|
|
647
|
+
// content. Only the true trailing marker is stripped.
|
|
648
|
+
const idx = findTrailingFoldMarker(output);
|
|
649
|
+
if (idx !== -1) {
|
|
650
|
+
return output.slice(0, idx).trim();
|
|
629
651
|
}
|
|
630
652
|
return output.trim();
|
|
631
653
|
}
|
|
@@ -659,6 +681,7 @@ module.exports = {
|
|
|
659
681
|
waitForServer,
|
|
660
682
|
withTimeout,
|
|
661
683
|
extractSummary,
|
|
684
|
+
findTrailingFoldMarker,
|
|
662
685
|
formatFoldOutput,
|
|
663
686
|
DEFAULT_TIMEOUT,
|
|
664
687
|
FOLD_MARKER,
|
package/src/mcp-server.js
CHANGED
|
@@ -211,7 +211,17 @@ const handlers = {
|
|
|
211
211
|
const { generateTaskId } = require('./sidecar/start');
|
|
212
212
|
const taskId = generateTaskId();
|
|
213
213
|
|
|
214
|
-
|
|
214
|
+
// New session → canonical amicus dir (writes).
|
|
215
|
+
const sessionDir = getSessionDir(cwd, taskId);
|
|
216
|
+
|
|
217
|
+
// BL-1: the prompt goes via file, not inline. A long prompt passed as a CLI
|
|
218
|
+
// arg silently truncates/corrupts on Windows (~32KB command-line cap). Mirror
|
|
219
|
+
// the amicus_fanout briefing-file pattern; the spawn command line must NOT
|
|
220
|
+
// carry the prompt. --prompt-file is resolved by handleStart/resolvePromptSource.
|
|
221
|
+
// The file itself is written just before the spawn fallback below (the
|
|
222
|
+
// shared-server path passes the prompt in-process and never reads args).
|
|
223
|
+
const briefingPath = path.join(sessionDir, 'briefing.md');
|
|
224
|
+
const args = ['start', '--prompt-file', briefingPath, '--task-id', taskId, '--client', 'cowork'];
|
|
215
225
|
if (resolvedModel) { args.push('--model', resolvedModel); }
|
|
216
226
|
const agent = (input.noUi && (!input.agent || input.agent.toLowerCase() === 'chat'))
|
|
217
227
|
? 'build' : input.agent;
|
|
@@ -229,9 +239,6 @@ const handlers = {
|
|
|
229
239
|
if (input.windowPosition) { args.push('--position', input.windowPosition); }
|
|
230
240
|
args.push('--cwd', cwd);
|
|
231
241
|
|
|
232
|
-
// New session → canonical amicus dir (writes).
|
|
233
|
-
const sessionDir = getSessionDir(cwd, taskId);
|
|
234
|
-
|
|
235
242
|
if (sharedServer.enabled && input.noUi) {
|
|
236
243
|
// Shared server path: headless only, delegates to runHeadless()
|
|
237
244
|
let sessionId;
|
|
@@ -349,14 +356,20 @@ const handlers = {
|
|
|
349
356
|
}
|
|
350
357
|
}
|
|
351
358
|
|
|
352
|
-
// Feature flag disabled (or shared server failed): fall back to per-process spawn
|
|
359
|
+
// Feature flag disabled (or shared server failed): fall back to per-process spawn.
|
|
360
|
+
// BL-1: create the session dir and write the prompt to briefing.md BEFORE the
|
|
361
|
+
// spawn so --prompt-file (built above) resolves to a real file, keeping the
|
|
362
|
+
// full prompt off the ~32KB-capped Windows command line.
|
|
353
363
|
let child;
|
|
354
|
-
try {
|
|
364
|
+
try {
|
|
365
|
+
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
366
|
+
fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
|
|
367
|
+
child = spawnSidecarProcess(args, sessionDir);
|
|
368
|
+
} catch (err) {
|
|
355
369
|
return textResult(`Failed to start Amicus: ${err.message}`, true);
|
|
356
370
|
}
|
|
357
371
|
|
|
358
372
|
if (child && child.pid) {
|
|
359
|
-
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
360
373
|
recordSession(taskId, cwd); // #40: global index for cross-project lookup
|
|
361
374
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
362
375
|
if (!fs.existsSync(metaPath)) {
|
|
@@ -631,14 +644,22 @@ const handlers = {
|
|
|
631
644
|
// New continuation session → canonical amicus dir (writes).
|
|
632
645
|
const sessionDir = getSessionDir(cwd, newTaskId);
|
|
633
646
|
|
|
634
|
-
|
|
647
|
+
// BL-1: the follow-up prompt goes via file, not inline, so a long prompt is
|
|
648
|
+
// never truncated by the ~32KB Windows command-line cap. handleContinue reads
|
|
649
|
+
// --prompt-file. The briefing is written into the NEW session dir below.
|
|
650
|
+
const briefingPath = path.join(sessionDir, 'briefing.md');
|
|
651
|
+
const args = ['continue', input.taskId, '--prompt-file', briefingPath,
|
|
635
652
|
'--task-id', newTaskId, '--client', 'cowork', '--cwd', cwd];
|
|
636
653
|
if (input.model) { args.push('--model', input.model); }
|
|
637
654
|
if (input.noUi) { args.push('--no-ui', '--agent', 'build'); }
|
|
638
655
|
if (input.timeout) { args.push('--timeout', String(input.timeout)); }
|
|
639
656
|
if (input.contextTurns) { args.push('--context-turns', String(input.contextTurns)); }
|
|
640
657
|
if (input.contextMaxTokens) { args.push('--context-max-tokens', String(input.contextMaxTokens)); }
|
|
641
|
-
try {
|
|
658
|
+
try {
|
|
659
|
+
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
660
|
+
fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
|
|
661
|
+
spawnSidecarProcess(args, sessionDir);
|
|
662
|
+
} catch (err) {
|
|
642
663
|
return textResult(`Failed to continue: ${err.message}`, true);
|
|
643
664
|
}
|
|
644
665
|
recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
|
package/src/opencode-client.js
CHANGED
|
@@ -261,7 +261,21 @@ async function getMessages(client, sessionId, directory) {
|
|
|
261
261
|
...directoryQuery(directory)
|
|
262
262
|
});
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
// A well-formed response carries a data array (possibly empty). If it's
|
|
265
|
+
// absent, the SDK returned an error shape (e.g. a 5xx with { error }) that
|
|
266
|
+
// would otherwise be indistinguishable from "0 messages" — surface it so the
|
|
267
|
+
// poll loop's diagnostics aren't blind. Return contract unchanged: an array.
|
|
268
|
+
if (!Array.isArray(result.data)) {
|
|
269
|
+
const { logger } = require('./utils/logger');
|
|
270
|
+
logger.warn('getMessages: SDK response had no data array', {
|
|
271
|
+
sessionId,
|
|
272
|
+
status: (result.response && result.response.status) || (result.error && result.error.status),
|
|
273
|
+
error: result.error && (result.error.message || JSON.stringify(result.error))
|
|
274
|
+
});
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return result.data;
|
|
265
279
|
}
|
|
266
280
|
|
|
267
281
|
/**
|
package/src/session-manager.js
CHANGED
|
@@ -37,7 +37,15 @@ const LEGACY_SESSIONS_DIR = 'sidecar_sessions';
|
|
|
37
37
|
* // Returns: '/path/to/project/.claude/amicus_sessions/abc123'
|
|
38
38
|
*/
|
|
39
39
|
function getSessionDir(projectDir, taskId) {
|
|
40
|
-
|
|
40
|
+
const sessionDir = path.join(projectDir, '.claude', SESSIONS_DIR, taskId);
|
|
41
|
+
// Defense-in-depth: reject a taskId that would escape the sessions dir
|
|
42
|
+
// (path separators / '..'). Callers pre-validate today; this is a backstop.
|
|
43
|
+
const base = path.resolve(projectDir, '.claude', SESSIONS_DIR);
|
|
44
|
+
const resolved = path.resolve(sessionDir);
|
|
45
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
|
46
|
+
throw new Error('Invalid task ID: path traversal detected');
|
|
47
|
+
}
|
|
48
|
+
return sessionDir;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/**
|
|
@@ -9,11 +9,20 @@
|
|
|
9
9
|
* except the injectable `now`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
// Bound the unbounded toolCalls accumulator (BL-4). This array holds {id,name,input}
|
|
13
|
+
// objects whose `input` can be large; it is the only mirror-state member that grows
|
|
14
|
+
// per tool call with no natural bound and carries heavy payloads. Keep the most recent
|
|
15
|
+
// N, dropping the oldest. Dedup identity lives in the separate seenToolCallIds Set so
|
|
16
|
+
// dropping an old array entry never causes a re-append or a spurious toolCalls.length
|
|
17
|
+
// bump in the headless idle detector.
|
|
18
|
+
const MAX_TOOL_CALLS = 2000;
|
|
19
|
+
|
|
12
20
|
/** Fresh cursor for a session's mirror. */
|
|
13
21
|
function createMirrorState() {
|
|
14
22
|
return {
|
|
15
23
|
seenTextParts: new Map(), // partId -> last captured text length
|
|
16
|
-
toolCalls: [], // [{id,name,input}]
|
|
24
|
+
toolCalls: [], // [{id,name,input}] — capped at MAX_TOOL_CALLS (most-recent-N)
|
|
25
|
+
seenToolCallIds: new Set(), // stable dedup identity for tool calls (survives the cap)
|
|
17
26
|
seenToolResultIds: new Set(),
|
|
18
27
|
receivingReported: false,
|
|
19
28
|
output: '', // accumulated assistant text
|
|
@@ -75,9 +84,13 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
75
84
|
progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
|
|
76
85
|
}
|
|
77
86
|
}
|
|
78
|
-
} else if ((part.type === 'tool_use' || part.type === 'tool') && !state.
|
|
87
|
+
} else if ((part.type === 'tool_use' || part.type === 'tool') && !state.seenToolCallIds.has(part.id)) {
|
|
79
88
|
const toolCall = { id: part.id, name: part.name, input: part.input };
|
|
89
|
+
state.seenToolCallIds.add(part.id);
|
|
80
90
|
state.toolCalls.push(toolCall);
|
|
91
|
+
// Bound growth: keep the most recent N tool-call payloads (BL-4). Dedup is the
|
|
92
|
+
// Set above, so dropping the oldest here never causes a re-append.
|
|
93
|
+
if (state.toolCalls.length > MAX_TOOL_CALLS) { state.toolCalls.shift(); }
|
|
81
94
|
appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
|
|
82
95
|
|
|
83
96
|
// Update progress on tool_use detection
|
package/src/utils/auth-json.js
CHANGED
|
@@ -11,7 +11,43 @@ const os = require('os');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { logger } = require('./logger');
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Ordered, de-duplicated candidate locations for OpenCode's auth.json, most
|
|
16
|
+
* specific first. OpenCode uses XDG-style data dirs; on Windows it still writes
|
|
17
|
+
* to ~/.local/share/opencode (verified), so that path stays FIRST after XDG.
|
|
18
|
+
* Mirrors the cross-platform precedence pattern in src/sidecar/electron-cache.js.
|
|
19
|
+
* @param {NodeJS.ProcessEnv} [env] - Environment (injectable for tests)
|
|
20
|
+
* @returns {string[]}
|
|
21
|
+
*/
|
|
22
|
+
function authJsonCandidates(env = process.env) {
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
const candidates = [];
|
|
25
|
+
if (env.XDG_DATA_HOME) { candidates.push(path.join(env.XDG_DATA_HOME, 'opencode', 'auth.json')); }
|
|
26
|
+
candidates.push(path.join(home, '.local', 'share', 'opencode', 'auth.json'));
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
29
|
+
candidates.push(path.join(appData, 'opencode', 'auth.json'));
|
|
30
|
+
}
|
|
31
|
+
return [...new Set(candidates)];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the auth.json path to use: first existing candidate, else the primary
|
|
36
|
+
* (~/.local/share) path so callers/writers have a stable default.
|
|
37
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function resolveAuthJsonPath(env = process.env) {
|
|
41
|
+
const candidates = authJsonCandidates(env);
|
|
42
|
+
for (const c of candidates) {
|
|
43
|
+
if (fs.existsSync(c)) { return c; }
|
|
44
|
+
}
|
|
45
|
+
const localShare = path.join('.local', 'share');
|
|
46
|
+
return candidates.find((c) => c.includes(localShare)) || candidates[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Backward-compat export: the resolved path at module load time.
|
|
50
|
+
const AUTH_JSON_PATH = resolveAuthJsonPath();
|
|
15
51
|
|
|
16
52
|
/** Known provider IDs that map to sidecar's PROVIDER_ENV_MAP */
|
|
17
53
|
const KNOWN_PROVIDERS = ['openrouter', 'google', 'openai', 'anthropic', 'deepseek'];
|
|
@@ -37,10 +73,11 @@ function extractKey(entry) {
|
|
|
37
73
|
* @returns {Object<string, string>} Map of provider -> key string (only providers with keys)
|
|
38
74
|
*/
|
|
39
75
|
function readAuthJsonKeys() {
|
|
40
|
-
|
|
76
|
+
const authPath = resolveAuthJsonPath();
|
|
77
|
+
if (!fs.existsSync(authPath)) { return {}; }
|
|
41
78
|
let parsed;
|
|
42
79
|
try {
|
|
43
|
-
parsed = JSON.parse(fs.readFileSync(
|
|
80
|
+
parsed = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
44
81
|
} catch (_err) {
|
|
45
82
|
logger.debug('auth.json is malformed, skipping import');
|
|
46
83
|
return {};
|
|
@@ -89,11 +126,12 @@ function checkAuthJson(provider) {
|
|
|
89
126
|
*/
|
|
90
127
|
function removeFromAuthJson(provider) {
|
|
91
128
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
129
|
+
const authPath = resolveAuthJsonPath();
|
|
130
|
+
if (!fs.existsSync(authPath)) { return; }
|
|
131
|
+
const parsed = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
94
132
|
if (!parsed[provider]) { return; }
|
|
95
133
|
delete parsed[provider];
|
|
96
|
-
fs.writeFileSync(
|
|
134
|
+
fs.writeFileSync(authPath, JSON.stringify(parsed, null, 2), 'utf-8');
|
|
97
135
|
} catch (_err) {
|
|
98
136
|
logger.debug('Failed to remove provider from auth.json', { provider });
|
|
99
137
|
}
|
|
@@ -105,5 +143,7 @@ module.exports = {
|
|
|
105
143
|
checkAuthJson,
|
|
106
144
|
removeFromAuthJson,
|
|
107
145
|
AUTH_JSON_PATH,
|
|
108
|
-
KNOWN_PROVIDERS
|
|
146
|
+
KNOWN_PROVIDERS,
|
|
147
|
+
resolveAuthJsonPath,
|
|
148
|
+
authJsonCandidates
|
|
109
149
|
};
|