opencode-herdr-orchestration 0.3.1 → 0.3.2
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/README.md +2 -0
- package/bin/orchestration.js +8 -1
- package/package.json +2 -2
- package/src/doctor.js +383 -0
package/README.md
CHANGED
|
@@ -752,6 +752,8 @@ npm run check
|
|
|
752
752
|
npm test
|
|
753
753
|
```
|
|
754
754
|
|
|
755
|
+
Launcher diagnostics are read-only via `node bin/orchestration.js doctor`, which reports the winning launcher plus ordered candidates plus per-candidate versions plus agreement plus spawn probe plus integration presence plus flapping signals as JSON plus a human summary with operator-executed remedies. See [Troubleshooting](#troubleshooting) with [Missing Herdr OpenCode integration](#missing-herdr-opencode-integration) and [Windows exe versus shim launcher resolution](#windows-exe-versus-shim-launcher-resolution) for symptom plus check plus verify lineage, and [Installation](#installation), [Manual Installation](#manual-installation), [Upgrade](#upgrade), and [Recovery](#recovery) for procedures instead of duplicating them here.
|
|
756
|
+
|
|
755
757
|
Tests cover topology, model variants, permissions, override merging, session mode isolation, response selection, signed cursors, UTF-8 pagination, concurrent response reads, tool authorization, orchestration state storage and access, Developer steering submission allowlists and denials, append-only ordering, concurrent submissions, unread detection, read-without-consume, restart recovery, idempotent consume, Plan ID isolation, lock and journal recovery, permission parity, absence of steering CLIs, shepherd ownership lifecycle validation, session plus generation fencing, non-owner denial, both-phase race fencing, submission during milestone with consumption after sheepdog yields, each mandatory sync point with disposition before consume, snapshots with pending consequential gating, correction routing with raw-record rejection, no consequential authorization, worker raw-access denial, sensitive-data exclusion, M2 regression, M4 regression across a real linked worktree common directory plus migration across service instances and processes plus full end to end M1 through M3 submit plus ownership plus sync plus consume plus fail closed conflicts, project permission skill disposable-repo end to end from human instruction to skill application to git diff to debug config plus negative global refusal plus denial plus consequential plus flock preservation with widened project tools, and Git hook behavior on protected, worker, review, governance, and planning pushes.
|
|
756
758
|
|
|
757
759
|
## Releases
|
package/bin/orchestration.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
writePluginConfig,
|
|
25
25
|
writeSteerCommand,
|
|
26
26
|
} from "../src/installer.js";
|
|
27
|
+
import { collectDoctorReport } from "../src/doctor.js";
|
|
27
28
|
|
|
28
29
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
29
30
|
const hooksPath = join(homedir(), ".config", "opencode-herdr-orchestration", "hooks");
|
|
@@ -190,6 +191,11 @@ function captureHookStatus() {
|
|
|
190
191
|
});
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
function runDoctor() {
|
|
195
|
+
const report = collectDoctorReport();
|
|
196
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
197
|
+
}
|
|
198
|
+
|
|
193
199
|
try {
|
|
194
200
|
if (command === "install") await installOrUpdate(false);
|
|
195
201
|
else if (command === "update") await installOrUpdate(true);
|
|
@@ -198,8 +204,9 @@ try {
|
|
|
198
204
|
else if (command === "install-hooks") installHooks();
|
|
199
205
|
else if (command === "uninstall-hooks") uninstallHooks();
|
|
200
206
|
else if (command === "status") fullStatus();
|
|
207
|
+
else if (command === "doctor") runDoctor();
|
|
201
208
|
else {
|
|
202
|
-
process.stderr.write("Usage: opencode-herdr-orchestration <install|update|configure-agents|status|uninstall|install-hooks|uninstall-hooks> [--with-hooks] [--force]\n");
|
|
209
|
+
process.stderr.write("Usage: opencode-herdr-orchestration <install|update|configure-agents|status|doctor|uninstall|install-hooks|uninstall-hooks> [--with-hooks] [--force]\n");
|
|
203
210
|
process.exitCode = 2;
|
|
204
211
|
}
|
|
205
212
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-herdr-orchestration",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Capability-separated Herdr orchestration agents for OpenCode",
|
|
5
5
|
"author": "CodingJinxx",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "node --test",
|
|
31
|
-
"check": "node --check src/plugin.js && node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check src/state.js && node --check src/diagnostics.js && node --check src/steer.js && node --check src/installer.js && node --check bin/orchestration.js"
|
|
31
|
+
"check": "node --check src/plugin.js && node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check src/state.js && node --check src/diagnostics.js && node --check src/steer.js && node --check src/installer.js && node --check src/doctor.js && node --check bin/orchestration.js"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
34
34
|
"node": ">=20"
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// Read-only launcher diagnostics (doctor M1).
|
|
2
|
+
// Inspects the Windows opencode launcher resolution without changing state.
|
|
3
|
+
// Every collector below only reads: PATH inspection, Get-Command ordering,
|
|
4
|
+
// per-candidate --version probes, spawn probe observation, integration
|
|
5
|
+
// presence checks, and flapping signals. No global environment mutation,
|
|
6
|
+
// no shell profile change, no file creation, and no Herdr or OpenCode
|
|
7
|
+
// invocation beyond read-only status and version queries.
|
|
8
|
+
// Operator-executed remedies are reported as text for the human operator;
|
|
9
|
+
// this module never applies them.
|
|
10
|
+
import spawn from "cross-spawn";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { platform } from "node:os";
|
|
14
|
+
import { delimiter, join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const DOCTOR_ANCHORS = Object.freeze([
|
|
17
|
+
"#installation",
|
|
18
|
+
"#manual-installation",
|
|
19
|
+
"#upgrade",
|
|
20
|
+
"#recovery",
|
|
21
|
+
"#troubleshooting",
|
|
22
|
+
"#missing-herdr-opencode-integration",
|
|
23
|
+
"#windows-exe-versus-shim-launcher-resolution",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const README_ANCHORS = DOCTOR_ANCHORS;
|
|
27
|
+
|
|
28
|
+
export const HEALTHY_OPENCODE_VERSION = "1.18.29";
|
|
29
|
+
export const HEALTHY_HERDR_VERSION = "0.8.2";
|
|
30
|
+
export const HEALTHY_INTEGRATION_STATUS = "opencode: current (v10)";
|
|
31
|
+
export const MISSING_INTEGRATION_STATUS = "opencode: not installed";
|
|
32
|
+
export const WIN32_SPAWN_ERROR = "%1 is not a valid Win32 application";
|
|
33
|
+
export const STABLE_CHANNEL = "stable";
|
|
34
|
+
|
|
35
|
+
function normalizeSource(source) {
|
|
36
|
+
return typeof source === "string" ? source.trim() : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function classifyLauncher(source) {
|
|
40
|
+
const value = normalizeSource(source);
|
|
41
|
+
if (!value) return { source: value, kind: "unknown" };
|
|
42
|
+
const lower = value.toLowerCase().replaceAll("/", "\\");
|
|
43
|
+
const base = value.split(/[\\/]/).pop().toLowerCase();
|
|
44
|
+
if (base === "opencode.cmd") return { source: value, kind: "shim-cmd" };
|
|
45
|
+
if (base === "opencode.ps1") return { source: value, kind: "shim-ps1" };
|
|
46
|
+
if (base === "opencode" || base === "opencode.sh") return { source: value, kind: "extensionless-shim" };
|
|
47
|
+
if (base === "opencode.exe") {
|
|
48
|
+
if (lower.includes("\\node_modules\\opencode-ai\\bin\\opencode.exe")) {
|
|
49
|
+
return { source: value, kind: "direct-exe" };
|
|
50
|
+
}
|
|
51
|
+
return { source: value, kind: "shim-exe" };
|
|
52
|
+
}
|
|
53
|
+
return { source: value, kind: "unknown" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function orderCandidates(sources) {
|
|
57
|
+
const list = Array.isArray(sources) ? sources : [];
|
|
58
|
+
const ordered = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const raw of list) {
|
|
61
|
+
const value = normalizeSource(raw);
|
|
62
|
+
if (!value || seen.has(value.toLowerCase())) continue;
|
|
63
|
+
seen.add(value.toLowerCase());
|
|
64
|
+
const classified = classifyLauncher(value);
|
|
65
|
+
ordered.push({ source: classified.source, kind: classified.kind, order: ordered.length });
|
|
66
|
+
}
|
|
67
|
+
return ordered;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseVersionText(text) {
|
|
71
|
+
if (typeof text !== "string") return null;
|
|
72
|
+
const match = text.match(/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/);
|
|
73
|
+
return match ? match[1] : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function checkVersionAgreement(versionEntries) {
|
|
77
|
+
const entries = Array.isArray(versionEntries) ? versionEntries : [];
|
|
78
|
+
const normalized = entries.map((entry) => ({
|
|
79
|
+
source: normalizeSource(entry?.source),
|
|
80
|
+
version: typeof entry?.version === "string" && entry.version.length > 0 ? entry.version : null,
|
|
81
|
+
error: typeof entry?.error === "string" && entry.error.length > 0 ? entry.error : null,
|
|
82
|
+
}));
|
|
83
|
+
const versions = normalized.map((entry) => entry.version);
|
|
84
|
+
const distinct = [...new Set(versions.filter((version) => version !== null))];
|
|
85
|
+
const allHaveVersion = normalized.length > 0 && normalized.every((entry) => entry.version !== null && entry.error === null);
|
|
86
|
+
const agree = allHaveVersion && distinct.length === 1;
|
|
87
|
+
return {
|
|
88
|
+
agree,
|
|
89
|
+
agreement: agree,
|
|
90
|
+
versions: normalized,
|
|
91
|
+
distinct,
|
|
92
|
+
detail: agree
|
|
93
|
+
? `All ${normalized.length} launcher(s) agree on ${distinct[0]}.`
|
|
94
|
+
: `Launchers disagree or are missing versions: ${normalized.map((entry) => `${entry.source || "(empty)"}=${entry.version || entry.error || "unknown"}`).join(", ") || "no candidates"}.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function evaluateSpawnProbe({ source, stdout, stderr, status }) {
|
|
99
|
+
const origin = normalizeSource(source);
|
|
100
|
+
const outText = typeof stdout === "string" ? stdout : "";
|
|
101
|
+
const errText = typeof stderr === "string" ? stderr : "";
|
|
102
|
+
const combined = `${outText}\n${errText}`;
|
|
103
|
+
if (combined.includes(WIN32_SPAWN_ERROR)) {
|
|
104
|
+
return { source: origin, ok: false, version: parseVersionText(outText), error: WIN32_SPAWN_ERROR };
|
|
105
|
+
}
|
|
106
|
+
if (status !== 0) {
|
|
107
|
+
const detail = errText.replace(/\s+/g, " ").trim().slice(0, 500) || `exit status ${String(status)}`;
|
|
108
|
+
return { source: origin, ok: false, version: parseVersionText(outText), error: detail };
|
|
109
|
+
}
|
|
110
|
+
const version = parseVersionText(outText);
|
|
111
|
+
if (!version) {
|
|
112
|
+
return { source: origin, ok: false, version: null, error: "no version in probe output" };
|
|
113
|
+
}
|
|
114
|
+
return { source: origin, ok: true, version, error: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function checkIntegrationPresence({ integrationStatusText, herdrHelpText, integrationInstallHelpText } = {}) {
|
|
118
|
+
const statusText = typeof integrationStatusText === "string" ? integrationStatusText : "";
|
|
119
|
+
const helpText = typeof herdrHelpText === "string" ? herdrHelpText : "";
|
|
120
|
+
const installHelpText = typeof integrationInstallHelpText === "string" ? integrationInstallHelpText : "";
|
|
121
|
+
const present = statusText.includes(HEALTHY_INTEGRATION_STATUS);
|
|
122
|
+
const missing = statusText.includes(MISSING_INTEGRATION_STATUS);
|
|
123
|
+
const hasIntegrationSubcommand = helpText.includes("herdr integration") || helpText.includes("integration <subcommand>");
|
|
124
|
+
const installListsOpencode = installHelpText.includes("opencode");
|
|
125
|
+
return {
|
|
126
|
+
present,
|
|
127
|
+
presence: present,
|
|
128
|
+
statusText: statusText ? statusText.slice(0, 2000) : null,
|
|
129
|
+
missing,
|
|
130
|
+
hasIntegrationSubcommand,
|
|
131
|
+
installListsOpencode,
|
|
132
|
+
detail: present
|
|
133
|
+
? `Integration present: ${HEALTHY_INTEGRATION_STATUS}.`
|
|
134
|
+
: `Integration not present: expected ${HEALTHY_INTEGRATION_STATUS}${missing ? ` (saw ${MISSING_INTEGRATION_STATUS})` : ""}.`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function detectFlapping({ candidates, versions, npmViewVersion } = {}) {
|
|
139
|
+
const ordered = Array.isArray(candidates) ? candidates : [];
|
|
140
|
+
const versionEntries = Array.isArray(versions) ? versions : [];
|
|
141
|
+
const npmVersion = typeof npmViewVersion === "string" && npmViewVersion.length > 0 ? npmViewVersion : null;
|
|
142
|
+
const hasShim = ordered.some((candidate) => {
|
|
143
|
+
const kind = candidate?.kind || classifyLauncher(candidate?.source || "").kind;
|
|
144
|
+
return kind === "shim-cmd" || kind === "shim-ps1" || kind === "shim-exe" || kind === "extensionless-shim";
|
|
145
|
+
});
|
|
146
|
+
const reappearingShim = ordered.length > 1 && hasShim;
|
|
147
|
+
const winnerVersion = versionEntries[0]?.version || null;
|
|
148
|
+
const bumpedVersion = Boolean(winnerVersion && npmVersion && winnerVersion !== npmVersion);
|
|
149
|
+
const distinct = [...new Set(versionEntries.map((entry) => entry?.version).filter(Boolean))];
|
|
150
|
+
const versionSplit = distinct.length > 1;
|
|
151
|
+
const signals = [];
|
|
152
|
+
if (reappearingShim) signals.push("reappearing shim among ordered candidates after a global update");
|
|
153
|
+
if (bumpedVersion) signals.push(`bumped launcher version ${winnerVersion} versus npm view ${npmVersion}`);
|
|
154
|
+
if (versionSplit) signals.push(`version split across candidates: ${distinct.join(", ")}`);
|
|
155
|
+
if (ordered.length > 1 && ordered[0]?.kind !== "direct-exe" && hasShim) {
|
|
156
|
+
signals.push("winner is a shim while a direct exe exists elsewhere in order");
|
|
157
|
+
}
|
|
158
|
+
const flapping = signals.length > 0;
|
|
159
|
+
return {
|
|
160
|
+
flapping,
|
|
161
|
+
reappearingShim,
|
|
162
|
+
bumpedVersion,
|
|
163
|
+
versionSplit,
|
|
164
|
+
npmViewVersion: npmVersion,
|
|
165
|
+
signals,
|
|
166
|
+
detail: flapping ? `Flapping signals: ${signals.join("; ")}.` : "No flapping signals.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function describeCandidate(candidate, index) {
|
|
171
|
+
return `${index}: ${candidate.source} [${candidate.kind}]`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function formatHumanSummary(report) {
|
|
175
|
+
const winner = report?.winner ? `${report.winner.source} [${report.winner.kind}]` : "(no winner)";
|
|
176
|
+
const candidates = Array.isArray(report?.candidates) ? report.candidates : [];
|
|
177
|
+
const versions = Array.isArray(report?.versions) ? report.versions : [];
|
|
178
|
+
const agreement = report?.agreement || {};
|
|
179
|
+
const spawnProbe = report?.spawnProbe || {};
|
|
180
|
+
const integration = report?.integration || {};
|
|
181
|
+
const flapping = report?.flapping || {};
|
|
182
|
+
const lines = [];
|
|
183
|
+
lines.push("Launcher diagnostics (read-only, no changes applied).");
|
|
184
|
+
lines.push(`Winner per (Get-Command opencode).Source: ${winner}.`);
|
|
185
|
+
lines.push(`Ordered candidates per (Get-Command opencode -All).Source (${candidates.length}):`);
|
|
186
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
187
|
+
const version = versions[index]?.version || versions[index]?.error || "unknown";
|
|
188
|
+
lines.push(`- ${describeCandidate(candidates[index], index)} reports ${version}.`);
|
|
189
|
+
}
|
|
190
|
+
lines.push(
|
|
191
|
+
agreement.agree
|
|
192
|
+
? `Agreement: all candidates agree on ${agreement.distinct?.[0] || HEALTHY_OPENCODE_VERSION}.`
|
|
193
|
+
: `Agreement: ${agreement.detail || "versions disagree"}. Compare opencode --version against each full-path exe and shim form; healthy hosts report the same ${HEALTHY_OPENCODE_VERSION} from each.`,
|
|
194
|
+
);
|
|
195
|
+
lines.push(
|
|
196
|
+
spawnProbe.ok
|
|
197
|
+
? `Spawn probe: Start-Process with -FilePath from the resolved launcher plus -ArgumentList --version -NoNewWindow -Wait reports ${spawnProbe.version} without ${WIN32_SPAWN_ERROR}.`
|
|
198
|
+
: `Spawn probe: Start-Process with -FilePath from ${spawnProbe.source || "winner"} plus -ArgumentList --version -NoNewWindow -Wait did not succeed (${spawnProbe.error || "unknown"}); live the extensionless shim fails with ${WIN32_SPAWN_ERROR} while the direct exe reports ${HEALTHY_OPENCODE_VERSION}.`,
|
|
199
|
+
);
|
|
200
|
+
lines.push(
|
|
201
|
+
integration.present
|
|
202
|
+
? `Integration presence: herdr integration status shows ${HEALTHY_INTEGRATION_STATUS}. herdr --help lists herdr integration <subcommand> and herdr integration install --help lists opencode as a valid target.`
|
|
203
|
+
: `Integration presence: herdr integration status does not show ${HEALTHY_INTEGRATION_STATUS}${integration.missing ? ` (saw ${MISSING_INTEGRATION_STATUS})` : ""}. Check opencode agent list plus opencode debug agent shepherd plus node bin/orchestration.js status.`,
|
|
204
|
+
);
|
|
205
|
+
lines.push(
|
|
206
|
+
flapping.flapping
|
|
207
|
+
? `Flapping signals: ${flapping.detail || flapping.signals?.join("; ")}. A global opencode upgrade recreates the npm shims, so a previously durable order can flap back to shim-first with a bumped opencode --version versus npm view opencode-ai version. Re-inspect (Get-Command opencode -All).Source for a reappearing shim.`
|
|
208
|
+
: "Flapping signals: none. No reappearing shim and no bumped version versus npm view opencode-ai version.",
|
|
209
|
+
);
|
|
210
|
+
lines.push(`PATH inspected read-only via $env:PATH plus (Get-Command herdr).Source plus (Get-Command herdr -All).Source; Herdr resolves to a single bin exe with no shim confusion per herdr --version ${HEALTHY_HERDR_VERSION}. Confirm the server split with herdr status showing status: running for server separate from client. Autoupdate context is npm view opencode-herdr-orchestration version plus herdr channel show reporting ${STABLE_CHANNEL} plus herdr update --help plus opencode upgrade --help.`);
|
|
211
|
+
lines.push("Operator-executed remedies (this package never overwrites the global environment and never sets global environment values automatically):");
|
|
212
|
+
lines.push("- For current-session relief use the winning full exe path directly.");
|
|
213
|
+
lines.push("- For a durable fix apply a persistent user-chosen PATH reorder that places the direct exe directory before the npm shim directory, then restart Herdr plus the terminal plus OpenCode intentionally when ready.");
|
|
214
|
+
lines.push("- Session-local changes alone never fix Herdr spawns because panes inherit the Herdr server environment; the persistent reorder is an operator-chosen Windows user environment change, not a package change.");
|
|
215
|
+
lines.push("See [Installation](#installation), [Manual Installation](#manual-installation), [Upgrade](#upgrade), and [Recovery](#recovery) for procedures instead of duplicating them here, with [Troubleshooting](#troubleshooting) plus [Missing Herdr OpenCode integration](#missing-herdr-opencode-integration) plus [Windows exe versus shim launcher resolution](#windows-exe-versus-shim-launcher-resolution) for symptom plus check plus verify lineage.");
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function buildDoctorReport({ candidates, versions, spawnProbe, integration, flapping, pathEntries } = {}) {
|
|
220
|
+
const ordered = orderCandidates(candidates || []);
|
|
221
|
+
const versionEntries = Array.isArray(versions) && versions.length > 0
|
|
222
|
+
? versions.map((entry, index) => ({
|
|
223
|
+
source: normalizeSource(entry?.source || ordered[index]?.source || ""),
|
|
224
|
+
version: typeof entry?.version === "string" && entry.version ? entry.version : null,
|
|
225
|
+
error: typeof entry?.error === "string" && entry.error ? entry.error : null,
|
|
226
|
+
}))
|
|
227
|
+
: ordered.map((candidate) => ({ source: candidate.source, version: null, error: "unknown" }));
|
|
228
|
+
const agreement = checkVersionAgreement(versionEntries);
|
|
229
|
+
const winner = ordered[0] || null;
|
|
230
|
+
const probe = spawnProbe && typeof spawnProbe === "object" && spawnProbe.source
|
|
231
|
+
? spawnProbe
|
|
232
|
+
: { source: winner?.source || null, ok: false, version: versionEntries[0]?.version || null, error: versionEntries[0]?.error || "unknown" };
|
|
233
|
+
const integrationResult = integration && typeof integration === "object" ? integration : checkIntegrationPresence({});
|
|
234
|
+
const flappingResult = flapping && typeof flapping === "object" && Array.isArray(flapping.signals)
|
|
235
|
+
? flapping
|
|
236
|
+
: { flapping: false, reappearingShim: false, bumpedVersion: false, versionSplit: false, npmViewVersion: null, signals: [], detail: "No flapping signals." };
|
|
237
|
+
const pathList = Array.isArray(pathEntries) ? pathEntries : [];
|
|
238
|
+
const base = {
|
|
239
|
+
tool: "doctor",
|
|
240
|
+
readOnly: true,
|
|
241
|
+
winner,
|
|
242
|
+
candidates: ordered,
|
|
243
|
+
orderedCandidates: ordered,
|
|
244
|
+
versions: versionEntries,
|
|
245
|
+
candidateVersions: versionEntries,
|
|
246
|
+
agreement,
|
|
247
|
+
spawnProbe: probe,
|
|
248
|
+
spawnProbes: versionEntries.map((entry) => ({
|
|
249
|
+
source: entry.source,
|
|
250
|
+
ok: entry.version !== null && entry.error === null,
|
|
251
|
+
version: entry.version,
|
|
252
|
+
error: entry.error,
|
|
253
|
+
})),
|
|
254
|
+
integration: integrationResult,
|
|
255
|
+
integrationPresence: integrationResult,
|
|
256
|
+
flapping: flappingResult,
|
|
257
|
+
flappingSignals: flappingResult.signals || [],
|
|
258
|
+
path: { entries: pathList, inspectedReadOnly: true },
|
|
259
|
+
remedies: [
|
|
260
|
+
"For current-session relief use the winning full exe path directly.",
|
|
261
|
+
"For a durable fix apply a persistent user-chosen PATH reorder that places the direct exe directory before the npm shim directory, then restart Herdr plus the terminal plus OpenCode intentionally when ready.",
|
|
262
|
+
"Session-local changes alone never fix Herdr spawns because panes inherit the Herdr server environment.",
|
|
263
|
+
],
|
|
264
|
+
anchors: [...DOCTOR_ANCHORS],
|
|
265
|
+
readmeAnchors: [...DOCTOR_ANCHORS],
|
|
266
|
+
};
|
|
267
|
+
const summary = formatHumanSummary(base);
|
|
268
|
+
return {
|
|
269
|
+
...base,
|
|
270
|
+
operatorRemedies: [...base.remedies],
|
|
271
|
+
summary,
|
|
272
|
+
humanSummary: summary,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function runCrossSpawnVersion(candidate) {
|
|
277
|
+
try {
|
|
278
|
+
const result = spawn.sync(candidate, ["--version"], { encoding: "utf8", windowsHide: true, timeout: 15000 });
|
|
279
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
280
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : (result.error ? String(result.error.message || result.error) : "");
|
|
281
|
+
const version = parseVersionText(stdout);
|
|
282
|
+
if (result.error) {
|
|
283
|
+
return { source: candidate, version, error: String(result.error.message || result.error).slice(0, 500) };
|
|
284
|
+
}
|
|
285
|
+
if (result.status !== 0) {
|
|
286
|
+
return { source: candidate, version, error: (stderr || `exit status ${String(result.status)}`).replace(/\s+/g, " ").trim().slice(0, 500) };
|
|
287
|
+
}
|
|
288
|
+
if (!version) return { source: candidate, version: null, error: "no version in output" };
|
|
289
|
+
return { source: candidate, version, error: null };
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return { source: candidate, version: null, error: String(error?.message || error).slice(0, 500) };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function runHelpCapture(command, args) {
|
|
296
|
+
try {
|
|
297
|
+
const result = spawn.sync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15000 });
|
|
298
|
+
if (result.error) return "";
|
|
299
|
+
return typeof result.stdout === "string" ? result.stdout : "";
|
|
300
|
+
} catch {
|
|
301
|
+
return "";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function collectLiveCandidates(envPath) {
|
|
306
|
+
const currentPlatform = platform();
|
|
307
|
+
if (currentPlatform === "win32") {
|
|
308
|
+
try {
|
|
309
|
+
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "(Get-Command opencode -All).Source"], {
|
|
310
|
+
encoding: "utf8",
|
|
311
|
+
windowsHide: true,
|
|
312
|
+
timeout: 15000,
|
|
313
|
+
});
|
|
314
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
315
|
+
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
316
|
+
if (lines.length > 0) return lines;
|
|
317
|
+
} catch {}
|
|
318
|
+
}
|
|
319
|
+
const raw = typeof envPath === "string" ? envPath : "";
|
|
320
|
+
const entries = raw.split(delimiter).map((entry) => entry.trim()).filter(Boolean);
|
|
321
|
+
const found = [];
|
|
322
|
+
const names = currentPlatform === "win32" ? ["opencode.cmd", "opencode.ps1", "opencode.exe", "opencode"] : ["opencode"];
|
|
323
|
+
for (const dir of entries) {
|
|
324
|
+
for (const name of names) {
|
|
325
|
+
const candidate = join(dir.replace(/[\\/]+$/, ""), name);
|
|
326
|
+
try {
|
|
327
|
+
if (existsSync(candidate)) found.push(candidate);
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
if (found.length >= 8) break;
|
|
331
|
+
}
|
|
332
|
+
return found;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function collectLivePathEntries(envPath) {
|
|
336
|
+
const raw = typeof envPath === "string" ? envPath : "";
|
|
337
|
+
return raw.split(delimiter).map((entry) => entry.trim()).filter(Boolean);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function collectDoctorReport(options = {}) {
|
|
341
|
+
const envPath = options.envPath !== undefined ? options.envPath : (process.env.PATH || process.env.Path || "");
|
|
342
|
+
const liveCandidates = options.candidates !== undefined ? options.candidates : collectLiveCandidates(envPath);
|
|
343
|
+
const ordered = orderCandidates(liveCandidates);
|
|
344
|
+
const liveVersions = options.versions !== undefined
|
|
345
|
+
? options.versions
|
|
346
|
+
: ordered.map((candidate) => runCrossSpawnVersion(candidate.source));
|
|
347
|
+
const agreement = checkVersionAgreement(liveVersions);
|
|
348
|
+
const winnerProbeInput = options.spawnProbe !== undefined ? options.spawnProbe : (() => {
|
|
349
|
+
const winnerSource = ordered[0]?.source;
|
|
350
|
+
if (!winnerSource) return { source: null, ok: false, version: null, error: "no candidates" };
|
|
351
|
+
const match = liveVersions[0];
|
|
352
|
+
if (match && match.error && String(match.error).includes(WIN32_SPAWN_ERROR)) {
|
|
353
|
+
return evaluateSpawnProbe({ source: winnerSource, stdout: "", stderr: match.error, status: 1 });
|
|
354
|
+
}
|
|
355
|
+
if (match && match.version) {
|
|
356
|
+
return { source: winnerSource, ok: match.error === null, version: match.version, error: match.error };
|
|
357
|
+
}
|
|
358
|
+
return { source: winnerSource, ok: false, version: null, error: (match && match.error) || "unknown" };
|
|
359
|
+
})();
|
|
360
|
+
const integration = options.integration !== undefined ? options.integration : checkIntegrationPresence({
|
|
361
|
+
integrationStatusText: runHelpCapture("herdr", ["integration", "status"]),
|
|
362
|
+
herdrHelpText: runHelpCapture("herdr", ["--help"]),
|
|
363
|
+
integrationInstallHelpText: runHelpCapture("herdr", ["integration", "install", "--help"]),
|
|
364
|
+
});
|
|
365
|
+
let npmViewVersion = null;
|
|
366
|
+
if (options.npmViewVersion !== undefined) {
|
|
367
|
+
npmViewVersion = options.npmViewVersion;
|
|
368
|
+
} else {
|
|
369
|
+
const npmText = runHelpCapture("npm", ["view", "opencode-ai", "version"]);
|
|
370
|
+
npmViewVersion = parseVersionText(npmText);
|
|
371
|
+
}
|
|
372
|
+
const flapping = options.flapping !== undefined
|
|
373
|
+
? options.flapping
|
|
374
|
+
: detectFlapping({ candidates: ordered, versions: liveVersions, npmViewVersion });
|
|
375
|
+
return buildDoctorReport({
|
|
376
|
+
candidates: ordered.map((candidate) => candidate.source),
|
|
377
|
+
versions: liveVersions,
|
|
378
|
+
spawnProbe: winnerProbeInput,
|
|
379
|
+
integration,
|
|
380
|
+
flapping,
|
|
381
|
+
pathEntries: collectLivePathEntries(envPath),
|
|
382
|
+
});
|
|
383
|
+
}
|