@heretyc/subagent-mcp 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +124 -0
- package/directives/carryover-claude.md +17 -0
- package/directives/carryover-codex.md +17 -0
- package/directives/off-turn-reminder.md +1 -0
- package/directives/orchestration-claude.md +21 -0
- package/directives/orchestration-codex.md +22 -0
- package/dist/advanced-ruleset.py +67 -0
- package/dist/deadlock.js +8 -0
- package/dist/doctor.js +32 -0
- package/dist/effort.js +78 -0
- package/dist/hooks/orchestration-claude.js +88 -0
- package/dist/hooks/orchestration-codex.js +152 -0
- package/dist/index.js +908 -0
- package/dist/orchestration/hook-core.js +208 -0
- package/dist/orchestration/marker.js +139 -0
- package/dist/output-helpers.js +128 -0
- package/dist/platform.js +59 -0
- package/dist/routing-table.json +3821 -0
- package/dist/routing.js +260 -0
- package/dist/ruleset-scaffold.js +2 -0
- package/dist/ruleset.js +319 -0
- package/dist/setup.js +507 -0
- package/dist/status-helpers.js +56 -0
- package/dist/stream-helpers.js +182 -0
- package/dist/wait-helpers.js +21 -0
- package/package.json +51 -0
- package/scripts/postinstall.mjs +102 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import * as marker from "./marker.js";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the repo-root `directives/` dir at runtime. Honors an explicit plugin
|
|
8
|
+
* root (Claude sets CLAUDE_PLUGIN_ROOT; a generic PLUGIN_ROOT is also accepted)
|
|
9
|
+
* so the bundled plugin finds its assets wherever it is installed. Otherwise we
|
|
10
|
+
* walk up from the COMPILED file location: dist/hooks/<x>.js -> ../../directives
|
|
11
|
+
* === <repoRoot>/directives.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveDirectivesDir(env) {
|
|
14
|
+
const root = env.CLAUDE_PLUGIN_ROOT || env.PLUGIN_ROOT;
|
|
15
|
+
if (root) {
|
|
16
|
+
return join(root, "directives");
|
|
17
|
+
}
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
// Compiled location is dist/orchestration/hook-core.js, so ../../directives
|
|
20
|
+
// is the repo root's directives dir; the entry shims live at dist/hooks/<x>.js
|
|
21
|
+
// and import this module, but __dirname here is the hook-core module's own
|
|
22
|
+
// dir. Two levels up from dist/orchestration is the repo root either way.
|
|
23
|
+
return join(here, "..", "..", "directives");
|
|
24
|
+
}
|
|
25
|
+
/** Read a directive asset by filename. On ANY failure return '' (fail-safe). */
|
|
26
|
+
export function readDirective(env, fileName) {
|
|
27
|
+
try {
|
|
28
|
+
return readFileSync(join(resolveDirectivesDir(env), fileName), "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Hard cap on how many bytes of a transcript we will read when counting turns.
|
|
36
|
+
* Transcripts grow without bound over a long session, and the hook runs INLINE
|
|
37
|
+
* on every UserPromptSubmit before the prompt is sent — an unbounded
|
|
38
|
+
* readFileSync(...,'utf8') + full split('\n') is O(file size) per turn (O(n^2)
|
|
39
|
+
* over a session) and a multi-hundred-MB (or attacker-supplied) transcript_path
|
|
40
|
+
* could stall the user's turn for seconds or OOM the hook. We therefore read at
|
|
41
|
+
* most the trailing TRANSCRIPT_READ_CAP bytes.
|
|
42
|
+
*/
|
|
43
|
+
export const TRANSCRIPT_READ_CAP = 16 * 1024 * 1024; // 16 MB
|
|
44
|
+
/**
|
|
45
|
+
* Count JSONL lines in a transcript whose parsed object.type === `wantedType`,
|
|
46
|
+
* reading at most the trailing TRANSCRIPT_READ_CAP bytes (the most recent turns
|
|
47
|
+
* are at the end of the file). Fully fail-safe: any error -> 0, which makes the
|
|
48
|
+
* caller emit FULL (a visible directive) rather than silently suppressing.
|
|
49
|
+
*
|
|
50
|
+
* For a tail read we drop the first (likely partial) line of the window so we
|
|
51
|
+
* never mis-parse a line cut in half by the cap boundary. Under-counting by at
|
|
52
|
+
* most one line at the boundary is acceptable for cadence; over a 16 MB window
|
|
53
|
+
* the relative turn count stays stable across consecutive turns.
|
|
54
|
+
*/
|
|
55
|
+
export function countJsonlType(transcriptPath, wantedType) {
|
|
56
|
+
if (!transcriptPath)
|
|
57
|
+
return 0;
|
|
58
|
+
let fd;
|
|
59
|
+
try {
|
|
60
|
+
const size = statSync(transcriptPath).size;
|
|
61
|
+
let raw;
|
|
62
|
+
let droppedPartialHead = false;
|
|
63
|
+
if (size <= TRANSCRIPT_READ_CAP) {
|
|
64
|
+
raw = readFileSync(transcriptPath, "utf8");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Read only the trailing window via an fd so we never materialize the
|
|
68
|
+
// whole file. The first line of the window is probably truncated.
|
|
69
|
+
const start = size - TRANSCRIPT_READ_CAP;
|
|
70
|
+
const buf = Buffer.allocUnsafe(TRANSCRIPT_READ_CAP);
|
|
71
|
+
fd = openSync(transcriptPath, "r");
|
|
72
|
+
let offset = 0;
|
|
73
|
+
let pos = start;
|
|
74
|
+
while (offset < TRANSCRIPT_READ_CAP) {
|
|
75
|
+
const bytes = readSync(fd, buf, offset, TRANSCRIPT_READ_CAP - offset, pos);
|
|
76
|
+
if (bytes <= 0)
|
|
77
|
+
break;
|
|
78
|
+
offset += bytes;
|
|
79
|
+
pos += bytes;
|
|
80
|
+
}
|
|
81
|
+
raw = buf.toString("utf8", 0, offset);
|
|
82
|
+
droppedPartialHead = true;
|
|
83
|
+
}
|
|
84
|
+
let count = 0;
|
|
85
|
+
let first = true;
|
|
86
|
+
for (const line of raw.split("\n")) {
|
|
87
|
+
// Drop the first (partial) line only when we read a tail window.
|
|
88
|
+
if (first) {
|
|
89
|
+
first = false;
|
|
90
|
+
if (droppedPartialHead)
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed)
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
const obj = JSON.parse(trimmed);
|
|
98
|
+
if (obj && obj.type === wantedType)
|
|
99
|
+
count++;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Skip unparseable lines; never throw.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return count;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (fd !== undefined) {
|
|
112
|
+
try {
|
|
113
|
+
closeSync(fd);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Best-effort close; never throw.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Return the stable key used to compare hook claims. Some hosts omit
|
|
123
|
+
* session_id; transcript_path is per-session, so a short hash keeps the claim
|
|
124
|
+
* sticky without changing classifyClaim's string/undefined contract.
|
|
125
|
+
*/
|
|
126
|
+
export function sessionKey(payload) {
|
|
127
|
+
if (typeof payload.session_id === "string") {
|
|
128
|
+
return payload.session_id;
|
|
129
|
+
}
|
|
130
|
+
if (typeof payload.transcript_path === "string" &&
|
|
131
|
+
payload.transcript_path.length > 0) {
|
|
132
|
+
return ("tp-" +
|
|
133
|
+
createHash("sha256")
|
|
134
|
+
.update(payload.transcript_path, "utf8")
|
|
135
|
+
.digest("hex")
|
|
136
|
+
.slice(0, 16));
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
export function classifyClaim(owner_session, baseline_turn, current) {
|
|
141
|
+
if (baseline_turn == null || owner_session == null) {
|
|
142
|
+
return "fresh";
|
|
143
|
+
}
|
|
144
|
+
// owner_session is a real string here.
|
|
145
|
+
if (current === undefined || owner_session !== current) {
|
|
146
|
+
return "carryover";
|
|
147
|
+
}
|
|
148
|
+
return "same";
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Core hook logic. Returns the string to inject, or '' to inject nothing.
|
|
152
|
+
*
|
|
153
|
+
* Order:
|
|
154
|
+
* 1. subagent -> '' (a subagent must never be nagged to delegate).
|
|
155
|
+
* 2. marker not active for cwd -> '' (OFF; zero emission).
|
|
156
|
+
* 3. read current turn + marker state, classify the claim.
|
|
157
|
+
* 4. FRESH (never claimed) -> claim + baseline at this turn, persist, emit FULL
|
|
158
|
+
* (this is the freshly-enabled turn, relTurn 0).
|
|
159
|
+
* 5. CARRYOVER (owned by another/prior session) -> re-claim + re-baseline at
|
|
160
|
+
* this turn, persist, emit the CARRYOVER notice prepended to FULL only
|
|
161
|
+
* before the marker's carryover_ack has latched.
|
|
162
|
+
* 6. SAME-SESSION -> rel = turn - baseline; FULL when rel % 5 === 0, else
|
|
163
|
+
* off-turn.
|
|
164
|
+
*/
|
|
165
|
+
export function runHook(payload, env, adapter) {
|
|
166
|
+
try {
|
|
167
|
+
if (adapter.isSubagent(payload, env)) {
|
|
168
|
+
return "";
|
|
169
|
+
}
|
|
170
|
+
const cwd = payload.cwd || process.cwd();
|
|
171
|
+
if (!marker.isActive(cwd)) {
|
|
172
|
+
return "";
|
|
173
|
+
}
|
|
174
|
+
const current = sessionKey(payload);
|
|
175
|
+
const turn = adapter.currentTurn(payload.transcript_path);
|
|
176
|
+
const m = marker.readMarker(cwd);
|
|
177
|
+
const kind = classifyClaim(m.owner_session, m.baseline_turn, current);
|
|
178
|
+
if (kind === "fresh") {
|
|
179
|
+
m.baseline_turn = turn;
|
|
180
|
+
m.owner_session = current ?? null;
|
|
181
|
+
marker.writeMarker(cwd, m);
|
|
182
|
+
return readDirective(env, adapter.fullDirectiveFile);
|
|
183
|
+
}
|
|
184
|
+
if (kind === "carryover") {
|
|
185
|
+
// Re-claim for the current session and re-baseline at this turn so the
|
|
186
|
+
// notice fires once per project marker. The ack survives re-claims, so
|
|
187
|
+
// sub-agent/parallel-session marker ping-pong cannot re-fire it.
|
|
188
|
+
const firstTime = !m.carryover_ack;
|
|
189
|
+
m.baseline_turn = turn;
|
|
190
|
+
m.owner_session = current ?? null;
|
|
191
|
+
m.provenance = "carried-over";
|
|
192
|
+
m.carryover_ack = true;
|
|
193
|
+
marker.writeMarker(cwd, m);
|
|
194
|
+
return firstTime
|
|
195
|
+
? readDirective(env, adapter.carryoverDirectiveFile) +
|
|
196
|
+
readDirective(env, adapter.fullDirectiveFile)
|
|
197
|
+
: readDirective(env, adapter.fullDirectiveFile);
|
|
198
|
+
}
|
|
199
|
+
const rel = turn - m.baseline_turn;
|
|
200
|
+
return rel % 5 === 0
|
|
201
|
+
? readDirective(env, adapter.fullDirectiveFile)
|
|
202
|
+
: readDirective(env, adapter.offTurnFile);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Any failure -> inject nothing. Never crash or stall the host turn.
|
|
206
|
+
return "";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
const markerDir = join(tmpdir(), "subagent-mcp");
|
|
6
|
+
/**
|
|
7
|
+
* Canonicalize a working directory so two spellings of the same path hash
|
|
8
|
+
* identically. Strip a leading Windows \\?\ extended-length prefix FIRST (on
|
|
9
|
+
* the raw input) — resolve() canonicalizes that prefix away, so stripping after
|
|
10
|
+
* resolve is dead code and an extended-length cwd would otherwise hash
|
|
11
|
+
* differently from its plain form. Then resolve() to an absolute path; use
|
|
12
|
+
* forward slashes; lowercase on win32 (the FS is case-insensitive there); drop
|
|
13
|
+
* a trailing slash.
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeCwd(cwd) {
|
|
16
|
+
let raw = cwd;
|
|
17
|
+
if (raw.startsWith("\\\\?\\")) {
|
|
18
|
+
raw = raw.slice(4);
|
|
19
|
+
}
|
|
20
|
+
let p = resolve(raw);
|
|
21
|
+
p = p.replace(/\\/g, "/");
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
p = p.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
if (p.length > 1 && p.endsWith("/")) {
|
|
26
|
+
p = p.slice(0, -1);
|
|
27
|
+
}
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
export function cwdHash(cwd) {
|
|
31
|
+
return createHash("sha256")
|
|
32
|
+
.update(normalizeCwd(cwd), "utf8")
|
|
33
|
+
.digest("hex")
|
|
34
|
+
.slice(0, 16);
|
|
35
|
+
}
|
|
36
|
+
export function markerPath(cwd) {
|
|
37
|
+
return join(markerDir, "orch-" + cwdHash(cwd) + ".flag");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Enable orchestration for cwd. ALWAYS overwrites — re-enabling re-baselines by
|
|
41
|
+
* clearing owner_session/baseline_turn back to null so the next hook turn
|
|
42
|
+
* re-claims and re-baselines.
|
|
43
|
+
*/
|
|
44
|
+
export function enable(cwd) {
|
|
45
|
+
try {
|
|
46
|
+
// Restrictive POSIX perms: the marker dir/file live in the shared,
|
|
47
|
+
// world-readable /tmp on Linux/macOS and persist a session_id. mode 0o700/
|
|
48
|
+
// 0o600 keeps them owner-only so other local users cannot read the
|
|
49
|
+
// session_id or enumerate which projects have orchestration enabled. mode is
|
|
50
|
+
// ignored on Windows (harmless; tmpdir is already per-user there).
|
|
51
|
+
mkdirSync(markerDir, { recursive: true, mode: 0o700 });
|
|
52
|
+
const state = {
|
|
53
|
+
owner_session: null,
|
|
54
|
+
baseline_turn: null,
|
|
55
|
+
provenance: "user-enabled",
|
|
56
|
+
carryover_ack: false,
|
|
57
|
+
};
|
|
58
|
+
writeFileSync(markerPath(cwd), JSON.stringify(state), { encoding: "utf8", mode: 0o600 });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Fail-safe: never throw to the caller.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Disable orchestration for cwd by removing the marker.
|
|
66
|
+
*
|
|
67
|
+
* No existsSync() guard: that only opens a TOCTOU window where a concurrent
|
|
68
|
+
* clearForCwd/disable for the same cwd removes the file between the check and
|
|
69
|
+
* the unlink. We just unlink and swallow ENOENT (already-gone is success).
|
|
70
|
+
*
|
|
71
|
+
* KNOWN LIMITATION: the marker is keyed by cwd, NOT by session. Two CLI
|
|
72
|
+
* sessions in the same project share one marker, so their enable/disable
|
|
73
|
+
* interleave and the last writer wins. Per-session isolation would require
|
|
74
|
+
* keying the marker by cwd+session_id; not done here because LOCKED DECISION 1
|
|
75
|
+
* keys the marker by working directory alone. (The hook tracks the owning
|
|
76
|
+
* session via owner_session so a carried-over marker is re-claimed, but the
|
|
77
|
+
* marker file itself is still shared per cwd.)
|
|
78
|
+
*/
|
|
79
|
+
export function disable(cwd) {
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(markerPath(cwd));
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (e?.code !== "ENOENT") {
|
|
85
|
+
// Any non-ENOENT failure is still swallowed (fail-safe); ENOENT means the
|
|
86
|
+
// marker was already gone, which is the desired end state anyway.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function isActive(cwd) {
|
|
91
|
+
try {
|
|
92
|
+
return existsSync(markerPath(cwd));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function readMarker(cwd) {
|
|
99
|
+
try {
|
|
100
|
+
const raw = readFileSync(markerPath(cwd), "utf8");
|
|
101
|
+
const parsed = JSON.parse(raw);
|
|
102
|
+
const provenance = parsed.provenance === "user-enabled" || parsed.provenance === "carried-over"
|
|
103
|
+
? parsed.provenance
|
|
104
|
+
: null;
|
|
105
|
+
return {
|
|
106
|
+
owner_session: typeof parsed.owner_session === "string" ? parsed.owner_session : null,
|
|
107
|
+
baseline_turn: typeof parsed.baseline_turn === "number" ? parsed.baseline_turn : null,
|
|
108
|
+
provenance,
|
|
109
|
+
carryover_ack: typeof parsed.carryover_ack === "boolean" ? parsed.carryover_ack : false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Missing/corrupt marker -> safe default (unclaimed, no baseline/ack).
|
|
114
|
+
return {
|
|
115
|
+
owner_session: null,
|
|
116
|
+
baseline_turn: null,
|
|
117
|
+
provenance: null,
|
|
118
|
+
carryover_ack: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function writeMarker(cwd, obj) {
|
|
123
|
+
try {
|
|
124
|
+
// Owner-only perms (see enable()): the marker persists owner_session.
|
|
125
|
+
mkdirSync(markerDir, { recursive: true, mode: 0o700 });
|
|
126
|
+
writeFileSync(markerPath(cwd), JSON.stringify(obj), { encoding: "utf8", mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Fail-safe.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Marker removal alias, identical to disable. RETAINED for callers that clear a
|
|
134
|
+
* marker explicitly (e.g. the tool's enabled:false path). NOTE: the server no
|
|
135
|
+
* longer calls this on startup — orchestration mode now PERSISTS across sessions.
|
|
136
|
+
*/
|
|
137
|
+
export function clearForCwd(cwd) {
|
|
138
|
+
disable(cwd);
|
|
139
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Defensive extraction of a sub-agent's final assistant turn text from its
|
|
2
|
+
// captured stdout. NEVER throws; on any parse failure, unknown shape, or empty
|
|
3
|
+
// result it falls back to the raw stdout (trimmed). Empty stdout -> "".
|
|
4
|
+
function rawFallback(stdout) {
|
|
5
|
+
return (stdout || "").trim();
|
|
6
|
+
}
|
|
7
|
+
// Pull a final assistant-message string out of one parsed codex `--json` event.
|
|
8
|
+
// Codex emits newline-delimited JSON; the final assistant message has appeared
|
|
9
|
+
// under a few shapes across CLI versions, so match tolerantly.
|
|
10
|
+
function codexEventText(evt) {
|
|
11
|
+
if (!evt || typeof evt !== "object")
|
|
12
|
+
return null;
|
|
13
|
+
const e = evt;
|
|
14
|
+
// Shape A: { type: "agent_message", message: "..." }
|
|
15
|
+
if (e.type === "agent_message" && typeof e.message === "string") {
|
|
16
|
+
return e.message;
|
|
17
|
+
}
|
|
18
|
+
// Shape B: { type: "item.completed", item: { item_type: "agent_message", text: "..." } }
|
|
19
|
+
if (e.type === "item.completed" && e.item && typeof e.item === "object") {
|
|
20
|
+
const item = e.item;
|
|
21
|
+
if (item.item_type === "agent_message" && typeof item.text === "string") {
|
|
22
|
+
return item.text;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Shape C: { msg: { type: "agent_message", message: "..." } }
|
|
26
|
+
if (e.msg && typeof e.msg === "object") {
|
|
27
|
+
const msg = e.msg;
|
|
28
|
+
if (msg.type === "agent_message" && typeof msg.message === "string") {
|
|
29
|
+
return msg.message;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
export function extractFinalTurn(provider, stdout) {
|
|
35
|
+
if (!stdout)
|
|
36
|
+
return "";
|
|
37
|
+
if (provider === "claude") {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(stdout);
|
|
40
|
+
// Object with a string `result` field is claude's final assistant message.
|
|
41
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
42
|
+
const r = parsed.result;
|
|
43
|
+
if (typeof r === "string")
|
|
44
|
+
return r;
|
|
45
|
+
}
|
|
46
|
+
// Array form: last element of type "result" or carrying a string result.
|
|
47
|
+
if (Array.isArray(parsed)) {
|
|
48
|
+
for (let i = parsed.length - 1; i >= 0; i--) {
|
|
49
|
+
const el = parsed[i];
|
|
50
|
+
if (el && typeof el === "object") {
|
|
51
|
+
const obj = el;
|
|
52
|
+
if (obj.type === "result" && typeof obj.result === "string") {
|
|
53
|
+
return obj.result;
|
|
54
|
+
}
|
|
55
|
+
if (typeof obj.result === "string") {
|
|
56
|
+
return obj.result;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Not a single buffered object/array — fall through to stream-json scan.
|
|
64
|
+
}
|
|
65
|
+
// stream-json: one JSON event per line. Prefer the final `result` event;
|
|
66
|
+
// otherwise the last assistant `text` block.
|
|
67
|
+
let resultText = null;
|
|
68
|
+
let lastAssistantText = null;
|
|
69
|
+
for (const line of stdout.split("\n")) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (!trimmed)
|
|
72
|
+
continue;
|
|
73
|
+
let evt;
|
|
74
|
+
try {
|
|
75
|
+
evt = JSON.parse(trimmed);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!evt || typeof evt !== "object")
|
|
81
|
+
continue;
|
|
82
|
+
const e = evt;
|
|
83
|
+
if (e.type === "result" && typeof e.result === "string") {
|
|
84
|
+
resultText = e.result;
|
|
85
|
+
}
|
|
86
|
+
else if (e.type === "assistant" && e.message && typeof e.message === "object") {
|
|
87
|
+
const content = e.message.content;
|
|
88
|
+
if (Array.isArray(content)) {
|
|
89
|
+
for (const block of content) {
|
|
90
|
+
if (block && typeof block === "object") {
|
|
91
|
+
const b = block;
|
|
92
|
+
if (b.type === "text" && typeof b.text === "string")
|
|
93
|
+
lastAssistantText = b.text;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (resultText !== null)
|
|
100
|
+
return resultText;
|
|
101
|
+
if (lastAssistantText !== null)
|
|
102
|
+
return lastAssistantText;
|
|
103
|
+
return rawFallback(stdout);
|
|
104
|
+
}
|
|
105
|
+
if (provider === "codex") {
|
|
106
|
+
let last = null;
|
|
107
|
+
const lines = stdout.split("\n");
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed)
|
|
111
|
+
continue;
|
|
112
|
+
try {
|
|
113
|
+
const evt = JSON.parse(trimmed);
|
|
114
|
+
const text = codexEventText(evt);
|
|
115
|
+
if (text !== null)
|
|
116
|
+
last = text;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// skip non-JSON lines
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (last !== null)
|
|
123
|
+
return last;
|
|
124
|
+
return rawFallback(stdout);
|
|
125
|
+
}
|
|
126
|
+
// Unknown provider: raw fallback.
|
|
127
|
+
return rawFallback(stdout);
|
|
128
|
+
}
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { join, posix } from "path";
|
|
2
|
+
/**
|
|
3
|
+
* Pure, dependency-injected exe resolver — enables unit testing with mocked
|
|
4
|
+
* platform and filesystem without spawning real processes.
|
|
5
|
+
*
|
|
6
|
+
* win32: Resolves the real .exe under the npm global prefix (PowerShell/.cmd
|
|
7
|
+
* shims cannot be spawned directly). Falls back to bare name if the
|
|
8
|
+
* expected path does not exist.
|
|
9
|
+
*
|
|
10
|
+
* darwin / linux: The npm global bin directory contains a real symlink that
|
|
11
|
+
* re-execs the correct vendor binary, so the bare name works fine when
|
|
12
|
+
* the shell's PATH is set up normally. For minimal-PATH environments
|
|
13
|
+
* (non-login shells, some MCP host launchers) we probe candidate
|
|
14
|
+
* absolute paths in order and return the first that exists. If none
|
|
15
|
+
* exist we return the bare name so PATH is the final arbiter.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveExeFor(provider, platform, deps) {
|
|
18
|
+
if (platform === "win32") {
|
|
19
|
+
const prefix = deps.npmPrefix();
|
|
20
|
+
if (provider === "claude") {
|
|
21
|
+
const exe = join(prefix, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
|
|
22
|
+
if (deps.existsSync(exe))
|
|
23
|
+
return exe;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
// codex
|
|
27
|
+
const exe = join(prefix, "node_modules", "@openai", "codex", "node_modules", "@openai", "codex-win32-x64", "vendor", "x86_64-pc-windows-msvc", "bin", "codex.exe");
|
|
28
|
+
if (deps.existsSync(exe))
|
|
29
|
+
return exe;
|
|
30
|
+
}
|
|
31
|
+
// Fall back to bare name — PATH resolver
|
|
32
|
+
return provider;
|
|
33
|
+
}
|
|
34
|
+
// darwin / linux: prefer bare name on PATH, but check known absolute
|
|
35
|
+
// candidate locations for non-login shell environments.
|
|
36
|
+
// Use posix.join for path construction so that unix-style prefixes produce
|
|
37
|
+
// forward-slash paths even when this module is built on Windows.
|
|
38
|
+
const prefix = deps.npmPrefix();
|
|
39
|
+
let candidates;
|
|
40
|
+
if (provider === "claude") {
|
|
41
|
+
candidates = [
|
|
42
|
+
posix.join(prefix, "bin", "claude"),
|
|
43
|
+
"/opt/homebrew/bin/claude",
|
|
44
|
+
"/usr/local/bin/claude",
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
candidates = [
|
|
49
|
+
posix.join(prefix, "bin", "codex"),
|
|
50
|
+
"/opt/homebrew/bin/codex",
|
|
51
|
+
"/usr/local/bin/codex",
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (deps.existsSync(candidate))
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
return provider; // bare name — last resort, relies on PATH
|
|
59
|
+
}
|