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
package/src/cli.js
CHANGED
|
@@ -94,6 +94,17 @@ function parseArgs(argv) {
|
|
|
94
94
|
} else {
|
|
95
95
|
result[key] = true;
|
|
96
96
|
}
|
|
97
|
+
} else if (arg === '-o') {
|
|
98
|
+
// Single short-flag alias, scoped to exactly '-o' (council verdict's
|
|
99
|
+
// --out shorthand). No general short-flag support is implemented —
|
|
100
|
+
// any other leading-dash token still falls through to positionals.
|
|
101
|
+
const next = argv[i + 1];
|
|
102
|
+
if (next && !next.startsWith('-')) {
|
|
103
|
+
result.out = next;
|
|
104
|
+
i++;
|
|
105
|
+
} else {
|
|
106
|
+
result.out = true;
|
|
107
|
+
}
|
|
97
108
|
} else {
|
|
98
109
|
result._.push(arg);
|
|
99
110
|
}
|
|
@@ -338,7 +349,10 @@ Commands:
|
|
|
338
349
|
council tally <input.json> [--json] Tally council findings → tiers/street-cred
|
|
339
350
|
council stats [--json] Reviewer-reliability from the ledger
|
|
340
351
|
council report <verdict.json> [--wave <wave.json>] [--md|--html] Disagreement+verdict report
|
|
352
|
+
council validate <file> [--json] Validate a Stage-1 findings block (exit 0/2/1)
|
|
353
|
+
council verdict <tally.json> [--decisions <d.json>] [-o <out.json>] Build + write verdict.json
|
|
341
354
|
doctor Check your setup: keys, catalog, binary, skills, MCP (--json)
|
|
355
|
+
spend [--since 7d] [--json] Cross-run cost rollup from the spend ledger
|
|
342
356
|
abort Abort a running session (or --all)
|
|
343
357
|
setup Configure default model and aliases
|
|
344
358
|
--api-keys Open API key setup window
|
|
@@ -428,6 +442,7 @@ Options for 'status':
|
|
|
428
442
|
abort: `
|
|
429
443
|
Options for 'abort':
|
|
430
444
|
--all Abort all running sessions in this project
|
|
445
|
+
--json Emit the abort result as stable JSON
|
|
431
446
|
`,
|
|
432
447
|
read: `
|
|
433
448
|
Options for 'read':
|
|
@@ -443,6 +458,7 @@ Options for 'continue':
|
|
|
443
458
|
--model <model> Optional. Override the model (alias or provider/model)
|
|
444
459
|
--cwd <path> Project directory (default: cwd)
|
|
445
460
|
--no-ui Run without GUI (autonomous mode)
|
|
461
|
+
--json With --no-ui: emit the run result as stable JSON
|
|
446
462
|
--timeout <minutes> Headless timeout (default: 15)
|
|
447
463
|
--context-turns <N> Max conversation turns (default: 50)
|
|
448
464
|
--context-max-tokens <N> Max context tokens (default: 80000)
|
|
@@ -452,6 +468,7 @@ Options for 'resume':
|
|
|
452
468
|
<task_id> Required. Session to reopen (positional)
|
|
453
469
|
--cwd <path> Project directory (default: cwd)
|
|
454
470
|
--no-ui Run without GUI (autonomous mode)
|
|
471
|
+
--json With --no-ui: emit the run result as stable JSON
|
|
455
472
|
--timeout <minutes> Headless timeout (default: 15)
|
|
456
473
|
`,
|
|
457
474
|
council: `
|
|
@@ -465,12 +482,33 @@ Subcommands for 'council':
|
|
|
465
482
|
--wave <wave.json> Include per-leg run stats from a wave file
|
|
466
483
|
--md Emit Markdown (default)
|
|
467
484
|
--html Emit a self-contained HTML page
|
|
485
|
+
validate <file> Validate a Stage-1 reviewer's findings block
|
|
486
|
+
--json Machine-readable output
|
|
487
|
+
Exit codes: 0 ok:true, 2 ok:false (validation failure), 1 BAD_ARGS
|
|
488
|
+
(missing/unreadable file)
|
|
489
|
+
verdict <tally.json> Build + write verdict.json (buildVerdict + atomic write)
|
|
490
|
+
--decisions <d.json> Optional. Stage-4 decisions array (default [])
|
|
491
|
+
-o, --out <out.json> Output path (default ./verdict.json)
|
|
492
|
+
--json Print the full verdict document
|
|
493
|
+
save <name> --models a,b,c Save a named council preset (>=2 resolvable members)
|
|
494
|
+
--json Machine-readable output
|
|
495
|
+
list List saved councils plus the built-in benches
|
|
496
|
+
--json Machine-readable output
|
|
497
|
+
show <name> Resolve a council by name (saved or built-in)
|
|
498
|
+
--json Machine-readable output
|
|
468
499
|
`,
|
|
469
500
|
doctor: `
|
|
470
501
|
Options for 'doctor':
|
|
471
502
|
--json Machine-readable output
|
|
472
503
|
--fix Self-heal fixable checks in place (provisions the
|
|
473
504
|
Electron GUI binary; no global reinstall)
|
|
505
|
+
`,
|
|
506
|
+
spend: `
|
|
507
|
+
Options for 'spend':
|
|
508
|
+
--since <Nd> Restrict to the last N days (e.g. --since 7d)
|
|
509
|
+
--json Machine-readable output (versioned spend doc)
|
|
510
|
+
Reads ~/.config/amicus/spend-ledger.jsonl (one row per completed run/leg).
|
|
511
|
+
Shows remaining OpenRouter credit when a key is configured.
|
|
474
512
|
`,
|
|
475
513
|
setup: `
|
|
476
514
|
Options for 'setup':
|
|
@@ -511,6 +549,22 @@ Examples:
|
|
|
511
549
|
amicus read abc123 --conversation
|
|
512
550
|
`;
|
|
513
551
|
|
|
552
|
+
// Commands handled directly in bin/amicus.js's switch that have no dedicated
|
|
553
|
+
// USAGE_COMMAND_BLOCKS entry (their usage is covered by USAGE_HEADER's command
|
|
554
|
+
// list only). Kept minimal and explicit rather than parsing the switch itself.
|
|
555
|
+
const SWITCH_ONLY_COMMANDS = ['update'];
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Canonical list of top-level command names, for did-you-mean suggestions and
|
|
559
|
+
* any other consumer that needs "every command amicus recognizes" without a
|
|
560
|
+
* second hand-maintained list. Derived from USAGE_COMMAND_BLOCKS (the existing
|
|
561
|
+
* per-command help source of truth) plus SWITCH_ONLY_COMMANDS.
|
|
562
|
+
* @returns {string[]}
|
|
563
|
+
*/
|
|
564
|
+
function getCommandNames() {
|
|
565
|
+
return [...Object.keys(USAGE_COMMAND_BLOCKS), ...SWITCH_ONLY_COMMANDS];
|
|
566
|
+
}
|
|
567
|
+
|
|
514
568
|
/**
|
|
515
569
|
* Get usage text.
|
|
516
570
|
*
|
|
@@ -535,5 +589,6 @@ module.exports = {
|
|
|
535
589
|
parseArgs,
|
|
536
590
|
validateStartArgs,
|
|
537
591
|
getUsage,
|
|
592
|
+
getCommandNames,
|
|
538
593
|
DEFAULTS
|
|
539
594
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/council/presets-cli.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `amicus council save|list|show` — CLI wrappers around the council-preset
|
|
6
|
+
* primitives (src/utils/config.js councils.*, src/utils/council-presets.js
|
|
7
|
+
* built-in benches). Split out of cli-handlers-council.js to keep that file
|
|
8
|
+
* under the 300-line size gate.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { failJson, ERROR_CODES } = require('../utils/error-doc');
|
|
12
|
+
const { listBuiltinCouncilNames } = require('../utils/council-presets');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `amicus council save <name> --models a,b,c`
|
|
16
|
+
* Validates >=2 members, each resolvable via the same alias/catalog logic
|
|
17
|
+
* `resolveCouncilMembers` uses (effective aliases, or a raw `provider/model`
|
|
18
|
+
* id containing '/'). Overwrites an existing name with a notice — this is
|
|
19
|
+
* also how a user shadows a built-in bench of the same name.
|
|
20
|
+
* @param {string|undefined} name
|
|
21
|
+
* @param {string|undefined} modelsArg comma-separated aliases/ids
|
|
22
|
+
* @param {boolean} useJson
|
|
23
|
+
* @returns {number} exit code
|
|
24
|
+
*/
|
|
25
|
+
function runSave(name, modelsArg, useJson) {
|
|
26
|
+
if (!name) {
|
|
27
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council save needs a <name>',
|
|
28
|
+
hint: 'amicus council save <name> --models a,b,c' });
|
|
29
|
+
}
|
|
30
|
+
if (typeof modelsArg !== 'string' || !modelsArg.trim()) {
|
|
31
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council save needs --models a,b,c',
|
|
32
|
+
hint: 'amicus council save <name> --models a,b,c' });
|
|
33
|
+
}
|
|
34
|
+
const members = modelsArg.split(',').map(m => m.trim()).filter(Boolean);
|
|
35
|
+
if (members.length < 2) {
|
|
36
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'a council needs at least 2 members',
|
|
37
|
+
hint: 'pass --models with 2 or more comma-separated aliases or provider/model IDs' });
|
|
38
|
+
}
|
|
39
|
+
const { getEffectiveAliases, loadConfig, saveConfig, getCouncil } = require('../utils/config');
|
|
40
|
+
const aliases = getEffectiveAliases();
|
|
41
|
+
const unresolved = members.filter(m => !m.includes('/') && !aliases[m]);
|
|
42
|
+
if (unresolved.length) {
|
|
43
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS,
|
|
44
|
+
message: `unresolvable member(s): ${unresolved.join(', ')}`,
|
|
45
|
+
hint: 'each member must be a known alias (see `amicus models`) or a provider/model id containing "/"' });
|
|
46
|
+
}
|
|
47
|
+
const overwritten = !!getCouncil(name);
|
|
48
|
+
const cfg = loadConfig() || {};
|
|
49
|
+
if (!cfg.councils) { cfg.councils = {}; }
|
|
50
|
+
cfg.councils[name] = members;
|
|
51
|
+
saveConfig(cfg);
|
|
52
|
+
const doc = { ok: true, name, models: members, overwritten };
|
|
53
|
+
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderSave(doc));
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderSave(doc) {
|
|
58
|
+
const notice = doc.overwritten ? ' (overwritten)' : '';
|
|
59
|
+
return `Saved council '${doc.name}'${notice}: ${doc.models.join(', ')}\n`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `amicus council list [--json]` — user-saved councils plus the built-in
|
|
64
|
+
* benches (free/budget/frontier), each entry marked `builtin`. When a user
|
|
65
|
+
* council shares a name with a built-in, BOTH entries are listed: the user
|
|
66
|
+
* entry (builtin:false) is the one actually used by resolveCouncilMembers,
|
|
67
|
+
* and the built-in entry (builtin:true) is marked `shadowed:true`.
|
|
68
|
+
* @param {boolean} useJson
|
|
69
|
+
* @returns {number} exit code
|
|
70
|
+
*/
|
|
71
|
+
function runList(useJson) {
|
|
72
|
+
const { getCouncils } = require('../utils/config');
|
|
73
|
+
const userCouncils = getCouncils();
|
|
74
|
+
const userNames = new Set(Object.keys(userCouncils));
|
|
75
|
+
const entries = [];
|
|
76
|
+
for (const name of Object.keys(userCouncils).sort()) {
|
|
77
|
+
entries.push({ name, builtin: false, members: userCouncils[name] });
|
|
78
|
+
}
|
|
79
|
+
for (const name of listBuiltinCouncilNames()) {
|
|
80
|
+
const entry = { name, builtin: true };
|
|
81
|
+
if (userNames.has(name)) { entry.shadowed = true; }
|
|
82
|
+
entries.push(entry);
|
|
83
|
+
}
|
|
84
|
+
const doc = { councils: entries };
|
|
85
|
+
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderList(entries));
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderList(entries) {
|
|
90
|
+
const lines = entries.map(e => {
|
|
91
|
+
if (e.builtin) { return ` ${e.name.padEnd(16)} [built-in]${e.shadowed ? ' (shadowed by a saved council of the same name)' : ''}`; }
|
|
92
|
+
return ` ${e.name.padEnd(16)} ${e.members.join(', ')}`;
|
|
93
|
+
});
|
|
94
|
+
return 'Councils:\n' + lines.join('\n') + '\n';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `amicus council show <name> [--json]` — resolves `name` exactly like
|
|
99
|
+
* `resolveCouncilMembers` (user config first, built-in fallback) and
|
|
100
|
+
* displays the raw members plus per-member resolution results (resolved /
|
|
101
|
+
* dropped). Unlike `resolveCouncilMembers` (which the run paths use, and
|
|
102
|
+
* which fails outright below 2 usable members), `show` is diagnostic-only:
|
|
103
|
+
* it always reports the full resolved/dropped split, even for a council
|
|
104
|
+
* that currently has fewer than 2 usable members.
|
|
105
|
+
* @param {string|undefined} name
|
|
106
|
+
* @param {boolean} useJson
|
|
107
|
+
* @returns {number} exit code
|
|
108
|
+
*/
|
|
109
|
+
function runShow(name, useJson) {
|
|
110
|
+
if (!name) {
|
|
111
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'council show needs a <name>',
|
|
112
|
+
hint: 'amicus council show <name> [--json]' });
|
|
113
|
+
}
|
|
114
|
+
const { getCouncilWithSource, getEffectiveAliases } = require('../utils/config');
|
|
115
|
+
const { readCache } = require('../utils/model-catalog');
|
|
116
|
+
const catalog = (readCache() || {}).models || [];
|
|
117
|
+
const { members, builtin } = getCouncilWithSource(name, catalog);
|
|
118
|
+
if (!members) {
|
|
119
|
+
return failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: `Unknown council '${name}'`,
|
|
120
|
+
hint: "'amicus council list' shows available councils, or 'amicus council save' to create one" });
|
|
121
|
+
}
|
|
122
|
+
const aliases = getEffectiveAliases();
|
|
123
|
+
const resolved = [];
|
|
124
|
+
const dropped = [];
|
|
125
|
+
for (const member of members) {
|
|
126
|
+
const id = member.includes('/') ? member : aliases[member];
|
|
127
|
+
(id ? resolved : dropped).push(member);
|
|
128
|
+
}
|
|
129
|
+
const doc = { name, builtin, members, resolved, dropped };
|
|
130
|
+
process.stdout.write(useJson ? JSON.stringify(doc, null, 2) + '\n' : renderShow(doc));
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderShow(doc) {
|
|
135
|
+
const tag = doc.builtin ? ' [built-in]' : '';
|
|
136
|
+
let out = `Council '${doc.name}'${tag}\n members: ${doc.members.join(', ')}\n resolved: ${doc.resolved.join(', ')}\n`;
|
|
137
|
+
if (doc.dropped.length) { out += ` dropped: ${doc.dropped.join(', ')}\n`; }
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { runSave, runList, runShow };
|
package/src/headless.js
CHANGED
|
@@ -12,33 +12,51 @@ const { ensureNodeModulesBinInPath } = require('./utils/path-setup');
|
|
|
12
12
|
const { ensurePortAvailable } = require('./utils/server-setup');
|
|
13
13
|
const { mapAgentToOpenCode } = require('./utils/agent-mapping');
|
|
14
14
|
const { writeProgress } = require('./sidecar/progress');
|
|
15
|
-
const {
|
|
15
|
+
const { writeFileAtomic } = require('./utils/atomic-write');
|
|
16
|
+
const { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls } = require('./sidecar/conversation-mirror');
|
|
17
|
+
const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require('./utils/fold-marker');
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
|
-
* Fold marker that the agent outputs when done
|
|
20
|
+
* Fold marker that the agent outputs when done.
|
|
19
21
|
* Spec Reference: §6.2
|
|
22
|
+
*
|
|
23
|
+
* #BL-7 residual (15b.3): the bare `[SIDECAR_FOLD]` string is now a LEGACY
|
|
24
|
+
* literal only, kept exported for external consumers with no nonce context
|
|
25
|
+
* (see extractSummary/formatFoldOutput's no-nonce fallback paths below).
|
|
26
|
+
* NOTE for anyone `.toContain('[SIDECAR_FOLD]')`-checking real run output:
|
|
27
|
+
* that substring check does NOT match the real nonced marker — a nonced
|
|
28
|
+
* marker is `[SIDECAR_FOLD:<nonce>]`, which lacks the literal closing
|
|
29
|
+
* bracket immediately after FOLD that `[SIDECAR_FOLD]` requires. Real runs
|
|
30
|
+
* always call buildFoldMarker(nonce), never this bare constant.
|
|
20
31
|
*/
|
|
21
32
|
const FOLD_MARKER = '[SIDECAR_FOLD]';
|
|
22
33
|
const COMPLETE_MARKER = FOLD_MARKER; // backward compat
|
|
23
34
|
|
|
24
35
|
/**
|
|
25
|
-
* #BL-7: the fold marker
|
|
26
|
-
* legitimately emit
|
|
27
|
-
* reproducing these instructions, or from
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
36
|
+
* #BL-7: the fold marker used to be the fixed public string [SIDECAR_FOLD]. A
|
|
37
|
+
* model can legitimately emit that bare string on its own line mid-output —
|
|
38
|
+
* summarizing a prior sidecar, reproducing these instructions, or from
|
|
39
|
+
* scraped content — which forced a PREMATURE fold even after pinning the
|
|
40
|
+
* marker to the final non-empty line (the marker being fixed and public means
|
|
41
|
+
* ANY echo of it, if it happened to land last, still completed the run).
|
|
42
|
+
*
|
|
43
|
+
* 15b.3 closes the residual gap: every run now carries a per-run random
|
|
44
|
+
* nonce, and the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` — a
|
|
45
|
+
* string the model can only produce by actually finishing (it isn't public,
|
|
46
|
+
* isn't in training data, and isn't guessable). A bare `[SIDECAR_FOLD]` or a
|
|
47
|
+
* marker carrying a DIFFERENT run's nonce no longer completes.
|
|
31
48
|
*
|
|
32
49
|
* @param {string} output - Accumulated assistant output
|
|
50
|
+
* @param {string} nonce - This run's fold nonce (required — see runHeadless)
|
|
33
51
|
* @returns {number} char index where the trailing marker line begins, or -1
|
|
34
52
|
*/
|
|
35
|
-
function findTrailingFoldMarker(output) {
|
|
36
|
-
if (!output) { return -1; }
|
|
53
|
+
function findTrailingFoldMarker(output, nonce) {
|
|
54
|
+
if (!output || !nonce) { return -1; }
|
|
37
55
|
// The marker must be the last non-empty line: it sits alone on its line
|
|
38
56
|
// (only intra-line whitespace around it) and NOTHING but whitespace follows
|
|
39
57
|
// to the end of the string. The `(?![\s\S]*\S)` lookahead pins it to the true
|
|
40
|
-
// end — a
|
|
41
|
-
const m =
|
|
58
|
+
// end — a marker followed by more prose is echoed content, not a signal.
|
|
59
|
+
const m = trailingFoldMarkerRegex(nonce).exec(output);
|
|
42
60
|
return m ? m.index : -1;
|
|
43
61
|
}
|
|
44
62
|
|
|
@@ -53,6 +71,7 @@ const STABLE_FINISHED_POLLS = Number(process.env.AMICUS_STABLE_FINISHED_POLLS) |
|
|
|
53
71
|
const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30; // ~60s at 2s — no completion signal
|
|
54
72
|
const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
|
|
55
73
|
const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
|
|
74
|
+
const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 180000; // B53: wedged tool call w/ no progress
|
|
56
75
|
|
|
57
76
|
/**
|
|
58
77
|
* Race a promise against a timeout. Returns the promise's result, or rejects with
|
|
@@ -103,6 +122,15 @@ async function waitForServer(client, checkHealthFn, maxAttempts = 30) {
|
|
|
103
122
|
* @param {string} [options.summaryLength='normal'] - Desired summary length
|
|
104
123
|
* @param {object} [options.reasoning] - Reasoning/thinking configuration
|
|
105
124
|
* @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
|
|
125
|
+
* @param {string} [options.nonce] - Per-run fold nonce (15b.3, #BL-7 residual). The
|
|
126
|
+
* PROMPT the caller built (prompt-builder.js buildPrompts) must have instructed the
|
|
127
|
+
* model with this SAME nonce — runHeadless only DETECTS, it never re-derives one from
|
|
128
|
+
* the prompt text, so caller and detector agreeing on the nonce is the caller's
|
|
129
|
+
* responsibility. Falls back to a freshly generated nonce if omitted (keeps this
|
|
130
|
+
* function usable standalone / in tests that don't care about the fold-nonce
|
|
131
|
+
* property) — but a fallback nonce the prompt never advertised means the model can
|
|
132
|
+
* never legitimately produce it, so such a run can only ever complete via one of the
|
|
133
|
+
* non-fold-marker paths (idle/timeout/etc.), never a premature bare-marker fold.
|
|
106
134
|
* @returns {Promise<object>} Result object with summary, completed, timedOut flags
|
|
107
135
|
*/
|
|
108
136
|
async function runHeadless(model, systemPrompt, userMessage, taskId, project, timeoutMs = DEFAULT_TIMEOUT, agent, options = {}) {
|
|
@@ -116,6 +144,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
116
144
|
} = require('./opencode-client');
|
|
117
145
|
|
|
118
146
|
const { reasoning } = options;
|
|
147
|
+
// 15b.3: never fall back to bare-marker detection — an omitted nonce still
|
|
148
|
+
// gets ONE generated here so findTrailingFoldMarker always has something to
|
|
149
|
+
// match, but since the prompt (built by the caller) never advertised THIS
|
|
150
|
+
// fallback value, the model cannot legitimately produce it. No silent
|
|
151
|
+
// bare-`[SIDECAR_FOLD]` acceptance path exists anywhere below.
|
|
152
|
+
const foldNonce = options.nonce || generateFoldNonce();
|
|
119
153
|
const { getSessionDir } = require('./session-manager');
|
|
120
154
|
const sessionDir = getSessionDir(project, taskId);
|
|
121
155
|
const conversationPath = path.join(sessionDir, 'conversation.jsonl');
|
|
@@ -202,7 +236,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
202
236
|
writeProgress(sessionDir, 'server_ready');
|
|
203
237
|
|
|
204
238
|
if (!serverReady) {
|
|
205
|
-
server.close();
|
|
239
|
+
await server.close();
|
|
206
240
|
return {
|
|
207
241
|
summary: '',
|
|
208
242
|
completed: false,
|
|
@@ -239,7 +273,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
239
273
|
sessionId = await createSession(client, ...dirArgs);
|
|
240
274
|
} catch (error) {
|
|
241
275
|
if (watchdog) { watchdog.cancel(); }
|
|
242
|
-
if (!externalServer) { server.close(); }
|
|
276
|
+
if (!externalServer) { await server.close(); }
|
|
243
277
|
return {
|
|
244
278
|
summary: '',
|
|
245
279
|
completed: false,
|
|
@@ -261,7 +295,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
261
295
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
262
296
|
const m = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
263
297
|
m.goPid = server.goPid;
|
|
264
|
-
|
|
298
|
+
writeFileAtomic(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
|
|
265
299
|
} catch { /* metadata optional */ }
|
|
266
300
|
}
|
|
267
301
|
const { installSignalAbort, markAborted } = require('./utils/session-abort');
|
|
@@ -276,7 +310,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
276
310
|
const { abortSession } = require('./opencode-client');
|
|
277
311
|
abortSession(client, sessionId, ...dirArgs).catch(() => {});
|
|
278
312
|
} catch { /* best-effort */ }
|
|
279
|
-
|
|
313
|
+
// close() is now async (B06 escalation) — this handler stays sync
|
|
314
|
+
// (do not restructure signal handlers), so fire-and-forget with a
|
|
315
|
+
// rejection guard. The REF'd escalation poll inside close() still
|
|
316
|
+
// does its work; the pre-existing 300ms exit timer below may cut
|
|
317
|
+
// that grace short — see task 15b.1 report for that known gap.
|
|
318
|
+
try { server.close().catch(() => {}); } catch { /* best-effort */ }
|
|
280
319
|
const { resolveTerminalState } = require('./sidecar/session-finalize');
|
|
281
320
|
const code = resolveTerminalState({ aborted: true }, signal).exitCode;
|
|
282
321
|
const t = setTimeout(() => process.exit(code), 300);
|
|
@@ -356,6 +395,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
356
395
|
const stableIdlePolls = options.stableIdlePolls || STABLE_IDLE_POLLS;
|
|
357
396
|
const pollCallTimeoutMs = options.pollCallTimeoutMs || POLL_CALL_TIMEOUT_MS;
|
|
358
397
|
const maxConsecutivePollFailures = options.maxConsecutivePollFailures || MAX_CONSECUTIVE_POLL_FAILURES;
|
|
398
|
+
const toolCallStallMs = options.toolCallStallMs || TOOL_CALL_STALL_MS;
|
|
359
399
|
let consecutivePollFailures = 0;
|
|
360
400
|
let pollFailureBail = false;
|
|
361
401
|
let lastAssistantMsgId = null;
|
|
@@ -364,6 +404,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
364
404
|
let lastToolCallCount = 0;
|
|
365
405
|
let lastToolResultCount = 0;
|
|
366
406
|
let lastMessageCount = 0;
|
|
407
|
+
let lastReasoningLength = 0; // B53: track reasoning-output growth to detect thinking
|
|
408
|
+
let lastProgressAt = Date.now(); // B53: last poll where `progressed` was true
|
|
409
|
+
let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
|
|
367
410
|
|
|
368
411
|
while (!completed && (Date.now() - startTime) < timeoutMs) {
|
|
369
412
|
watchdog.touch();
|
|
@@ -420,11 +463,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
420
463
|
elapsed: Date.now() - startTime
|
|
421
464
|
});
|
|
422
465
|
|
|
423
|
-
// Check for the completion marker as the FINAL non-empty line
|
|
424
|
-
// Models may emit
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
466
|
+
// Check for the completion marker as the FINAL non-empty line, carrying
|
|
467
|
+
// THIS run's nonce (#BL-7 + 15b.3). Models may emit a bare or wrong-nonce
|
|
468
|
+
// marker on its own line mid-output (echoing a prior sidecar, these
|
|
469
|
+
// instructions, or scraped content) — only the exact nonced marker,
|
|
470
|
+
// with nothing but blank lines after it, is a completion signal.
|
|
471
|
+
if (findTrailingFoldMarker(mirror.output, foldNonce) !== -1) {
|
|
428
472
|
completed = true;
|
|
429
473
|
break;
|
|
430
474
|
}
|
|
@@ -472,8 +516,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
472
516
|
}
|
|
473
517
|
|
|
474
518
|
// Activity-aware idle detection: ANY of text growth, a new tool call, a new
|
|
475
|
-
// tool result, a new message,
|
|
476
|
-
// Only count toward completion when NOTHING changed
|
|
519
|
+
// tool result, a new message, a new assistant message id, or reasoning-output
|
|
520
|
+
// growth counts as progress. Only count toward completion when NOTHING changed
|
|
521
|
+
// (genuine idle).
|
|
477
522
|
const outputGrew = mirror.output.length > lastOutputLength;
|
|
478
523
|
lastOutputLength = mirror.output.length;
|
|
479
524
|
const toolActivity = mirror.toolCalls.length > lastToolCallCount;
|
|
@@ -483,8 +528,42 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
483
528
|
const messageActivity = messageCount > lastMessageCount;
|
|
484
529
|
lastMessageCount = messageCount;
|
|
485
530
|
const newAssistant = currentAssistantMsgId !== lastAssistantMsgId;
|
|
486
|
-
|
|
487
|
-
|
|
531
|
+
// B53: an interleaved-thinking model with a pending tool call can stream ONLY
|
|
532
|
+
// reasoning deltas for minutes with no text/tool/result/message growth — mirror
|
|
533
|
+
// the F6d treatment in conversation-mirror.js (reasoning growth = activity) so
|
|
534
|
+
// the stall clock resets instead of falsely firing "Tool call stalled".
|
|
535
|
+
const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
|
|
536
|
+
lastReasoningLength = mirror.reasoningOutput.length;
|
|
537
|
+
|
|
538
|
+
const progressed = outputGrew || toolActivity || resultActivity || messageActivity
|
|
539
|
+
|| newAssistant || reasoningActivity;
|
|
540
|
+
if (progressed) { lastProgressAt = Date.now(); }
|
|
541
|
+
|
|
542
|
+
// B53: a wedged tool call (tool_use emitted, result never arrives) otherwise
|
|
543
|
+
// burns the full --timeout with zero output — the stable-poll idle gate above
|
|
544
|
+
// requires mirror.output.length > 0, which a pre-text wedge never satisfies.
|
|
545
|
+
// Fire ONLY when a tool call is genuinely pending AND no progress of any kind
|
|
546
|
+
// (text/tool/result/message/new-assistant) has been observed for the stall
|
|
547
|
+
// window — this cannot false-positive during active streaming (progress
|
|
548
|
+
// resets the clock every poll) and cannot fire without a wedged tool.
|
|
549
|
+
const pendingToolCalls = getPendingToolCalls(mirror);
|
|
550
|
+
if (pendingToolCalls.length > 0 && (Date.now() - lastProgressAt) > toolCallStallMs) {
|
|
551
|
+
const stalled = pendingToolCalls[0];
|
|
552
|
+
const pendingSeconds = Math.round((Date.now() - Date.parse(stalled.firstSeenAt)) / 1000);
|
|
553
|
+
sessionError = `Tool call stalled: ${stalled.name} pending ${pendingSeconds}s with no result or output`;
|
|
554
|
+
logger.error('Tool call stalled — no progress within threshold', {
|
|
555
|
+
taskId, toolName: stalled.name, toolId: stalled.id, pendingSeconds, toolCallStallMs
|
|
556
|
+
});
|
|
557
|
+
toolStalled = true;
|
|
558
|
+
try {
|
|
559
|
+
const { abortSession } = require('./opencode-client');
|
|
560
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
561
|
+
logger.info('Session aborted after tool-call stall', { taskId, sessionId });
|
|
562
|
+
} catch (abortErr) {
|
|
563
|
+
logger.warn('Failed to abort session after tool-call stall', { error: abortErr.message });
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
488
567
|
|
|
489
568
|
if (!progressed) {
|
|
490
569
|
// Require real output before counting toward completion — the SDK creates an
|
|
@@ -556,7 +635,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
556
635
|
|
|
557
636
|
watchdog.cancel();
|
|
558
637
|
if (uninstallSignals) { uninstallSignals(); }
|
|
559
|
-
if (!externalServer) { server.close(); }
|
|
638
|
+
if (!externalServer) { await server.close(); }
|
|
560
639
|
|
|
561
640
|
// Log summary of tool calls for debugging
|
|
562
641
|
if (mirror.toolCalls.length > 0) {
|
|
@@ -572,13 +651,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
572
651
|
// Propagate the error when the model errored with no output (F1 semantics:
|
|
573
652
|
// a model error alongside streamed output still yields a usable summary),
|
|
574
653
|
// and ALWAYS when the poll loop bailed on consecutive failures (F4: a dead
|
|
575
|
-
// server must never classify as a complete leg, even with partial output)
|
|
654
|
+
// server must never classify as a complete leg, even with partial output)
|
|
655
|
+
// or on a tool-call stall (B53: same — a wedged tool must never classify
|
|
656
|
+
// as complete, even if some text streamed alongside it before the wedge).
|
|
576
657
|
const { sumPerMessageUsage } = require('./utils/pricing');
|
|
577
658
|
const usage = sumPerMessageUsage(mirror.usageByMsg);
|
|
578
659
|
|
|
579
|
-
if (sessionError && (!mirror.output || pollFailureBail)) {
|
|
660
|
+
if (sessionError && (!mirror.output || pollFailureBail || toolStalled)) {
|
|
580
661
|
return {
|
|
581
|
-
summary: mirror.output ? extractSummary(mirror.output) : '',
|
|
662
|
+
summary: mirror.output ? extractSummary(mirror.output, foldNonce) : '',
|
|
582
663
|
completed: false,
|
|
583
664
|
timedOut,
|
|
584
665
|
aborted,
|
|
@@ -590,7 +671,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
590
671
|
}
|
|
591
672
|
|
|
592
673
|
return {
|
|
593
|
-
summary: extractSummary(mirror.output),
|
|
674
|
+
summary: extractSummary(mirror.output, foldNonce),
|
|
594
675
|
completed,
|
|
595
676
|
timedOut,
|
|
596
677
|
aborted,
|
|
@@ -617,7 +698,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
617
698
|
}
|
|
618
699
|
if (watchdog) { watchdog.cancel(); }
|
|
619
700
|
if (uninstallSignals) { uninstallSignals(); }
|
|
620
|
-
if (!externalServer) { server.close(); }
|
|
701
|
+
if (!externalServer) { await server.close(); }
|
|
621
702
|
const { emptyUsageTotals } = require('./utils/pricing');
|
|
622
703
|
return {
|
|
623
704
|
summary: '',
|
|
@@ -632,28 +713,47 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
632
713
|
}
|
|
633
714
|
|
|
634
715
|
/**
|
|
635
|
-
* Extract summary from output (everything before the trailing
|
|
636
|
-
* Spec Reference: §6.2 - Return summary (everything before
|
|
716
|
+
* Extract summary from output (everything before the trailing fold marker)
|
|
717
|
+
* Spec Reference: §6.2 - Return summary (everything before the fold marker)
|
|
637
718
|
*
|
|
638
719
|
* @param {string} output - Raw output from OpenCode
|
|
720
|
+
* @param {string} [nonce] - This run's fold nonce (15b.3). When omitted, falls
|
|
721
|
+
* back to matching the LEGACY bare `[SIDECAR_FOLD]` marker — this keeps
|
|
722
|
+
* extractSummary usable as a standalone string utility (e.g. re-processing
|
|
723
|
+
* output captured before the nonce scheme, or a caller that genuinely has
|
|
724
|
+
* no nonce context) without ever accepting a WRONG nonce as a match.
|
|
639
725
|
* @returns {string} Extracted summary
|
|
640
726
|
*/
|
|
641
|
-
function extractSummary(output) {
|
|
727
|
+
function extractSummary(output, nonce) {
|
|
642
728
|
if (!output) {
|
|
643
729
|
return '';
|
|
644
730
|
}
|
|
645
731
|
|
|
646
732
|
// Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
|
|
647
|
-
// A
|
|
733
|
+
// A marker echoed mid-output (describing code, reproducing these
|
|
648
734
|
// instructions, or from scraped content) is NOT a delimiter — keep it as
|
|
649
735
|
// content. Only the true trailing marker is stripped.
|
|
650
|
-
const idx = findTrailingFoldMarker(output);
|
|
736
|
+
const idx = nonce ? findTrailingFoldMarker(output, nonce) : findLegacyBareTrailingMarker(output);
|
|
651
737
|
if (idx !== -1) {
|
|
652
738
|
return output.slice(0, idx).trim();
|
|
653
739
|
}
|
|
654
740
|
return output.trim();
|
|
655
741
|
}
|
|
656
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Legacy bare-marker trailing match (`[SIDECAR_FOLD]`, no nonce) — the
|
|
745
|
+
* pre-15b.3 behavior, kept only for extractSummary's no-nonce fallback path.
|
|
746
|
+
* NEVER used by runHeadless's own detection (that always carries a nonce —
|
|
747
|
+
* see findTrailingFoldMarker), so no live completion path can be forced by a
|
|
748
|
+
* bare marker.
|
|
749
|
+
* @param {string} output
|
|
750
|
+
* @returns {number}
|
|
751
|
+
*/
|
|
752
|
+
function findLegacyBareTrailingMarker(output) {
|
|
753
|
+
const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
|
|
754
|
+
return m ? m.index : -1;
|
|
755
|
+
}
|
|
756
|
+
|
|
657
757
|
/**
|
|
658
758
|
* Format a structured fold output with metadata
|
|
659
759
|
* @param {Object} options - Fold output options
|
|
@@ -663,11 +763,14 @@ function extractSummary(output) {
|
|
|
663
763
|
* @param {string} [options.cwd] - Working directory (defaults to process.cwd())
|
|
664
764
|
* @param {string} [options.mode='headless'] - Execution mode
|
|
665
765
|
* @param {string} options.summary - Summary text
|
|
766
|
+
* @param {string} [options.nonce] - This run's fold nonce (15b.3). When omitted,
|
|
767
|
+
* falls back to the legacy bare `[SIDECAR_FOLD]` marker for back-compat with
|
|
768
|
+
* external callers of this exported utility that predate the nonce scheme.
|
|
666
769
|
* @returns {string} Formatted fold output
|
|
667
770
|
*/
|
|
668
|
-
function formatFoldOutput({ model, sessionId, client, cwd, mode, summary }) {
|
|
771
|
+
function formatFoldOutput({ model, sessionId, client, cwd, mode, summary, nonce }) {
|
|
669
772
|
return [
|
|
670
|
-
|
|
773
|
+
nonce ? buildFoldMarker(nonce) : FOLD_MARKER,
|
|
671
774
|
`Model: ${model}`,
|
|
672
775
|
`Session: ${sessionId}`,
|
|
673
776
|
`Client: ${client || 'code-local'}`,
|
|
@@ -688,9 +791,14 @@ module.exports = {
|
|
|
688
791
|
DEFAULT_TIMEOUT,
|
|
689
792
|
FOLD_MARKER,
|
|
690
793
|
COMPLETE_MARKER,
|
|
794
|
+
// 15b.3: re-exported so callers that already `require('./headless')` don't
|
|
795
|
+
// also need `require('./utils/fold-marker')` for the common case.
|
|
796
|
+
buildFoldMarker,
|
|
797
|
+
generateFoldNonce,
|
|
691
798
|
POLL_INTERVAL_MS,
|
|
692
799
|
STABLE_FINISHED_POLLS,
|
|
693
800
|
STABLE_IDLE_POLLS,
|
|
694
801
|
POLL_CALL_TIMEOUT_MS,
|
|
695
802
|
MAX_CONSECUTIVE_POLL_FAILURES,
|
|
803
|
+
TOOL_CALL_STALL_MS,
|
|
696
804
|
};
|
package/src/index.js
CHANGED
|
@@ -30,21 +30,13 @@ const { detectEnvironment, inferClient, getSessionRoot } = require('./environmen
|
|
|
30
30
|
const { compressContext, estimateTokenCount, buildPreamble } = require('./context-compression');
|
|
31
31
|
|
|
32
32
|
module.exports = {
|
|
33
|
-
// Canonical Amicus public API
|
|
33
|
+
// Canonical Amicus public API
|
|
34
34
|
startAmicus: startSidecar,
|
|
35
35
|
listAmicus: listSidecars,
|
|
36
36
|
resumeAmicus: resumeSidecar,
|
|
37
37
|
continueAmicus: continueSidecar,
|
|
38
38
|
readAmicus: readSidecar,
|
|
39
39
|
runFanout,
|
|
40
|
-
|
|
41
|
-
// DEPRECATED(amicus-shim): remove *Sidecar exports in a future revision — see docs/SHIMS.md
|
|
42
|
-
// Primary sidecar APIs
|
|
43
|
-
startSidecar,
|
|
44
|
-
listSidecars,
|
|
45
|
-
resumeSidecar,
|
|
46
|
-
continueSidecar,
|
|
47
|
-
readSidecar,
|
|
48
40
|
generateTaskId,
|
|
49
41
|
|
|
50
42
|
// Context building
|