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.
Files changed (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +200 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +19 -107
  5. package/commands/council.md +7 -3
  6. package/electron/fold.js +10 -1
  7. package/electron/ipc-setup.js +10 -15
  8. package/electron/main.js +21 -16
  9. package/electron/preload-setup.js +0 -1
  10. package/electron/setup-ui-council.js +64 -10
  11. package/electron/setup-ui-styles.js +34 -3
  12. package/electron/setup-ui.js +44 -12
  13. package/package.json +2 -5
  14. package/skills/second-opinion/MODEL-NOTES.md +2 -2
  15. package/skills/second-opinion/SKILL.md +30 -28
  16. package/skills/sidecar/SKILL.md +20 -17
  17. package/src/cli-handlers-abort.js +244 -0
  18. package/src/cli-handlers-council.js +101 -1
  19. package/src/cli-handlers-doctor.js +20 -53
  20. package/src/cli-handlers-resume-continue.js +103 -0
  21. package/src/cli-handlers-run.js +9 -8
  22. package/src/cli-handlers-spend.js +198 -0
  23. package/src/cli-handlers.js +5 -120
  24. package/src/cli.js +55 -0
  25. package/src/council/presets-cli.js +141 -0
  26. package/src/headless.js +146 -38
  27. package/src/index.js +1 -9
  28. package/src/mcp-server.js +140 -113
  29. package/src/mcp-tools.js +58 -24
  30. package/src/mcp-wait.js +8 -5
  31. package/src/opencode-client.js +33 -10
  32. package/src/prompt-builder.js +32 -11
  33. package/src/session-manager.js +7 -14
  34. package/src/sidecar/continue.js +34 -12
  35. package/src/sidecar/conversation-mirror.js +22 -1
  36. package/src/sidecar/crash-handler.js +2 -1
  37. package/src/sidecar/fanout-leg.js +12 -3
  38. package/src/sidecar/fanout.js +27 -10
  39. package/src/sidecar/interactive-process.js +6 -17
  40. package/src/sidecar/interactive.js +5 -6
  41. package/src/sidecar/models.js +33 -4
  42. package/src/sidecar/progress.js +2 -1
  43. package/src/sidecar/read.js +4 -6
  44. package/src/sidecar/resume.js +41 -11
  45. package/src/sidecar/session-finalize.js +2 -1
  46. package/src/sidecar/session-utils.js +13 -35
  47. package/src/sidecar/setup-window.js +2 -3
  48. package/src/sidecar/start.js +22 -7
  49. package/src/utils/abort-coordinator.js +57 -7
  50. package/src/utils/abort-result.js +36 -0
  51. package/src/utils/api-key-store.js +2 -13
  52. package/src/utils/cli-preflight.js +43 -0
  53. package/src/utils/config.js +30 -43
  54. package/src/utils/council-presets.js +87 -0
  55. package/src/utils/doctor-mcp-checks.js +84 -0
  56. package/src/utils/env-loader.js +1 -2
  57. package/src/utils/fold-marker.js +79 -0
  58. package/src/utils/idle-watchdog.js +9 -12
  59. package/src/utils/input-validators.js +52 -1
  60. package/src/utils/lifecycle.js +1 -1
  61. package/src/utils/mcp-discovery.js +80 -19
  62. package/src/utils/mcp-self-identity.js +12 -5
  63. package/src/utils/model-catalog.js +54 -6
  64. package/src/utils/read-slice.js +73 -0
  65. package/src/utils/remediation-hints.js +9 -0
  66. package/src/utils/result-schema-version.js +14 -0
  67. package/src/utils/result-schema.js +18 -12
  68. package/src/utils/session-abort.js +1 -1
  69. package/src/utils/session-index-tmp-sweep.js +80 -0
  70. package/src/utils/session-index.js +4 -5
  71. package/src/utils/session-path.js +6 -10
  72. package/src/utils/shared-server.js +7 -5
  73. package/src/utils/spend-ledger.js +80 -0
  74. package/src/utils/updater.js +2 -3
  75. 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 { createMirrorState, mirrorMessages, logMessage } = require('./sidecar/conversation-mirror');
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 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.
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 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);
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
- fs.writeFileSync(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
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
- try { server.close(); } catch { /* best-effort */ }
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 (#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) {
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, or a new assistant message id counts as progress.
476
- // Only count toward completion when NOTHING changed (genuine idle).
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
- const progressed = outputGrew || toolActivity || resultActivity || messageActivity || newAssistant;
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 [SIDECAR_FOLD])
636
- * Spec Reference: §6.2 - Return summary (everything before [SIDECAR_FOLD])
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 [SIDECAR_FOLD] echoed mid-output (describing code, reproducing these
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
- '[SIDECAR_FOLD]',
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 (the *Sidecar names below are deprecated shims)
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