amicus 4.7.0 → 4.7.1
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 +66 -0
- package/README.md +1 -1
- package/docs/configuration.md +1 -1
- package/docs/council.md +2 -2
- package/docs/testing.md +1 -1
- package/docs/troubleshooting.md +6 -4
- package/docs/usage.md +14 -10
- package/package.json +4 -3
- package/scripts/postinstall.js +6 -3
- package/scripts/setup-hooks.js +49 -3
- package/skills/sidecar/SKILL.md +3 -1
- package/src/cli-handlers-resume-continue.js +20 -0
- package/src/council/run-launch.js +12 -1
- package/src/council/run-retry.js +13 -1
- package/src/headless.js +69 -6
- package/src/sidecar/continue.js +8 -23
- package/src/sidecar/fanout-retry.js +15 -3
- package/src/sidecar/reopen-spend.js +32 -0
- package/src/sidecar/resume.js +1 -1
- package/src/utils/doctor-engine-check.js +32 -0
- package/src/utils/engine-install-scan.js +98 -15
- package/src/utils/engine-repair.js +96 -2
- package/src/utils/remediation-hints.js +29 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.1",
|
|
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": {
|
|
6
6
|
"name": "Christian Wagner"
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,72 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [4.7.1] - 2026-08-09
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **`continue`, `resume`, and `--retry-failed` now inherit the parent session's/wave's tag,
|
|
13
|
+
instead of dropping it.** `amicus list` now shows a TAG for continuations (they no longer group
|
|
14
|
+
under `(unattributed)` for `--group-by tag`), and `--tag` is now **rejected** on `continue` and
|
|
15
|
+
`resume` — the tag can only come from the parent, it cannot be overridden. This deletes a
|
|
16
|
+
documented limitation, not a bug: `docs/usage.md` carried a paragraph headed "Known limitation: a
|
|
17
|
+
tag is not inherited", and v4.7.0's own release notes called the gap "future work, not
|
|
18
|
+
oversights." That limitation is now gone.
|
|
19
|
+
- **`opencode-ai` and `@opencode-ai/sdk` are pinned to exactly `1.18.15`** (previously `^1.2.20` /
|
|
20
|
+
`^1.1.36`). Dev and CI now run on the same engine version end users resolve, so this is the
|
|
21
|
+
first release whose test suite actually ran against the engine users get. Honest scope: the pin
|
|
22
|
+
makes the *resolved* engine a pure function of the amicus version — it does nothing for a user
|
|
23
|
+
whose installed amicus version hasn't moved, and it does not force any existing npx cache to
|
|
24
|
+
re-resolve (that mechanism was investigated for #133 and found not to exist).
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **The no-output backstop message now reports what it observed, not what it guessed.** The old
|
|
29
|
+
three-line message asserted "likely a listed-but-not-serving model or a dead endpoint" with no
|
|
30
|
+
evidence behind it; it now names only what the deadline mechanism actually saw, and names
|
|
31
|
+
`AMICUS_NO_OUTPUT_BACKSTOP_MS` as the live governing window only when it is one, calling it out
|
|
32
|
+
as an overridden default otherwise.
|
|
33
|
+
- **The no-output backstop window now doubles on a Stage-1 retry, clamped to the leg timeout, so
|
|
34
|
+
retries can now heal a class of no-output failure that previously required a manual rerun.**
|
|
35
|
+
This is not a fix for the backstop class itself — only Stage-1 bench/critic/lens units retry;
|
|
36
|
+
judges, chair, chair-repair, and debate legs do not, and can still hit the backstop with no
|
|
37
|
+
automatic recovery.
|
|
38
|
+
- **`doctor` now reports engine version skew between installs instead of only grading on
|
|
39
|
+
presence.** A stale npx-cached copy of `opencode-ai` next to a newer global install now surfaces
|
|
40
|
+
as a WARN.
|
|
41
|
+
- **`npm root -g` now resolves on Windows** (it previously threw `ENOENT`/`EINVAL`, making the
|
|
42
|
+
global amicus install invisible to `doctor`). This also un-blinds `doctor --fix`'s donor
|
|
43
|
+
selection, which depends on seeing that global install to pick a healthy engine to copy from.
|
|
44
|
+
- **`npm test` no longer fails intermittently with `ENOENT: … src\__sizecheck_tmp__.js`.**
|
|
45
|
+
`tests/scripts/check-file-sizes.test.js` writes a real `src/__sizecheck_tmp__.js` and unlinks it a
|
|
46
|
+
few milliseconds later. It cannot move that fixture to a tmpdir: `checkAllTracked()` filters its
|
|
47
|
+
input through the anchored `src/**/*.js` include glob and only then resolves against
|
|
48
|
+
`process.cwd()`, so a tmpdir path is dropped before the read and the test would assert on an empty
|
|
49
|
+
violation list. Three suites walk `src/` in parallel jest workers, and a directory listing taken
|
|
50
|
+
while that file existed followed by a read issued after the unlink threw ENOENT — killing
|
|
51
|
+
`no-phantom-dependencies.test.js` during collection ("Test suite failed to run") on roughly 1 full
|
|
52
|
+
run in 2, with `cli-template-args.test.js` and `council/degrade-invariant.test.js` exposed
|
|
53
|
+
identically (both confirmed failing under a reproduction that widens the collision window). All
|
|
54
|
+
three now read through the new `tests/helpers/read-if-present.js`, which skips a file that vanished
|
|
55
|
+
mid-walk and still throws on any non-ENOENT failure, so an unreadable source file stays loud.
|
|
56
|
+
Pre-existing — reproduces at 0fe6128, unrelated to the graphify integration.
|
|
57
|
+
|
|
58
|
+
### Internal
|
|
59
|
+
|
|
60
|
+
- **`finalizeSpendForReopen` extracted to `src/sidecar/reopen-spend.js`**, out of
|
|
61
|
+
`sidecar/continue.js`, to keep that file under the 300-line gate; `resume.js` was already
|
|
62
|
+
reaching across into `continue.js` for it, so the shared home is the honest one.
|
|
63
|
+
- **Deleted three unreachable helpers from `scripts/validate-docs.js`** (dead code with no call
|
|
64
|
+
site remaining after earlier `--check` work landed).
|
|
65
|
+
- **Three doc/marker-freshness gates, all jest-enforced — one genuinely new, two hardened.**
|
|
66
|
+
`tests/docs-command-coverage.test.js` and the anchor-link gate
|
|
67
|
+
(`tests/docs-council-toc-anchors.test.js`) both already existed and already ran in CI; this
|
|
68
|
+
branch hardens them (the former now derives its command list from `bin/amicus.js`'s switch
|
|
69
|
+
instead of hardcoding five entries, the latter generalizes from one file to sixteen). Only the
|
|
70
|
+
CLAUDE.md AUTO-marker/cross-link freshness gate in this changelog entry's own commit is genuinely
|
|
71
|
+
new — `generate-docs --check` had never run in CI before — so a stale `CLAUDE.md` can no longer
|
|
72
|
+
pass CI silently.
|
|
73
|
+
|
|
8
74
|
## [4.7.0] - 2026-08-08
|
|
9
75
|
|
|
10
76
|
**"The count is the count."** Every number Amicus shows you is the number — what a council cost,
|
package/README.md
CHANGED
package/docs/configuration.md
CHANGED
|
@@ -460,6 +460,6 @@ aliases, and council presets carry over untouched.
|
|
|
460
460
|
| `eslint` ^8.0.0 | Code linting |
|
|
461
461
|
| `lint-staged` ^16.3.2 | Run linters on staged files |
|
|
462
462
|
|
|
463
|
-
|
|
463
|
+
The bundled **`opencode-ai`** engine (pinned to `1.18.15`, matched by `@opencode-ai/sdk`) installs automatically as a normal dependency — you don't install it separately. Its own postinstall lays down the per-platform binaries.
|
|
464
464
|
|
|
465
465
|
> **Legacy names.** Pre-rebrand `SIDECAR_*` environment variables were removed entirely in v2.0.0 — they are no longer read, with no warning. Rename to the `AMICUS_*` equivalents documented above. See [docs/SHIMS.md](./SHIMS.md) for the full removal record and rename table.
|
package/docs/council.md
CHANGED
|
@@ -540,8 +540,8 @@ model to the reliability ledger. Thin CLI wrapper over `tally()` (`src/council/t
|
|
|
540
540
|
### Tally-input schema
|
|
541
541
|
|
|
542
542
|
Claude assembles this file at the end of Stage 2 (the full assembly recipe is in
|
|
543
|
-
[SKILL.md](../skills/second-opinion/SKILL.md#
|
|
544
|
-
assembly recipe"). It needs **all five top-level keys** — `tally()` throws
|
|
543
|
+
[SKILL.md](../skills/second-opinion/SKILL.md#the-engine-run--stages-13-plus-the-stage-5-artifacts),
|
|
544
|
+
under "Stage-2 → tally assembly recipe"). It needs **all five top-level keys** — `tally()` throws
|
|
545
545
|
`Cannot read properties of undefined (reading 'map')` if `meta` or `findings` is missing:
|
|
546
546
|
|
|
547
547
|
| Field | Type | Meaning |
|
package/docs/testing.md
CHANGED
|
@@ -523,7 +523,7 @@ Complete mapping of test files to their targets and focus areas.
|
|
|
523
523
|
| `evals/tests/result_writer.test.js` | Result output | Summary formatting, file writing |
|
|
524
524
|
| `scripts/check-secrets.test.js` | Secret detection | Pattern matching, allowlist, multi-secret |
|
|
525
525
|
| `scripts/check-file-sizes.test.js` | File size limits | Line counting, batch checking |
|
|
526
|
-
| `scripts/validate-docs.test.js` | Doc drift detection |
|
|
526
|
+
| `scripts/validate-docs.test.js` | Doc drift detection | Staged file check |
|
|
527
527
|
| `helpers/cdp-client.test.js` | CDP helper | Mock HTTP+WebSocket CDP server, factory methods |
|
|
528
528
|
| `electron-headless-mode.test.js` | Electron headless | Source-level verify `AMICUS_HEADLESS_TEST` guard |
|
|
529
529
|
| `cli-headless-e2e.integration.test.js` | CLI E2E (real LLM) | `start --no-ui`, `list`, `read`, `read --metadata` |
|
package/docs/troubleshooting.md
CHANGED
|
@@ -233,13 +233,15 @@ For full headless configuration, see [docs/configuration.md](./configuration.md)
|
|
|
233
233
|
|
|
234
234
|
## Headless Leg Fails with `NO_OUTPUT_BACKSTOP`
|
|
235
235
|
|
|
236
|
-
**Symptom:** A headless leg (`amicus start --no-ui`, or one leg of a `fanout`/council run) fails with an error starting `NO_OUTPUT_BACKSTOP:
|
|
236
|
+
**Symptom:** A headless leg (`amicus start --no-ui`, or one leg of a `fanout`/council run) fails with an error starting `NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in Ns — the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)`. You may instead see `...in Ns — a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default` — this covers two different cases with the same wording: a Stage-1 retry (its window is the resolved `AMICUS_NO_OUTPUT_BACKSTOP_MS` value **doubled**, so a 120 s default reads `240s` on the retry) and the `amicus models --check --live` probe (a fixed, non-tunable `30s` — see below). Both are "caller-set" in the sense that the specific window wasn't read live from the env var at that call, but only the retry case is actually governed by it.
|
|
237
237
|
|
|
238
|
-
**Cause:** The
|
|
238
|
+
**Cause:** The mechanism only knows that the deadline passed with no substantive activity — no output, reasoning, or tool calls — for the backstop window (120 s by default for an ordinary leg). It does **not** know *why*. Do not read this as "the endpoint is dead" or "the model isn't being served" — the message deliberately stops short of that claim, because at one of its two firing sites the backstop can win the race before the prompt send even resolves, so acceptance itself was never observed. Treat `NO_OUTPUT_BACKSTOP` as "silence past the deadline," not a diagnosis.
|
|
239
239
|
|
|
240
|
-
**
|
|
240
|
+
**Lesson from #133:** an earlier version of this message *did* guess a cause ("likely a listed-but-not-serving model or a dead endpoint"), and that guess sent 30 minutes of debugging at model ids and API keys on a real incident. The actual cause was an OpenCode engine version skew between the npx-cached copy and the global install — sitting the whole time in `~/.local/share/opencode/log/opencode.log`. **Before chasing model/endpoint theories, check that log for the session's real error.**
|
|
241
241
|
|
|
242
|
-
**
|
|
242
|
+
**Confirm:** `amicus models --check` — audits your configured aliases against the live catalog and flags drift/staleness. A model that still resolves locally but has quietly been retired upstream is one possible explanation, but a static audit only compares against the catalog's *listing* — it can't tell you whether a still-listed model actually answers. For that, run `amicus models --check --live` on demand: it sends one tiny real request to every stored alias and reports `SERVED` / `SILENT` / `ERROR` per alias, where `SILENT` (`accepted-but-silent`) is this exact `NO_OUTPUT_BACKSTOP` failure, caught deliberately instead of by accident. It spends real money (one tiny leg per stored alias) — see [docs/usage.md § `amicus models`](./usage.md#amicus-models--the-model-catalog) for cost, scope, and exit-code details.
|
|
243
|
+
|
|
244
|
+
**Fix:** Check the `~/.local/share/opencode/log/opencode.log` for the session's real error first (see #133 above). Then check the alias's target (`amicus models --search <term>` to find the current id, then re-point the alias) — a stale alias is a common case, but not the only one. If the failing leg was a Stage-1 retry (the window read `240s`, or generally double your configured/default value), `AMICUS_NO_OUTPUT_BACKSTOP_MS` **is** the lever — raising it raises both the first attempt's window and the retry's doubled one, unless your `--timeout` is low enough that the doubled window is clamped to the leg timeout. On an ordinary (non-retry) leg, raise it only if a model legitimately needs more than 120 s to produce its first token. The live probe's fixed 30 s window is the one case the env var genuinely cannot touch. See [docs/configuration.md § Headless Poller Tuning](./configuration.md#headless-poller-tuning).
|
|
243
245
|
|
|
244
246
|
---
|
|
245
247
|
|
package/docs/usage.md
CHANGED
|
@@ -487,14 +487,18 @@ written at MCP launch time, or falls back to the portion of `briefing-stage1.md`
|
|
|
487
487
|
MCP-only, since the CLI never lists a council row to search in the first place. A bare `--search`
|
|
488
488
|
with no value is a usage error on the CLI. Tag itself is set at launch with `--tag <t>` on
|
|
489
489
|
`start`/`fanout`/`council run` (see those sections above), and is also a dimension for
|
|
490
|
-
`amicus spend --group-by tag`.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
continue` and `amicus resume`
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
490
|
+
`amicus spend --group-by tag`. `amicus continue` and `amicus resume` don't take a `--tag` of
|
|
491
|
+
their own — they automatically inherit the parent session's tag (see below).
|
|
492
|
+
|
|
493
|
+
**Tag inheritance.** `amicus continue` and `amicus resume` carry the parent session's tag forward
|
|
494
|
+
onto the new/reopened session's metadata and spend row, with no `--tag` flag needed — resume reuses
|
|
495
|
+
the parent's own session directory, and continue copies the tag onto the new session it creates, so
|
|
496
|
+
a multi-hop `continue` chain keeps the same tag at every depth. A `fanout --retry-failed` wave
|
|
497
|
+
likewise replays the original wave's tag automatically. Because the tag in each of these cases
|
|
498
|
+
comes from the session/wave being reopened rather than a fresh launch, `--tag` is rejected as a
|
|
499
|
+
usage error when combined with `continue`, `resume`, or `--retry-failed` — there is nothing new to
|
|
500
|
+
set. An untagged parent still leaves the key absent (not `null`) on the new metadata, and its spend
|
|
501
|
+
row still groups under `(unattributed)`, exactly as an untagged `start`/`fanout` would.
|
|
498
502
|
|
|
499
503
|
**`amicus status <id>` output.** Human-readable:
|
|
500
504
|
|
|
@@ -514,7 +518,7 @@ $ amicus status demo123 --json
|
|
|
514
518
|
"taskId": "demo123",
|
|
515
519
|
"status": "complete",
|
|
516
520
|
"elapsed": "5m 0s",
|
|
517
|
-
"version": "4.7.
|
|
521
|
+
"version": "4.7.1",
|
|
518
522
|
"model": "google/gemini-2.5-flash",
|
|
519
523
|
"phase": "terminal"
|
|
520
524
|
}
|
|
@@ -574,7 +578,7 @@ Runs every check below, in order, and prints a ✓/⚠/✗ line for each plus a
|
|
|
574
578
|
| `aliases` | Your configured aliases still resolve against the catalog | warn |
|
|
575
579
|
| `anthropic-base-url` | `ANTHROPIC_BASE_URL` isn't host-form (host-form 404s every direct-Anthropic leg unless normalized) | warn |
|
|
576
580
|
| `opencode-bin` | The OpenCode engine binary is on `PATH` | error |
|
|
577
|
-
| `engine-mcp` | The engine copy `npx -y amicus@latest mcp` would actually launch (catches a broken npx-cache copy a healthy local install would hide) | warn (error only if there's exactly one npx-cache copy and it's broken) |
|
|
581
|
+
| `engine-mcp` | The engine copy `npx -y amicus@latest mcp` would actually launch (catches a broken npx-cache copy a healthy local install would hide, and a version-skewed one — present but the wrong opencode-ai release vs. the global install, #133) | warn (error only if there's exactly one npx-cache copy and it's broken; also warns, never errors, on engine version skew between the npx copies and the global install) |
|
|
578
582
|
| `electron` | Electron (the interactive GUI) is installed | warn — headless still works |
|
|
579
583
|
| `skills` | Both skills exist under `~/.claude/skills/` | warn |
|
|
580
584
|
| `mcp` | Amicus is registered as an MCP server in Claude Code | warn |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.1",
|
|
4
4
|
"mcpName": "io.github.BourbonDog/amicus",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"generate-docs:check": "node scripts/generate-docs.js --check",
|
|
69
69
|
"validate-docs": "node scripts/validate-docs.js --full",
|
|
70
70
|
"setup-hooks": "node scripts/setup-hooks.js",
|
|
71
|
+
"graphify:index": "graphify extract . --backend claude-cli",
|
|
71
72
|
"check:secrets": "node scripts/check-secrets.js --all",
|
|
72
73
|
"check:sizes": "node scripts/check-file-sizes.js --all",
|
|
73
74
|
"check:tarball": "node scripts/check-tarball-lifecycle.js"
|
|
@@ -75,10 +76,10 @@
|
|
|
75
76
|
"dependencies": {
|
|
76
77
|
"@electron/get": "^5.0.0",
|
|
77
78
|
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
78
|
-
"@opencode-ai/sdk": "
|
|
79
|
+
"@opencode-ai/sdk": "1.18.15",
|
|
79
80
|
"dotenv": "^17.2.3",
|
|
80
81
|
"extract-zip": "^2.0.1",
|
|
81
|
-
"opencode-ai": "
|
|
82
|
+
"opencode-ai": "1.18.15",
|
|
82
83
|
"tiktoken": "^1.0.0",
|
|
83
84
|
"update-notifier": "^7.3.1",
|
|
84
85
|
"zod": "^3.0.0"
|
package/scripts/postinstall.js
CHANGED
|
@@ -110,9 +110,12 @@ async function provisionElectron(deps = {}) {
|
|
|
110
110
|
* path.
|
|
111
111
|
*
|
|
112
112
|
* Hook setup still needs to happen for devs, so we run setup-hooks.js here.
|
|
113
|
-
* It is a no-op for consumers
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* It is a no-op for consumers, but NOT merely because the published tarball
|
|
114
|
+
* has no .git: git resolves a repository by walking UP, so during a consumer's
|
|
115
|
+
* `npm install amicus` (cwd = node_modules/amicus) it finds the CONSUMER'S
|
|
116
|
+
* .git. setup-hooks.js is what makes this safe — it configures hooks only when
|
|
117
|
+
* git's toplevel IS the amicus package root, so a dependency install can never
|
|
118
|
+
* repoint someone else's core.hooksPath. See its docstring for the full story.
|
|
116
119
|
*
|
|
117
120
|
* Best-effort and self-contained: never throws (a hook-setup failure must
|
|
118
121
|
* never roll back the install), so it is safe to call before the optional
|
package/scripts/setup-hooks.js
CHANGED
|
@@ -21,20 +21,66 @@
|
|
|
21
21
|
*
|
|
22
22
|
* npm run setup-hooks # or: node scripts/setup-hooks.js
|
|
23
23
|
*
|
|
24
|
+
* ONLY EVER CONFIGURES THE AMICUS CHECKOUT ITSELF.
|
|
25
|
+
* ------------------------------------------------
|
|
26
|
+
* "Are we in a git checkout?" is NOT a sufficient guard, because git resolves
|
|
27
|
+
* a repository by walking UP the directory tree. When a consumer runs
|
|
28
|
+
* `npm install amicus` inside their own repo, npm runs this postinstall with
|
|
29
|
+
* cwd = <consumer>/node_modules/amicus — where `git rev-parse` finds the
|
|
30
|
+
* CONSUMER'S .git and happily reports success. Writing core.hooksPath there
|
|
31
|
+
* pointed their repository at a .husky directory that does not exist (it is
|
|
32
|
+
* not in package.json "files", so it never ships), which silently disabled
|
|
33
|
+
* every hook in their .git/hooks — no error, no warning, `git commit` just
|
|
34
|
+
* stops running them. The damage also outlived a failed install: npm rolls
|
|
35
|
+
* back node_modules, but not writes to someone else's .git/config.
|
|
36
|
+
*
|
|
37
|
+
* So the guard asks `git rev-parse --show-prefix` from the package root. That
|
|
38
|
+
* prints the path of the current directory RELATIVE to the top of the work
|
|
39
|
+
* tree, so it is empty exactly when the package root IS the repository root,
|
|
40
|
+
* and non-empty ("node_modules/amicus/") when amicus merely sits inside
|
|
41
|
+
* someone else's checkout. A dependency install, a vendored copy and a global
|
|
42
|
+
* install all land below their enclosing repo's root; the dev checkout — and
|
|
43
|
+
* every linked worktree of it, whose root is the worktree — comes back empty.
|
|
44
|
+
*
|
|
45
|
+
* DO NOT "simplify" this into comparing `--show-toplevel` against the package
|
|
46
|
+
* root as strings. That was the first attempt and it broke on Windows CI: the
|
|
47
|
+
* runners' %TEMP% is an 8.3 short path (C:\Users\RUNNER~1\...), Node's
|
|
48
|
+
* fs.realpathSync PRESERVES short components while git always reports the long
|
|
49
|
+
* form, so the two spellings of one directory never compared equal and the
|
|
50
|
+
* guard refused to configure a legitimate checkout. Case, separators, symlinks
|
|
51
|
+
* and 8.3 are four different ways for equal paths to spell differently; asking
|
|
52
|
+
* git to compute the relationship sidesteps all of them at once.
|
|
53
|
+
*
|
|
54
|
+
* Every git call is anchored at the package root rather than process.cwd() for
|
|
55
|
+
* the same reason: what gets configured must depend on where amicus lives, not
|
|
56
|
+
* on where the script happened to be invoked from.
|
|
57
|
+
*
|
|
24
58
|
* Safe to run anywhere: exits 0 outside a git checkout (npm tarball
|
|
25
59
|
* installs, exported archives) and never fails the install.
|
|
26
60
|
*/
|
|
27
61
|
|
|
28
62
|
const { execFileSync } = require('node:child_process');
|
|
63
|
+
const path = require('node:path');
|
|
64
|
+
|
|
65
|
+
/** This package's root — the parent of the scripts/ directory holding this file. */
|
|
66
|
+
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
29
67
|
|
|
30
68
|
function git(...args) {
|
|
31
|
-
return execFileSync('git', args, { encoding: 'utf-8' }).trim();
|
|
69
|
+
return execFileSync('git', args, { cwd: PKG_ROOT, encoding: 'utf-8' }).trim();
|
|
32
70
|
}
|
|
33
71
|
|
|
72
|
+
let prefix;
|
|
34
73
|
try {
|
|
35
|
-
git('rev-parse', '--
|
|
74
|
+
prefix = git('rev-parse', '--show-prefix');
|
|
36
75
|
} catch {
|
|
37
|
-
process.exit(0); // not a git checkout — nothing to configure
|
|
76
|
+
process.exit(0); // not a git checkout (or a bare repo) — nothing to configure
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (prefix !== '') {
|
|
80
|
+
// amicus is nested inside someone else's repository — a dependency install,
|
|
81
|
+
// a vendored copy, a global install under a tracked directory. Their hooks
|
|
82
|
+
// are none of our business. Silent: this is the normal consumer path.
|
|
83
|
+
process.exit(0);
|
|
38
84
|
}
|
|
39
85
|
|
|
40
86
|
try {
|
package/skills/sidecar/SKILL.md
CHANGED
|
@@ -380,7 +380,9 @@ amicus list --json # Output as JSON
|
|
|
380
380
|
- `--cwd <path>`: Project directory (default: current directory)
|
|
381
381
|
|
|
382
382
|
Rows show the `--tag <t>` label set at launch time (`start`/`fanout`/`council run` —
|
|
383
|
-
not a `list` flag itself)
|
|
383
|
+
not a `list` flag itself), or inherited automatically from the parent session on
|
|
384
|
+
`continue`/`resume` (neither accepts its own `--tag` — the tag comes from the
|
|
385
|
+
parent, so there's nothing new to set); untagged sessions show blank.
|
|
384
386
|
|
|
385
387
|
### Resume a Sidecar
|
|
386
388
|
|
|
@@ -21,6 +21,17 @@ const { GATEWAY_MODES } = require('./utils/model-descriptor');
|
|
|
21
21
|
*/
|
|
22
22
|
async function handleResume(args) {
|
|
23
23
|
const useJson = !!args.json;
|
|
24
|
+
// v4.7.1 Task 7 (R-D): --tag parses today only because getKnownFlags()
|
|
25
|
+
// unions every usage block's flags (fanout's/start's/council run's), and is
|
|
26
|
+
// then read by nobody here — resume reuses the parent session's own dir, so
|
|
27
|
+
// its tag is already inherited (E-5a) with nothing left to set. Mirrors
|
|
28
|
+
// handleFanout's --tag/--retry-failed rejection shape (cli-handlers-fanout.js:27-29).
|
|
29
|
+
if (args.tag !== undefined) {
|
|
30
|
+
process.exit(failJson(useJson, {
|
|
31
|
+
code: ERROR_CODES.BAD_ARGS,
|
|
32
|
+
message: 'Error: --tag is not supported on resume — the tag is inherited from the parent session',
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
24
35
|
const taskId = requireValidTaskId(args, useJson, 'resume', 'Usage: amicus resume <task_id>');
|
|
25
36
|
requireNoUiForJson(args, useJson);
|
|
26
37
|
|
|
@@ -51,6 +62,15 @@ async function handleResume(args) {
|
|
|
51
62
|
*/
|
|
52
63
|
async function handleContinue(args) {
|
|
53
64
|
const useJson = !!args.json;
|
|
65
|
+
// v4.7.1 Task 7 (R-D): same rationale as handleResume above — a continuation
|
|
66
|
+
// inherits the parent session's tag onto its own metadata.json (createContinueSessionMetadata),
|
|
67
|
+
// so a user-supplied --tag here would silently be ignored rather than applied.
|
|
68
|
+
if (args.tag !== undefined) {
|
|
69
|
+
process.exit(failJson(useJson, {
|
|
70
|
+
code: ERROR_CODES.BAD_ARGS,
|
|
71
|
+
message: 'Error: --tag is not supported on continue — the tag is inherited from the parent session',
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
54
74
|
const taskId = requireValidTaskId(args, useJson, 'continue', 'Usage: amicus continue <task_id> --prompt "..."');
|
|
55
75
|
|
|
56
76
|
// BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
|
|
@@ -64,7 +64,7 @@ function createLaunchers(deps = {}) {
|
|
|
64
64
|
* @param {{models: string[], prompt: string, project: string, waveId: string,
|
|
65
65
|
* timeout?: number, gateway?: string, noValidateModel?: boolean, agent?: string,
|
|
66
66
|
* councilRunId?: string, councilName?: string, tag?: string, fallback?: object,
|
|
67
|
-
* catalog?: Array}} opts
|
|
67
|
+
* catalog?: Array, noOutputBackstopMs?: number}} opts
|
|
68
68
|
* councilRunId/councilName (v4.3 Task 3, spec §7.2) are additive attribution
|
|
69
69
|
* ids forwarded verbatim into the runFanout call so it can stamp them onto
|
|
70
70
|
* every leg. tag (v4.7 F8 D16) rides the same forward — every call site
|
|
@@ -72,6 +72,9 @@ function createLaunchers(deps = {}) {
|
|
|
72
72
|
* fallback/catalog (v4.3 Task 18, spec §6.2) are likewise
|
|
73
73
|
* additive/opt-in — omitted by callers that must never substitute (the
|
|
74
74
|
* chair, debate legs); run-stages.js's Stage-1/Stage-2 launches pass them.
|
|
75
|
+
* noOutputBackstopMs (Task 5, #129) is opt-in and spread-guarded on
|
|
76
|
+
* Number.isFinite (0 is a valid disable value); only run-retry.js sets it,
|
|
77
|
+
* to escalate the window on a Stage-1 retry.
|
|
75
78
|
* @returns {Promise<{wave: object|null, exitCode: number}>}
|
|
76
79
|
*/
|
|
77
80
|
async function launchWave(opts) {
|
|
@@ -118,6 +121,14 @@ function createLaunchers(deps = {}) {
|
|
|
118
121
|
// undefined when no --tag, so stampLegAttribution's `if (options.tag)`
|
|
119
122
|
// guard (fanout-wave-io.js) simply no-ops, byte-identical to today.
|
|
120
123
|
tag: opts.tag,
|
|
124
|
+
// Task 5 (#129): spread-guarded on Number.isFinite, NOT on truthiness —
|
|
125
|
+
// an explicit 0 is this knob's documented disable hatch
|
|
126
|
+
// (no-output-backstop.js:13-15) and a truthiness guard would silently
|
|
127
|
+
// drop it. Guarding at all — rather than a plain
|
|
128
|
+
// `noOutputBackstopMs: opts.noOutputBackstopMs` — keeps the transport
|
|
129
|
+
// call key-identical for run-stage1-launch / run-stage2 / run-chair /
|
|
130
|
+
// run-debate, none of which set it.
|
|
131
|
+
...(Number.isFinite(opts.noOutputBackstopMs) ? { noOutputBackstopMs: opts.noOutputBackstopMs } : {}),
|
|
121
132
|
// v4.3 Task 18 (spec §6.2): additive/opt-in. Callers that must never
|
|
122
133
|
// substitute (run-chair.js, run-debate.js) simply omit these — runLeg's
|
|
123
134
|
// fallback path only activates when `fallback.enabled` is true.
|
package/src/council/run-retry.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const briefings = require('./briefings');
|
|
18
18
|
const { materializeReviews, isAbortExit } = require('./run-launch');
|
|
19
19
|
const runState = require('./run-state');
|
|
20
|
+
const { resolveNoOutputBackstopMs } = require('../utils/no-output-backstop');
|
|
20
21
|
const { waveStillDeadNote, srcLegStillDeadNote, retryLegStillDeadNote, missingLegStillDeadNote }
|
|
21
22
|
= require('./run-retry-notes');
|
|
22
23
|
|
|
@@ -143,6 +144,16 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
|
|
|
143
144
|
const out = { aborted: null, recoveredLegs: [], stillDeadNotes: [],
|
|
144
145
|
stillDeadWaves: [], stillDeadLegs: [], skippedDeadWaves: [], skippedDeadLegs: [],
|
|
145
146
|
stillDeadRetryLegs: [] };
|
|
147
|
+
// Task 5 (#129): SL-2 retries the SAME model under the SAME conditions, so a
|
|
148
|
+
// latency failure is structurally unhealable. Double the window, clamped to
|
|
149
|
+
// the leg timeout so the failure CLASS stays NO_OUTPUT_BACKSTOP rather than
|
|
150
|
+
// silently becoming an ordinary timeout at a low --timeout. 2*0 === 0 keeps
|
|
151
|
+
// the disable hatch. (o.timeout || 15) * 60 * 1000 mirrors fanout.js:254.
|
|
152
|
+
const legTimeoutMs = (o.timeout || 15) * 60 * 1000;
|
|
153
|
+
const escalatedBackstopMs = Math.min(
|
|
154
|
+
2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs : resolveNoOutputBackstopMs()),
|
|
155
|
+
legTimeoutMs,
|
|
156
|
+
);
|
|
146
157
|
|
|
147
158
|
for (const unit of groupStage1Losses(o, deadWaves, deadLegs)) {
|
|
148
159
|
// Task-4 review hardening: a unit this pass cannot even ATTEMPT — an
|
|
@@ -171,7 +182,8 @@ async function retryStage1Losses(ctx, { deadWaves = [], deadLegs = [], counts =
|
|
|
171
182
|
councilRunId: o.runId, councilName: o.councilName,
|
|
172
183
|
tag: o.tag, // v4.7 F8 D16: rides the same forward as councilRunId/councilName.
|
|
173
184
|
fallback: o.fallback, catalog: o.catalog,
|
|
174
|
-
waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit)
|
|
185
|
+
waveId: unit.waveId, retryOfWaveId: unit.retryOfWaveId, prompt: briefingFor(o, unit),
|
|
186
|
+
noOutputBackstopMs: escalatedBackstopMs };
|
|
175
187
|
// Dispatch by UNIT TYPE, not model count (spec §4: bench is always a wave —
|
|
176
188
|
// even down to its last surviving seat — critic/lens are always solos).
|
|
177
189
|
// A model-count proxy (`models.length === 1`) is wrong for a bench unit
|
package/src/headless.js
CHANGED
|
@@ -154,6 +154,58 @@ function withTimeout(promise, ms, label) {
|
|
|
154
154
|
]);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Task 6 (#129, #133): build the NO_OUTPUT_BACKSTOP reason string. Report
|
|
159
|
+
* ONLY what the mechanism observed — a deadline passed with no substantive
|
|
160
|
+
* activity (output/reasoning/tool calls) — never a cause. At the pre-send
|
|
161
|
+
* firing site (runHeadless, ~:506-518) the backstop can win the race against
|
|
162
|
+
* sendPromptAsync before the send ever resolves, so "the endpoint accepted
|
|
163
|
+
* the request" is not even something that site observed. The previous text
|
|
164
|
+
* asserted "likely a listed-but-not-serving model or a dead endpoint" — a
|
|
165
|
+
* canned guess with no evidence gate — which sent 30 minutes of #133's
|
|
166
|
+
* debugging at model ids and API keys while the real cause (an opencode
|
|
167
|
+
* engine version skew) sat in ~/.local/share/opencode/log/opencode.log the
|
|
168
|
+
* whole time.
|
|
169
|
+
*
|
|
170
|
+
* `fromEnv` distinguishes two ways `ms` was decided, NOT whether
|
|
171
|
+
* AMICUS_NO_OUTPUT_BACKSTOP_MS is relevant — it is relevant on both branches:
|
|
172
|
+
* - fromEnv=true: `ms` IS the live env-resolved value (or its documented
|
|
173
|
+
* default) — the message says so directly, "(0 disables)" included,
|
|
174
|
+
* because raising the env var changes exactly this window.
|
|
175
|
+
* - fromEnv=false: `ms` arrived as a direct, caller-set numeric option.
|
|
176
|
+
* Task 6 review (Important finding): this is NOT synonymous with "the
|
|
177
|
+
* env var doesn't apply" — src/council/run-retry.js:154 computes a
|
|
178
|
+
* Stage-1 retry's escalated window as
|
|
179
|
+
* `2 * (Number.isFinite(o.noOutputBackstopMs) ? o.noOutputBackstopMs :
|
|
180
|
+
* resolveNoOutputBackstopMs())` and forwards that as a direct
|
|
181
|
+
* `noOutputBackstopMs` (line 186) — so a 240s retry-fired backstop is
|
|
182
|
+
* "caller-set" by this predicate while still being *derived from* the
|
|
183
|
+
* env default doubled. Only src/sidecar/models-probe.js:79's hardcoded,
|
|
184
|
+
* non-tunable 30s (PROBE_WINDOW_MS; docs/usage.md:406 promises it's "not
|
|
185
|
+
* tunable") is truly independent of the env var. Because a real
|
|
186
|
+
* `fromEnv` flag distinguishing those two cases would have to ride the
|
|
187
|
+
* same value through src/sidecar/fanout.js, which is line-locked at
|
|
188
|
+
* EXACTLY 300/300 this release, the caller-set branch instead names the
|
|
189
|
+
* var as something this window *overrides* rather than either claiming
|
|
190
|
+
* it governs (false on the probe) or omitting it (false/unhelpful on the
|
|
191
|
+
* retry) — true on both, and still points a user at the remedy.
|
|
192
|
+
*
|
|
193
|
+
* Kept module-scope and pure (not a closure over runHeadless locals) so it
|
|
194
|
+
* can be asserted on directly in tests without driving the poll loop; the
|
|
195
|
+
* `noOutputBackstopReason` closure inside runHeadless just forwards to this
|
|
196
|
+
* with the per-run `noOutputBackstopMs`/`backstopFromEnv` values, so the two
|
|
197
|
+
* firing sites there stay identical to what's tested here.
|
|
198
|
+
* @param {{ms: number, fromEnv: boolean}} args
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
function formatNoOutputBackstopReason({ ms, fromEnv }) {
|
|
202
|
+
return 'NO_OUTPUT_BACKSTOP: no output, reasoning, or tool calls in '
|
|
203
|
+
+ `${Math.round(ms / 1000)}s — `
|
|
204
|
+
+ (fromEnv
|
|
205
|
+
? 'the AMICUS_NO_OUTPUT_BACKSTOP_MS window (0 disables)'
|
|
206
|
+
: 'a caller-set window overriding the AMICUS_NO_OUTPUT_BACKSTOP_MS default');
|
|
207
|
+
}
|
|
208
|
+
|
|
157
209
|
/**
|
|
158
210
|
* Wait for the OpenCode server to be ready using SDK health check
|
|
159
211
|
*/
|
|
@@ -473,16 +525,26 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
473
525
|
// deadline `nowMs >= deadline` can never satisfy — the backstop would
|
|
474
526
|
// silently never fire. Finite zero (the documented explicit-disable
|
|
475
527
|
// value) still takes the direct branch: Number.isFinite(0) === true.
|
|
476
|
-
|
|
477
|
-
|
|
528
|
+
// Capture the source-of-truth predicate ONCE and reuse it (do not call
|
|
529
|
+
// Number.isFinite a second time below) — `backstopFromEnv` is just its
|
|
530
|
+
// complement: options.noOutputBackstopMs is a direct, caller-set value
|
|
531
|
+
// exactly when Number.isFinite is true, so the window came from
|
|
532
|
+
// resolveNoOutputBackstopMs's env-resolution seam exactly when it's false.
|
|
533
|
+
// Reused both to pick noOutputBackstopMs and to decide whether the reason
|
|
534
|
+
// string below may name the env var, so the two can never drift apart.
|
|
535
|
+
const backstopFromEnv = !Number.isFinite(options.noOutputBackstopMs);
|
|
536
|
+
const noOutputBackstopMs = backstopFromEnv
|
|
537
|
+
? resolveNoOutputBackstopMs(options._env) : options.noOutputBackstopMs;
|
|
478
538
|
const noOutputBackstop = createNoOutputBackstop({ ms: noOutputBackstopMs, startedAt: Date.now() });
|
|
479
539
|
let backstopFired = false;
|
|
480
540
|
// Single source for the reason string so the pre-send firing site below and
|
|
481
541
|
// the per-poll firing site further down (still ticking the SAME instance)
|
|
482
|
-
// can never drift apart.
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
542
|
+
// can never drift apart. Forwards to the module-scope, pure
|
|
543
|
+
// formatNoOutputBackstopReason (below/exported) so tests can assert on the
|
|
544
|
+
// string shape directly without driving the whole poll loop.
|
|
545
|
+
const noOutputBackstopReason = () => formatNoOutputBackstopReason({
|
|
546
|
+
ms: noOutputBackstopMs, fromEnv: backstopFromEnv,
|
|
547
|
+
});
|
|
486
548
|
|
|
487
549
|
// Send prompt asynchronously (returns immediately, we poll for results) —
|
|
488
550
|
// bounded by the backstop: an endpoint that accepts but never answers must
|
|
@@ -1413,6 +1475,7 @@ module.exports = {
|
|
|
1413
1475
|
extractSummary,
|
|
1414
1476
|
findTrailingFoldMarker,
|
|
1415
1477
|
formatFoldOutput,
|
|
1478
|
+
formatNoOutputBackstopReason,
|
|
1416
1479
|
DEFAULT_TIMEOUT,
|
|
1417
1480
|
FOLD_MARKER,
|
|
1418
1481
|
COMPLETE_MARKER,
|
package/src/sidecar/continue.js
CHANGED
|
@@ -88,7 +88,7 @@ Build on the previous sidecar's findings. The user wants to continue or extend t
|
|
|
88
88
|
|
|
89
89
|
/** Create session metadata for continuation */
|
|
90
90
|
function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
91
|
-
const { model, briefing, headless, agent, gateway, resolutionVersion } = options;
|
|
91
|
+
const { model, briefing, headless, agent, gateway, resolutionVersion, tag } = options;
|
|
92
92
|
|
|
93
93
|
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
94
94
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
@@ -102,7 +102,11 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
|
102
102
|
agent: agent || (headless ? 'build' : 'chat'),
|
|
103
103
|
status: 'running',
|
|
104
104
|
createdAt: new Date().toISOString(),
|
|
105
|
-
continuesFrom: oldTaskId
|
|
105
|
+
continuesFrom: oldTaskId,
|
|
106
|
+
// v4.7.1 Task 7 (D13): absent-not-null, same idiom as start-metadata.js:50
|
|
107
|
+
// — a continuation inherits the parent's tag so a continue chain never
|
|
108
|
+
// scatters into `(unattributed)`.
|
|
109
|
+
...(tag ? { tag } : {}),
|
|
106
110
|
};
|
|
107
111
|
// #61 Task 5.2 (best-effort provenance): only present when THIS continue
|
|
108
112
|
// call freshly routed an explicit --model through the gateway router — the
|
|
@@ -116,26 +120,6 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
|
116
120
|
return sessionDir;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
/**
|
|
120
|
-
* Resolve a reopened session's usage, write it onto metadata, and append one
|
|
121
|
-
* attributed ledger row. Mirrors start.js's finalize (the only sites that
|
|
122
|
-
* dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
|
|
123
|
-
* @returns {{usage: object|null}}
|
|
124
|
-
*/
|
|
125
|
-
function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
|
|
126
|
-
const { resolveUsage } = require('../utils/pricing');
|
|
127
|
-
const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
|
|
128
|
-
if (usage) {
|
|
129
|
-
metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
|
|
130
|
-
try {
|
|
131
|
-
const { appendSpend } = require('../utils/spend-ledger');
|
|
132
|
-
const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
|
|
133
|
-
appendSpend({ taskId, model, mode, usage, op, status, project, gateway }, ctx);
|
|
134
|
-
} catch { /* best-effort */ }
|
|
135
|
-
}
|
|
136
|
-
return { usage };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
123
|
/**
|
|
140
124
|
* Continue from a previous sidecar session - Spec Reference: §4.4, §8.5
|
|
141
125
|
* @returns {Promise<number>} process exit code
|
|
@@ -196,6 +180,7 @@ async function continueSidecar(options) {
|
|
|
196
180
|
|
|
197
181
|
const sessionDir = createContinueSessionMetadata(newTaskId, project, {
|
|
198
182
|
model, briefing, headless, agent: effectiveAgent, gateway, resolutionVersion,
|
|
183
|
+
tag: oldMetadata.tag, // v4.7.1 Task 7: inherit the parent's tag (absent if the parent had none).
|
|
199
184
|
}, oldTaskId);
|
|
200
185
|
|
|
201
186
|
// Lock the NEW continuation session dir too — not just the previous one — so a
|
|
@@ -270,6 +255,7 @@ async function continueSidecar(options) {
|
|
|
270
255
|
// ledger row (status: statusFromResult, matching start.js — not terminal.status).
|
|
271
256
|
{
|
|
272
257
|
const { statusFromResult } = require('../utils/result-schema');
|
|
258
|
+
const { finalizeSpendForReopen } = require('./reopen-spend');
|
|
273
259
|
const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
274
260
|
const { usage } = finalizeSpendForReopen({
|
|
275
261
|
taskId: newTaskId, model, mode: headless ? 'headless' : 'interactive',
|
|
@@ -292,6 +278,5 @@ module.exports = {
|
|
|
292
278
|
loadPreviousSession,
|
|
293
279
|
buildContinuationContext,
|
|
294
280
|
createContinueSessionMetadata,
|
|
295
|
-
finalizeSpendForReopen,
|
|
296
281
|
continueSidecar
|
|
297
282
|
};
|
|
@@ -73,7 +73,10 @@ function buildRetryPlan(origWaveId, project, { models } = {}) {
|
|
|
73
73
|
} catch { /* legacy leg — fall back to briefing.md at launch time */ }
|
|
74
74
|
eligible.push({ legId, model, systemPrompt, userMessage, hadSavedContext });
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// waveMeta has been read since :51 but was discarded; retryFailedWave needs
|
|
77
|
+
// the tag BEFORE it builds fanoutOpts, and its own origMeta re-read at :176
|
|
78
|
+
// runs after runFanoutImpl at :153 — too late to influence the launch.
|
|
79
|
+
return { eligible, tag: waveMeta.tag };
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
/**
|
|
@@ -121,7 +124,7 @@ async function retryFailedWave(origWaveId, project, opts = {}) {
|
|
|
121
124
|
// --json caller's stdout stays machine-parseable either way.
|
|
122
125
|
const noopDoc = {
|
|
123
126
|
...buildWaveResult({ waveId: origWaveId, legs: [], status: 'complete' }),
|
|
124
|
-
retryOf: origWaveId, effective: [], note: 'no failed legs',
|
|
127
|
+
retryOf: origWaveId, effective: [], note: 'no failed legs', tag: plan.tag,
|
|
125
128
|
};
|
|
126
129
|
process.stdout.write(JSON.stringify(noopDoc, null, 2) + '\n');
|
|
127
130
|
} else {
|
|
@@ -148,7 +151,16 @@ async function retryFailedWave(origWaveId, project, opts = {}) {
|
|
|
148
151
|
// expects (parseModelsList/validateFanoutModels) — an array silently fails
|
|
149
152
|
// every leg pre-flight (BAD_ARGS), matching run-launch.js:41's precedent.
|
|
150
153
|
// Strip our own injection key so it is never forwarded.
|
|
151
|
-
const fanoutOpts = {
|
|
154
|
+
const fanoutOpts = {
|
|
155
|
+
...opts, models: models.join(','), prompt: briefing, project, waveId: newWaveId, retryContexts, retryOfWaveId: origWaveId,
|
|
156
|
+
// v4.7.1 Task 8: inherit the ORIGINAL wave's tag — sourced from disk
|
|
157
|
+
// (plan.tag, off waveMeta) never from opts.tag/args.tag. This is placed
|
|
158
|
+
// AFTER the `...opts` spread so it wins even if a caller's opts somehow
|
|
159
|
+
// carried a tag (the CLI itself already rejects --tag + --retry-failed
|
|
160
|
+
// upstream in cli-handlers-fanout.js). Absent-not-null idiom, matching
|
|
161
|
+
// fanout.js:152's `...(options.pack ? {...} : {})`.
|
|
162
|
+
...(plan.tag ? { tag: plan.tag } : {}),
|
|
163
|
+
};
|
|
152
164
|
delete fanoutOpts.runFanout;
|
|
153
165
|
const { wave, exitCode } = await runFanoutImpl(fanoutOpts);
|
|
154
166
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module sidecar/reopen-spend
|
|
3
|
+
* Spend finalization for a REOPENED session (continue/resume). Split out of
|
|
4
|
+
* sidecar/continue.js to keep that file under the 300-line gate; resume.js was
|
|
5
|
+
* already reaching across for it, so the shared home is the honest one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a reopened session's usage, write it onto metadata, and append one
|
|
12
|
+
* attributed ledger row. Mirrors start.js's finalize (the only sites that
|
|
13
|
+
* dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
|
|
14
|
+
* @returns {{usage: object|null}}
|
|
15
|
+
*/
|
|
16
|
+
function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
|
|
17
|
+
const { resolveUsage } = require('../utils/pricing');
|
|
18
|
+
const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
|
|
19
|
+
if (usage) {
|
|
20
|
+
metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
|
|
21
|
+
try {
|
|
22
|
+
const { appendSpend } = require('../utils/spend-ledger');
|
|
23
|
+
const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
|
|
24
|
+
// v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
|
|
25
|
+
// metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
|
|
26
|
+
appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
|
|
27
|
+
} catch { /* best-effort */ }
|
|
28
|
+
}
|
|
29
|
+
return { usage };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { finalizeSpendForReopen };
|
package/src/sidecar/resume.js
CHANGED
|
@@ -251,7 +251,7 @@ async function resumeSidecar(options) {
|
|
|
251
251
|
// v4.3: attribute resume spend (C9/E4). Reload metadata, write usage + append
|
|
252
252
|
// a ledger row (status: statusFromResult, matching start.js — not terminal.status).
|
|
253
253
|
{
|
|
254
|
-
const { finalizeSpendForReopen } = require('./
|
|
254
|
+
const { finalizeSpendForReopen } = require('./reopen-spend');
|
|
255
255
|
const { statusFromResult } = require('../utils/result-schema');
|
|
256
256
|
const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
257
257
|
const { usage } = finalizeSpendForReopen({
|
|
@@ -48,6 +48,38 @@ function evaluateEngineInstalls(d) {
|
|
|
48
48
|
|
|
49
49
|
const broken = npxCopies.filter((i) => !i.engineOk);
|
|
50
50
|
if (broken.length === 0) {
|
|
51
|
+
// Version skew (#133): a PRESENT engine can still be the wrong one. The
|
|
52
|
+
// npx copies and the global install resolve independently and at different
|
|
53
|
+
// times, and two versions writing one shared opencode.db is what produced
|
|
54
|
+
// #133's SQLiteError. Compare npx against global ONLY — a genuine source
|
|
55
|
+
// checkout's engine legitimately differs, so including it would fire red
|
|
56
|
+
// on every developer machine and in CI (E-1c). Unresolved versions never
|
|
57
|
+
// signal skew; absence of evidence is not evidence.
|
|
58
|
+
// WARN, never ERROR: doctor --fix has no skew branch, so an error would be
|
|
59
|
+
// unfixable, and this file already downgrades to warn at the ambiguous-npx
|
|
60
|
+
// branch below whenever the copy npx will select is ambiguous.
|
|
61
|
+
//
|
|
62
|
+
// Review round 2, finding 1: `kind === 'global'` alone is NOT the whole
|
|
63
|
+
// baseline. On the documented end-user invocation — `amicus doctor` run
|
|
64
|
+
// from the globally-installed copy — the running process IS the global
|
|
65
|
+
// install, so engine-install-scan.js's own dedup drops the separate
|
|
66
|
+
// `global` record and only `kind:'running'` survives, carrying
|
|
67
|
+
// `isGlobal: true` instead (see scanEngineInstalls). Without the
|
|
68
|
+
// `|| i.isGlobal` clause, the skew check was structurally unable to fire
|
|
69
|
+
// for exactly the users who filed #133. A TRUE source checkout (running,
|
|
70
|
+
// no isGlobal) is still excluded, as intended.
|
|
71
|
+
const globalV = (installs.find((i) => i.kind === 'global' || i.isGlobal) || {}).engineVersion;
|
|
72
|
+
const skewed = globalV
|
|
73
|
+
? npxCopies.filter((i) => i.engineVersion && i.engineVersion !== globalV)
|
|
74
|
+
: [];
|
|
75
|
+
if (skewed.length > 0) {
|
|
76
|
+
const detail = skewed.map((i) => `${i.pkgDir} has ${i.engineVersion}`).join('; ');
|
|
77
|
+
return {
|
|
78
|
+
id, name, status: 'warn',
|
|
79
|
+
message: `engine version skew — global install has ${globalV}; ${detail}`,
|
|
80
|
+
hint: HINTS.engineVersionSkew,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
51
83
|
return {
|
|
52
84
|
id, name, status: 'ok',
|
|
53
85
|
message: `engine present in ${npxCopies.length} npx-cache ${plural(npxCopies.length, 'copy', 'copies')}`,
|
|
@@ -27,12 +27,21 @@ function defaultNpmCacheDir(platform) {
|
|
|
27
27
|
return path.join(os.homedir(), '.npm');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort `npm root -g`. Never throws; returns null on any failure.
|
|
32
|
+
* ⚠️ Windows needs shell:true — npm is a .cmd shim, and Node 24's
|
|
33
|
+
* CVE-2024-27980 hardening rejects .cmd via execFileSync without a shell
|
|
34
|
+
* (bare `npm` → ENOENT, `npm.cmd` → EINVAL). Without this the global install
|
|
35
|
+
* was invisible to the whole scan, which also blinded engine-repair's donor
|
|
36
|
+
* search: `doctor --fix` reported "no healthy sibling install" while one sat
|
|
37
|
+
* at %AppData%\npm\node_modules.
|
|
38
|
+
*/
|
|
39
|
+
function resolveNpmRootG({ execFileSync, platform } = {}) {
|
|
40
|
+
const win = (platform || process.platform) === 'win32';
|
|
32
41
|
try {
|
|
33
|
-
const
|
|
34
|
-
const out =
|
|
35
|
-
encoding: 'utf-8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
42
|
+
const exec = execFileSync || require('child_process').execFileSync;
|
|
43
|
+
const out = exec(win ? 'npm root -g' : 'npm', win ? [] : ['root', '-g'], {
|
|
44
|
+
encoding: 'utf-8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'], shell: win,
|
|
36
45
|
});
|
|
37
46
|
return String(out).trim() || null;
|
|
38
47
|
} catch (_e) {
|
|
@@ -45,13 +54,41 @@ function safe(fn, fallback) {
|
|
|
45
54
|
try { return fn(); } catch (_e) { return fallback; }
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the engine version from the roots already on the record. Reads
|
|
59
|
+
* opencode-ai's own package.json, which is a faithful proxy for the executed
|
|
60
|
+
* binary because opencode-ai exact-pins all 12 platform sub-packages.
|
|
61
|
+
* ⚠️ Do NOT read next to the binary: hasOpencodeBinary probes
|
|
62
|
+
* opencode-windows-<arch>/bin/opencode.exe on win32 but .bin/opencode on
|
|
63
|
+
* POSIX, and .bin/ has no package.json — a binary-adjacent rule would work on
|
|
64
|
+
* Windows only and silently return nothing on the two POSIX CI legs.
|
|
65
|
+
* Uses the real `fs` module directly (not a seam) because this is the
|
|
66
|
+
* PRODUCTION default — tests always inject `readEngineVersion` instead (the
|
|
67
|
+
* suite's fakeFs implements no readFileSync).
|
|
68
|
+
* @returns {string|undefined} undefined (never null) so toEqual fixtures survive
|
|
69
|
+
*/
|
|
70
|
+
function defaultReadEngineVersion({ roots }) {
|
|
71
|
+
for (const root of roots || []) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = require('fs').readFileSync(path.join(root, 'opencode-ai', 'package.json'), 'utf-8');
|
|
74
|
+
const v = JSON.parse(raw).version;
|
|
75
|
+
if (v) { return String(v); }
|
|
76
|
+
} catch (_e) { /* try the next root */ }
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve p's real path via fs.realpathSync, tolerating any throw. */
|
|
82
|
+
function realNorm(p, fs) {
|
|
83
|
+
return path.normalize(safe(() => fs.realpathSync(p), p));
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
/** Drop installs whose pkgDir resolves to the same real path; keep the first. */
|
|
49
87
|
function dedupByRealpath(installs, fs) {
|
|
50
88
|
const seen = new Set();
|
|
51
89
|
const out = [];
|
|
52
90
|
for (const inst of installs) {
|
|
53
|
-
const
|
|
54
|
-
const key = path.normalize(real);
|
|
91
|
+
const key = realNorm(inst.pkgDir, fs);
|
|
55
92
|
if (seen.has(key)) { continue; }
|
|
56
93
|
seen.add(key);
|
|
57
94
|
out.push(inst);
|
|
@@ -59,6 +96,21 @@ function dedupByRealpath(installs, fs) {
|
|
|
59
96
|
return out;
|
|
60
97
|
}
|
|
61
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True when pkgDir is the same real install as the npm-global amicus
|
|
101
|
+
* package (`<gRoot>/amicus`). Recovers "this record IS the global install"
|
|
102
|
+
* after dedupByRealpath has already dropped the separate `kind:'global'`
|
|
103
|
+
* record — see the docblock on scanEngineInstalls for why (#133 R-A finding
|
|
104
|
+
* 1: on the documented end-user invocation, `amicus doctor` run from the
|
|
105
|
+
* globally-installed copy, `runningPkgDir` IS `<npm root -g>/amicus`, and
|
|
106
|
+
* listAmicusInstalls pushes `running` first, so dedup keeps `running` and
|
|
107
|
+
* drops `global`).
|
|
108
|
+
*/
|
|
109
|
+
function isGlobalInstall({ pkgDir, fs, gRoot }) {
|
|
110
|
+
if (!gRoot) { return false; }
|
|
111
|
+
return realNorm(pkgDir, fs) === realNorm(path.join(gRoot, 'amicus'), fs);
|
|
112
|
+
}
|
|
113
|
+
|
|
62
114
|
/**
|
|
63
115
|
* The amicus installs that could serve the MCP, highest-priority first
|
|
64
116
|
* (running, global, then npx-cache copies). All I/O behind seams.
|
|
@@ -76,7 +128,7 @@ function listAmicusInstalls(deps = {}) {
|
|
|
76
128
|
const platform = deps.platform || process.platform;
|
|
77
129
|
const runningPkgDir = deps.runningPkgDir || path.join(__dirname, '..', '..');
|
|
78
130
|
const npmCacheDir = deps.npmCacheDir || defaultNpmCacheDir(platform);
|
|
79
|
-
const npmRootG = deps.npmRootG ||
|
|
131
|
+
const npmRootG = deps.npmRootG || (() => resolveNpmRootG({ platform }));
|
|
80
132
|
|
|
81
133
|
const raw = [{ kind: 'running', pkgDir: runningPkgDir }];
|
|
82
134
|
|
|
@@ -121,22 +173,53 @@ function classifyLaunch(config) {
|
|
|
121
173
|
* @param {object} [deps] - listAmicusInstalls seams, plus:
|
|
122
174
|
* @param {(d:{pkgDir:string}) => boolean} [deps.hasOpencodeBinary]
|
|
123
175
|
* @param {(d:{pkgDir:string}) => string[]} [deps.opencodeRoots]
|
|
176
|
+
* @param {(d:{pkgDir:string, roots:string[]}) => (string|undefined)} [deps.readEngineVersion]
|
|
124
177
|
* @param {() => (object|null)} [deps.readAmicusMcpConfig]
|
|
125
|
-
* @returns {{installs: Array<{kind,pkgDir,engineOk,roots}>, mcpLaunch: string}}
|
|
178
|
+
* @returns {{installs: Array<{kind,pkgDir,engineOk,roots,engineVersion,isGlobal?}>, mcpLaunch: string}}
|
|
126
179
|
*/
|
|
127
180
|
function scanEngineInstalls(deps = {}) {
|
|
181
|
+
const fs = deps.fs || require('fs');
|
|
182
|
+
const platform = deps.platform || process.platform;
|
|
128
183
|
const hasOpencodeBinary = deps.hasOpencodeBinary || require('./path-setup').hasOpencodeBinary;
|
|
129
184
|
const opencodeRoots = deps.opencodeRoots || require('./path-setup').opencodeRoots;
|
|
185
|
+
const readEngineVersion = deps.readEngineVersion || defaultReadEngineVersion;
|
|
130
186
|
const readAmicusMcpConfig = deps.readAmicusMcpConfig
|
|
131
187
|
|| (() => require('./mcp-discovery').readAmicusMcpConfig());
|
|
132
188
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
189
|
+
// Resolve `npm root -g` once and feed that SAME resolver into
|
|
190
|
+
// listAmicusInstalls (below), so the isGlobal recovery here reuses its
|
|
191
|
+
// result instead of spawning a second `npm root -g` process.
|
|
192
|
+
let gRootCache;
|
|
193
|
+
let gRootResolved = false;
|
|
194
|
+
const npmRootGRaw = deps.npmRootG || (() => resolveNpmRootG({ platform }));
|
|
195
|
+
const npmRootGOnce = () => {
|
|
196
|
+
if (!gRootResolved) { gRootCache = safe(() => npmRootGRaw(), null); gRootResolved = true; }
|
|
197
|
+
return gRootCache;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const rawInstalls = listAmicusInstalls({ ...deps, platform, npmRootG: npmRootGOnce });
|
|
201
|
+
const gRoot = npmRootGOnce(); // already resolved by listAmicusInstalls above; this just reads the cache
|
|
202
|
+
|
|
203
|
+
const installs = rawInstalls.map((i) => {
|
|
204
|
+
const roots = opencodeRoots({ pkgDir: i.pkgDir });
|
|
205
|
+
// #133 R-A finding 1: recovers "this IS the global install" for a
|
|
206
|
+
// record whose `kind:'global'` twin was dropped by dedupByRealpath
|
|
207
|
+
// (see isGlobalInstall's docblock). Deliberately NOT stamped inside
|
|
208
|
+
// listAmicusInstalls — its output is pinned exact by toEqual in
|
|
209
|
+
// tests/utils/engine-install-scan.test.js:57 and :89.
|
|
210
|
+
const isGlobal = i.kind !== 'global' && isGlobalInstall({ pkgDir: i.pkgDir, fs, gRoot });
|
|
211
|
+
return {
|
|
212
|
+
...i,
|
|
213
|
+
engineOk: !!hasOpencodeBinary({ pkgDir: i.pkgDir }),
|
|
214
|
+
roots,
|
|
215
|
+
engineVersion: safe(() => readEngineVersion({ pkgDir: i.pkgDir, roots }), undefined),
|
|
216
|
+
...(isGlobal ? { isGlobal: true } : {}),
|
|
217
|
+
};
|
|
218
|
+
});
|
|
138
219
|
const mcpLaunch = classifyLaunch(safe(() => readAmicusMcpConfig(), null));
|
|
139
220
|
return { installs, mcpLaunch };
|
|
140
221
|
}
|
|
141
222
|
|
|
142
|
-
module.exports = {
|
|
223
|
+
module.exports = {
|
|
224
|
+
listAmicusInstalls, scanEngineInstalls, classifyLaunch, resolveNpmRootG,
|
|
225
|
+
};
|
|
@@ -23,11 +23,105 @@ function runningPkgDir() {
|
|
|
23
23
|
return path.join(__dirname, '..', '..');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Parse a semver-shaped string into a [major, minor, patch] triple.
|
|
28
|
+
* Prerelease versions (`1.18.15-beta.1`) return null — treated the same as
|
|
29
|
+
* unparseable, so they sort LAST rather than tying with (and, via a stable
|
|
30
|
+
* sort, sometimes beating) the release they're a prerelease of. Only the
|
|
31
|
+
* exact-release leading triple is a valid donor signal; amicus pins exact
|
|
32
|
+
* release versions, never prereleases.
|
|
33
|
+
* @returns {[number,number,number]|null} null for undefined/non-string/non-semver/prerelease
|
|
34
|
+
*/
|
|
35
|
+
function parseVersionTriple(v) {
|
|
36
|
+
if (typeof v !== 'string') { return null; }
|
|
37
|
+
const trimmed = v.trim();
|
|
38
|
+
if (/^\d+\.\d+\.\d+-/.test(trimmed)) { return null; }
|
|
39
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(trimmed);
|
|
40
|
+
if (!m) { return null; }
|
|
41
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Descending version comparator: newest first, unparseable/absent sorts last
|
|
46
|
+
* (never throws). Two unparseable values compare equal (0).
|
|
47
|
+
*/
|
|
48
|
+
function compareVersionsDesc(a, b) {
|
|
49
|
+
const ta = parseVersionTriple(a);
|
|
50
|
+
const tb = parseVersionTriple(b);
|
|
51
|
+
if (!ta && !tb) { return 0; }
|
|
52
|
+
if (!ta) { return 1; }
|
|
53
|
+
if (!tb) { return -1; }
|
|
54
|
+
for (let i = 0; i < 3; i += 1) {
|
|
55
|
+
if (ta[i] !== tb[i]) { return tb[i] - ta[i]; }
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* TWO TIERS, not one sort. An explicitly-global healthy donor — `kind:'global'`,
|
|
62
|
+
* or `isGlobal:true` when engine-install-scan.js's dedup collapsed the
|
|
63
|
+
* `global` record into `running` (#133 R-A finding 1) — wins OUTRIGHT,
|
|
64
|
+
* regardless of version. Only when no explicit-global donor exists does
|
|
65
|
+
* engineVersion rank the remaining candidates (newest first; ties/absent
|
|
66
|
+
* versions fall back to list order, running-first).
|
|
67
|
+
*
|
|
68
|
+
* Review round 2, finding 2: a single version-first sort (this function's
|
|
69
|
+
* first cut) inverted R-A's own goal three ways, each confirmed by a test in
|
|
70
|
+
* tests/utils/engine-repair.test.js:
|
|
71
|
+
* 1. running (dev tree) newer than global → donated the dev tree. R-A's
|
|
72
|
+
* stated goal is "--fix stops donating the dev engine"; the dev tree
|
|
73
|
+
* running ahead of the pin is the NORMAL direction mid-pin-bump, not a
|
|
74
|
+
* reason to trust it over the global install.
|
|
75
|
+
* 2. running-that-is-global older than a healthy npx sibling → donated the
|
|
76
|
+
* npx sibling — the exact outcome the ORIGINAL (pre-engineVersion)
|
|
77
|
+
* findDonor test says must never happen, reintroduced via the source
|
|
78
|
+
* class this file's own scanEngineInstalls docblock calls LEAST
|
|
79
|
+
* trustworthy (npx-cache copies: optional-dependency skips, AV
|
|
80
|
+
* quarantine on every re-resolve).
|
|
81
|
+
* 3. global's version unresolved, npx sibling versioned → donated the npx
|
|
82
|
+
* copy despite a `global` record existing at all.
|
|
83
|
+
* All three share one cause: ranking by version BEFORE asking "is there an
|
|
84
|
+
* explicit global donor at all". Tiering fixes it without losing what the
|
|
85
|
+
* version ranking was FOR — Task 3's kind-only rule left a residual hole
|
|
86
|
+
* (dev checkout, no npm-global install, broken npx destination, healthy npx
|
|
87
|
+
* sibling — no record has kind:'global' at all) where the old code fell
|
|
88
|
+
* through to `healthy[0]`, the running dev tree, and could donate a
|
|
89
|
+
* version-skewed dev engine over a newer healthy sibling. That hole is
|
|
90
|
+
* exactly the case where tier 1 finds nothing and tier 2's version ranking
|
|
91
|
+
* takes over.
|
|
92
|
+
*
|
|
93
|
+
* A `kind !== 'running'` proxy for tier 1 is wrong: listAmicusInstalls pushes
|
|
94
|
+
* `running` first and `global` second, and dedupByRealpath keeps the FIRST of
|
|
95
|
+
* any two entries that resolve to the same real path. So on an ordinary
|
|
96
|
+
* end-user machine — where the running process IS the global install — the
|
|
97
|
+
* `global` record never survives dedup; that copy is labeled `kind:
|
|
98
|
+
* 'running'` (carrying `isGlobal:true` instead, once scanEngineInstalls has
|
|
99
|
+
* run). A `kind !== 'running'` filter would then skip the good global engine
|
|
100
|
+
* and donate some other (possibly stale) healthy copy, importing the exact
|
|
101
|
+
* version skew this self-heal exists to prevent.
|
|
102
|
+
*
|
|
103
|
+
* Tier 1 (`kind==='global' || isGlobal`) is correct on both topologies: on a
|
|
104
|
+
* dev machine the dev tree and the global install are distinct real paths, so
|
|
105
|
+
* the `global` record survives dedup and wins over the dev tree. On an
|
|
106
|
+
* end-user machine there is no separate `global` record — the running process
|
|
107
|
+
* already IS it, flagged `isGlobal:true` — so tier 1 still finds it. Tier 2
|
|
108
|
+
* (list-order fallback via a stable sort) only applies when tier 1 finds
|
|
109
|
+
* nothing at all — a pure dev checkout or npx-only machine.
|
|
110
|
+
*/
|
|
27
111
|
function findDonor({ installs, destPkgDir, fs }) {
|
|
28
112
|
const norm = (p) => { try { return path.normalize(fs.realpathSync(p)); } catch { return path.normalize(p); } };
|
|
29
113
|
const destReal = norm(destPkgDir);
|
|
30
|
-
|
|
114
|
+
const healthy = installs.filter((i) => i.engineOk && norm(i.pkgDir) !== destReal);
|
|
115
|
+
if (healthy.length === 0) { return null; }
|
|
116
|
+
|
|
117
|
+
const explicitGlobal = healthy.find((i) => i.kind === 'global' || i.isGlobal);
|
|
118
|
+
if (explicitGlobal) { return explicitGlobal; }
|
|
119
|
+
|
|
120
|
+
// No explicit-global donor on this machine at all — rank the remaining
|
|
121
|
+
// healthy candidates by engineVersion (newest first; Array#sort is stable,
|
|
122
|
+
// so ties/absent versions preserve list order, i.e. running-first).
|
|
123
|
+
const sorted = [...healthy].sort((a, b) => compareVersionsDesc(a.engineVersion, b.engineVersion));
|
|
124
|
+
return sorted[0];
|
|
31
125
|
}
|
|
32
126
|
|
|
33
127
|
/** The donor root (nested or hoisted) that actually holds the engine binary. */
|
|
@@ -88,6 +88,35 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
88
88
|
sweepSessionIndexTmp:
|
|
89
89
|
'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Engine version skew (#133 R-A): a PRESENT engine can still be the WRONG
|
|
93
|
+
* one — the npx-cache copy `npx -y amicus@latest mcp` launches and the
|
|
94
|
+
* global install resolve independently and at different times, and two
|
|
95
|
+
* engine versions writing one shared opencode.db is what produced #133's
|
|
96
|
+
* SQLiteError. Unlike reinstallEngineAv (antivirus quarantine — a MISSING
|
|
97
|
+
* binary), this is a WRONG-VERSION binary, so the remedy has to touch both
|
|
98
|
+
* sides: reinstall the global copy (which re-resolves to the exact pinned
|
|
99
|
+
* opencode-ai/@opencode-ai/sdk version amicus pins as of 4.7.1), and clear
|
|
100
|
+
* the npx-cache tree so the npx copy re-resolves fresh next time it
|
|
101
|
+
* launches instead of replaying a stale cached one.
|
|
102
|
+
*
|
|
103
|
+
* Review round 2, finding 3: the first cut of this hint said
|
|
104
|
+
* `npm cache clean --force`, which does NOT touch the npx tree — verified
|
|
105
|
+
* against npm's own lib/commands/cache.js (npm 11.16.0): `cache clean`
|
|
106
|
+
* removes `flatOptions.cache` (the registry-metadata cache, `<cache
|
|
107
|
+
* dir>/_cacache`), while npx installs live at the SEPARATE
|
|
108
|
+
* `flatOptions.npxCache` (`<cache dir>/_npx`). That command would have left
|
|
109
|
+
* the skewed npx copy byte-for-byte in place. The correct command is npm's
|
|
110
|
+
* own npx-cache subcommand: `npm cache npx rm` with no keys and `--force`
|
|
111
|
+
* empties the whole npx cache (cache.js's `npxRm`: no keys + force →
|
|
112
|
+
* `fs.rm(npxCache, {recursive:true, force:true})`). Since `doctor --fix`
|
|
113
|
+
* has no dedicated skew branch (WARN only — see doctor-engine-check.js),
|
|
114
|
+
* this hint is the ONLY remedy the user gets — it must actually work.
|
|
115
|
+
*/
|
|
116
|
+
engineVersionSkew:
|
|
117
|
+
'npm install -g amicus && npm cache npx rm --force (reinstalls the global engine to the exact pinned opencode-ai version — amicus pins it exactly as of 4.7.1 — '
|
|
118
|
+
+ 'and empties the npx cache, the SEPARATE directory `npm cache clean` does not touch, so the npx-cache copy `npx -y amicus@latest mcp` launches re-resolves fresh to that same pinned version instead of replaying a stale one)',
|
|
119
|
+
|
|
91
120
|
/**
|
|
92
121
|
* Orphaned per-session metadata.json.*.tmp files (v4.6.3 PR3 Task 3 / D8):
|
|
93
122
|
* same producer shape as sweepSessionIndexTmp above, one level down — a
|