harnery 0.46.0 → 0.47.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/README.md +24 -0
- package/bin/README.md +10 -0
- package/bin/agent-coord.mjs +3 -0
- package/bin/agent-hook.mjs +3 -0
- package/bin/harn.mjs +3 -0
- package/bin/run.mjs +43 -0
- package/dist/commander.d.ts +18 -6
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +74 -2
- package/dist/commands/agents.d.ts +25 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -27
- package/dist/commands/autoupdate.d.ts +12 -0
- package/dist/commands/autoupdate.d.ts.map +1 -0
- package/dist/commands/autoupdate.js +190 -0
- package/dist/commands/computer.d.ts +45 -0
- package/dist/commands/computer.d.ts.map +1 -0
- package/dist/commands/computer.js +509 -0
- package/dist/commands/docs.js +3 -1
- package/dist/commands/fetch.js +11 -1
- package/dist/commands/grep.d.ts +72 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +200 -47
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -9
- package/dist/commands/rm.js +2 -2
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +2 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +2 -3
- package/dist/core/agents/coord-bin.d.ts +3 -0
- package/dist/core/agents/coord-bin.d.ts.map +1 -1
- package/dist/core/agents/coord-bin.js +6 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +6 -2
- package/dist/core/agents/render/prompt-context.d.ts +55 -7
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +122 -48
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +36 -16
- package/dist/core/agents/rules/stop-hook.d.ts +6 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +20 -1
- package/dist/core/agents/state/proc-start.d.ts +3 -1
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +26 -4
- package/dist/core/autoupdate/engine.d.ts +97 -0
- package/dist/core/autoupdate/engine.d.ts.map +1 -0
- package/dist/core/autoupdate/engine.js +548 -0
- package/dist/core/autoupdate/fingerprint.d.ts +16 -0
- package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
- package/dist/core/autoupdate/fingerprint.js +73 -0
- package/dist/core/autoupdate/git.d.ts +30 -0
- package/dist/core/autoupdate/git.d.ts.map +1 -0
- package/dist/core/autoupdate/git.js +82 -0
- package/dist/core/autoupdate/index.d.ts +6 -0
- package/dist/core/autoupdate/index.d.ts.map +1 -0
- package/dist/core/autoupdate/index.js +4 -0
- package/dist/core/autoupdate/state.d.ts +59 -0
- package/dist/core/autoupdate/state.d.ts.map +1 -0
- package/dist/core/autoupdate/state.js +214 -0
- package/dist/core/autoupdate/types.d.ts +113 -0
- package/dist/core/autoupdate/types.d.ts.map +1 -0
- package/dist/core/autoupdate/types.js +35 -0
- package/dist/core/config.d.ts +53 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +114 -1
- package/dist/core/events/v3/bootstrap.js +1 -0
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +1 -0
- package/dist/core/hooks/adapter/wiring.d.ts +12 -2
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +38 -8
- package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
- package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
- package/dist/core/hooks/autoupdate-cue.js +53 -0
- package/dist/core/hooks/cli.js +52 -4
- package/dist/core/hooks/effects/index.js +4 -4
- package/dist/core/hooks/resolve/coord-root.js +1 -0
- package/dist/core/hooks/resolve/transcript.d.ts +12 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +54 -0
- package/dist/core/semantic/contract.d.ts +3 -3
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +4 -2
- package/dist/core/storage/modes.d.ts +1 -1
- package/dist/core/storage/modes.d.ts.map +1 -1
- package/dist/core/storage/modes.js +5 -1
- package/dist/core/workflow/durable-record.d.ts +2 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -1
- package/dist/core/workflow/durable-record.js +13 -1
- package/dist/core/workflow/engine.js +4 -0
- package/dist/core/workflow/spawn-claude.d.ts +7 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +16 -4
- package/dist/core/workflow/specialists.d.ts.map +1 -1
- package/dist/core/workflow/specialists.js +1 -0
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +1 -9
- package/dist/lib/computer/cua.d.ts +50 -0
- package/dist/lib/computer/cua.d.ts.map +1 -0
- package/dist/lib/computer/cua.js +474 -0
- package/dist/lib/computer/host.d.ts +19 -0
- package/dist/lib/computer/host.d.ts.map +1 -0
- package/dist/lib/computer/host.js +72 -0
- package/dist/lib/computer/lease.d.ts +13 -0
- package/dist/lib/computer/lease.d.ts.map +1 -0
- package/dist/lib/computer/lease.js +269 -0
- package/dist/lib/computer/types.d.ts +163 -0
- package/dist/lib/computer/types.d.ts.map +1 -0
- package/dist/lib/computer/types.js +10 -0
- package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
- package/dist/lib/docs-metadata-v2.js +6 -5
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +1 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -1
- package/dist/lib/guarded-remove.js +5 -4
- package/dist/lib/headless/index.d.ts +33 -0
- package/dist/lib/headless/index.d.ts.map +1 -1
- package/dist/lib/headless/index.js +38 -2
- package/dist/lib/http/client.d.ts +2 -9
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +30 -9
- package/dist/lib/instructions/apply.d.ts +9 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -1
- package/dist/lib/instructions/apply.js +92 -14
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
- package/dist/lib/instructions/git-hooks.js +8 -4
- package/dist/lib/instructions/templates.d.ts +11 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +81 -1
- package/package.json +5 -5
- package/schemas/config.schema.json +89 -1
- package/src/commander.ts +98 -8
- package/src/commands/agents.ts +100 -32
- package/src/commands/autoupdate.ts +250 -0
- package/src/commands/computer.ts +682 -0
- package/src/commands/docs.ts +3 -1
- package/src/commands/fetch.ts +11 -1
- package/src/commands/grep.ts +274 -59
- package/src/commands/init.ts +17 -7
- package/src/commands/rm.ts +2 -2
- package/src/commands/web.ts +2 -0
- package/src/core/agents/canonical-emit.ts +2 -3
- package/src/core/agents/coord-bin.ts +30 -0
- package/src/core/agents/coord-client.ts +6 -2
- package/src/core/agents/render/prompt-context.ts +152 -53
- package/src/core/agents/rules/claim-conflict.ts +50 -18
- package/src/core/agents/rules/stop-hook.ts +28 -1
- package/src/core/agents/state/proc-start.ts +29 -5
- package/src/core/autoupdate/engine.ts +691 -0
- package/src/core/autoupdate/fingerprint.ts +89 -0
- package/src/core/autoupdate/git.ts +120 -0
- package/src/core/autoupdate/index.ts +29 -0
- package/src/core/autoupdate/state.ts +269 -0
- package/src/core/autoupdate/types.ts +135 -0
- package/src/core/config.ts +142 -1
- package/src/core/events/v3/bootstrap.ts +1 -0
- package/src/core/events/v3/runtime-build.ts +1 -0
- package/src/core/hooks/adapter/wiring.ts +51 -6
- package/src/core/hooks/autoupdate-cue.ts +72 -0
- package/src/core/hooks/cli.ts +54 -1
- package/src/core/hooks/effects/index.ts +4 -4
- package/src/core/hooks/resolve/coord-root.ts +1 -0
- package/src/core/hooks/resolve/transcript.ts +61 -0
- package/src/core/semantic/service.ts +6 -2
- package/src/core/storage/modes.ts +7 -1
- package/src/core/workflow/durable-record.ts +15 -1
- package/src/core/workflow/engine.ts +3 -0
- package/src/core/workflow/spawn-claude.ts +16 -4
- package/src/core/workflow/specialists.ts +1 -0
- package/src/core/workflow/types.ts +10 -0
- package/src/core/workflow/workspaces/leases.ts +1 -9
- package/src/lib/computer/cua.ts +572 -0
- package/src/lib/computer/host.ts +87 -0
- package/src/lib/computer/lease.ts +299 -0
- package/src/lib/computer/types.ts +177 -0
- package/src/lib/docs-metadata-v2.ts +24 -5
- package/src/lib/exec.ts +1 -0
- package/src/lib/guarded-remove.ts +5 -3
- package/src/lib/headless/index.ts +66 -2
- package/src/lib/http/client.ts +33 -19
- package/src/lib/instructions/apply.ts +100 -12
- package/src/lib/instructions/git-hooks.ts +8 -4
- package/src/lib/instructions/templates.ts +95 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop lease for `harn computer` (ADR 0228).
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per desktop records which agent may drive it. Callers are
|
|
5
|
+
* short-lived CLI invocations from WSL distros and native Windows shells that
|
|
6
|
+
* share the file through `/mnt/<drive>` (drvfs) or the native path, so this
|
|
7
|
+
* module relies on nothing that breaks across that boundary:
|
|
8
|
+
*
|
|
9
|
+
* - No flock and no pid liveness. Owner identity is `LeaseOwner.id`; a lease
|
|
10
|
+
* ends when its owner releases it or when it sits idle past `expires_at`.
|
|
11
|
+
* - The read-modify-write critical section is an atomic `mkdir` of
|
|
12
|
+
* `<dir>/lease.lock`. The holder writes `owner.json` ({pid, at, token}) into
|
|
13
|
+
* it and keeps it for milliseconds. A lock older than 10 seconds belongs to
|
|
14
|
+
* a crashed holder and is broken.
|
|
15
|
+
* - Lease writes go to a temporary file in the same directory and are renamed
|
|
16
|
+
* over the target, so a reader never sees a partial record.
|
|
17
|
+
*
|
|
18
|
+
* Generation rule: every acquisition of a free or expired lease increments
|
|
19
|
+
* `generation`, including an owner re-taking its own lease after it expired.
|
|
20
|
+
* A refresh of an unexpired lease by the same owner keeps the generation.
|
|
21
|
+
*
|
|
22
|
+
* Internal module: not exported from package.json.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import {
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
rmSync,
|
|
30
|
+
statSync,
|
|
31
|
+
unlinkSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import type {
|
|
36
|
+
DesktopLease,
|
|
37
|
+
LeaseAcquireResult,
|
|
38
|
+
LeaseApi,
|
|
39
|
+
LeaseOptions,
|
|
40
|
+
LeaseOwner,
|
|
41
|
+
} from "./types.ts";
|
|
42
|
+
|
|
43
|
+
/** Idle expiry when `LeaseOptions.idleMs` is omitted. */
|
|
44
|
+
export const DEFAULT_LEASE_IDLE_MS = 120_000;
|
|
45
|
+
const DEFAULT_POLL_MS = 500;
|
|
46
|
+
const LEASE_FILE = "desktop-lease.json";
|
|
47
|
+
const LOCK_DIR = "lease.lock";
|
|
48
|
+
const LOCK_OWNER_FILE = "owner.json";
|
|
49
|
+
/** A critical-section lock older than this belongs to a crashed holder. */
|
|
50
|
+
const LOCK_STALE_MS = 10_000;
|
|
51
|
+
/** Give up on the critical section after this long (stale break happens first). */
|
|
52
|
+
const LOCK_TIMEOUT_MS = 15_000;
|
|
53
|
+
const LOCK_RETRY_MS = 10;
|
|
54
|
+
|
|
55
|
+
/** Directory holding the lease file for a desktop state dir. */
|
|
56
|
+
export function leaseDir(desktopStateDir: string): string {
|
|
57
|
+
return join(desktopStateDir, "computer");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function leaseFile(dir: string): string {
|
|
61
|
+
return join(dir, LEASE_FILE);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sleepSync(ms: number): void {
|
|
65
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function errCode(error: unknown): string | undefined {
|
|
69
|
+
return (error as NodeJS.ErrnoException | undefined)?.code;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse a lease record; anything malformed counts as no lease. */
|
|
73
|
+
function parseLease(text: string): DesktopLease | null {
|
|
74
|
+
let value: unknown;
|
|
75
|
+
try {
|
|
76
|
+
value = JSON.parse(text);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (!value || typeof value !== "object") return null;
|
|
81
|
+
const lease = value as Partial<DesktopLease>;
|
|
82
|
+
if (
|
|
83
|
+
lease.schema !== 1 ||
|
|
84
|
+
typeof lease.owner_id !== "string" ||
|
|
85
|
+
typeof lease.owner_label !== "string" ||
|
|
86
|
+
typeof lease.generation !== "number" ||
|
|
87
|
+
!Number.isFinite(lease.generation) ||
|
|
88
|
+
typeof lease.acquired_at !== "string" ||
|
|
89
|
+
typeof lease.refreshed_at !== "string" ||
|
|
90
|
+
typeof lease.expires_at !== "string" ||
|
|
91
|
+
Number.isNaN(Date.parse(lease.expires_at))
|
|
92
|
+
) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return lease as DesktopLease;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The lease record on disk, expired or not; null when missing or corrupt. */
|
|
99
|
+
function readRaw(dir: string): DesktopLease | null {
|
|
100
|
+
try {
|
|
101
|
+
return parseLease(readFileSync(leaseFile(dir), "utf8"));
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isExpired(lease: DesktopLease, nowMs: number): boolean {
|
|
108
|
+
return nowMs >= Date.parse(lease.expires_at);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function writeLease(dir: string, lease: DesktopLease): void {
|
|
112
|
+
const tmp = join(dir, `${LEASE_FILE}.${process.pid}.${randomUUID()}.tmp`);
|
|
113
|
+
writeFileSync(tmp, `${JSON.stringify(lease, null, 2)}\n`);
|
|
114
|
+
try {
|
|
115
|
+
renameSync(tmp, leaseFile(dir));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(tmp);
|
|
119
|
+
} catch {
|
|
120
|
+
// best effort
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Age of a lock dir: owner.json `at`, else the directory mtime. */
|
|
127
|
+
function lockAgeMs(lockDir: string): number | undefined {
|
|
128
|
+
try {
|
|
129
|
+
const owner = JSON.parse(readFileSync(join(lockDir, LOCK_OWNER_FILE), "utf8")) as {
|
|
130
|
+
at?: unknown;
|
|
131
|
+
};
|
|
132
|
+
const at = typeof owner.at === "string" ? Date.parse(owner.at) : Number.NaN;
|
|
133
|
+
if (!Number.isNaN(at)) return Date.now() - at;
|
|
134
|
+
} catch {
|
|
135
|
+
// owner.json not written yet or unreadable: fall back to the dir itself
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return Date.now() - statSync(lockDir).mtimeMs;
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined; // lock vanished
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Break a stale lock. Rename first so two breakers cannot both win. */
|
|
145
|
+
function breakLock(lockDir: string): void {
|
|
146
|
+
const grave = `${lockDir}.stale.${process.pid}.${randomUUID()}`;
|
|
147
|
+
try {
|
|
148
|
+
renameSync(lockDir, grave);
|
|
149
|
+
} catch {
|
|
150
|
+
return; // someone else broke or released it
|
|
151
|
+
}
|
|
152
|
+
rmSync(grave, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Run `fn` while holding the critical-section lock in `dir` (which must
|
|
157
|
+
* exist). Synchronous: the section is a few file operations, and release()
|
|
158
|
+
* is synchronous in the contract.
|
|
159
|
+
*/
|
|
160
|
+
function withLock<T>(dir: string, fn: () => T): T {
|
|
161
|
+
const lockDir = join(dir, LOCK_DIR);
|
|
162
|
+
const token = randomUUID();
|
|
163
|
+
const started = Date.now();
|
|
164
|
+
for (;;) {
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(lockDir);
|
|
167
|
+
break;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (errCode(error) !== "EEXIST") throw error;
|
|
170
|
+
}
|
|
171
|
+
const age = lockAgeMs(lockDir);
|
|
172
|
+
if (age !== undefined && age > LOCK_STALE_MS) {
|
|
173
|
+
breakLock(lockDir);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (Date.now() - started > LOCK_TIMEOUT_MS) {
|
|
177
|
+
throw new Error(`desktop lease: timed out waiting for ${lockDir}`);
|
|
178
|
+
}
|
|
179
|
+
sleepSync(LOCK_RETRY_MS);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
writeFileSync(
|
|
183
|
+
join(lockDir, LOCK_OWNER_FILE),
|
|
184
|
+
JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }),
|
|
185
|
+
);
|
|
186
|
+
} catch {
|
|
187
|
+
// The mkdir already gave us the lock; owner.json only aids stale detection.
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
return fn();
|
|
191
|
+
} finally {
|
|
192
|
+
releaseLock(lockDir, token);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Remove the lock only if it is still ours (a slow holder may have been broken). */
|
|
197
|
+
function releaseLock(lockDir: string, token: string): void {
|
|
198
|
+
try {
|
|
199
|
+
const owner = JSON.parse(readFileSync(join(lockDir, LOCK_OWNER_FILE), "utf8")) as {
|
|
200
|
+
token?: unknown;
|
|
201
|
+
};
|
|
202
|
+
if (owner.token !== token) return;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (errCode(error) !== "ENOENT") return; // unreadable owner.json: leave it for stale breaking
|
|
205
|
+
// owner.json missing: our write failed or the lock is gone; fall through
|
|
206
|
+
}
|
|
207
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type TryResult =
|
|
211
|
+
| { ok: true; lease: DesktopLease; fresh: boolean }
|
|
212
|
+
| { ok: false; holder: DesktopLease };
|
|
213
|
+
|
|
214
|
+
function tryAcquire(owner: LeaseOwner, dir: string, nowMs: number, idleMs: number): TryResult {
|
|
215
|
+
return withLock(dir, () => {
|
|
216
|
+
const current = readRaw(dir);
|
|
217
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
218
|
+
const expiresIso = new Date(nowMs + idleMs).toISOString();
|
|
219
|
+
if (current && !isExpired(current, nowMs)) {
|
|
220
|
+
if (current.owner_id !== owner.id) return { ok: false, holder: current };
|
|
221
|
+
const refreshed: DesktopLease = { ...current, refreshed_at: nowIso, expires_at: expiresIso };
|
|
222
|
+
writeLease(dir, refreshed);
|
|
223
|
+
return { ok: true, lease: refreshed, fresh: false };
|
|
224
|
+
}
|
|
225
|
+
const lease: DesktopLease = {
|
|
226
|
+
schema: 1,
|
|
227
|
+
owner_id: owner.id,
|
|
228
|
+
owner_label: owner.label,
|
|
229
|
+
...(owner.project !== undefined ? { project: owner.project } : {}),
|
|
230
|
+
generation: (current?.generation ?? 0) + 1,
|
|
231
|
+
acquired_at: nowIso,
|
|
232
|
+
refreshed_at: nowIso,
|
|
233
|
+
expires_at: expiresIso,
|
|
234
|
+
};
|
|
235
|
+
writeLease(dir, lease);
|
|
236
|
+
return { ok: true, lease, fresh: true };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface DesktopLeaseDeps {
|
|
241
|
+
/** Async sleep used while waiting for another owner's lease. */
|
|
242
|
+
sleep?: (ms: number) => Promise<void>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Build a lease API; tests inject `sleep` (and `LeaseOptions.now`). */
|
|
246
|
+
export function createDesktopLease(deps: DesktopLeaseDeps = {}): LeaseApi {
|
|
247
|
+
const sleep =
|
|
248
|
+
deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
async acquire(owner: LeaseOwner, opts: LeaseOptions): Promise<LeaseAcquireResult> {
|
|
252
|
+
const now = opts.now ?? Date.now;
|
|
253
|
+
const idleMs = opts.idleMs ?? DEFAULT_LEASE_IDLE_MS;
|
|
254
|
+
const waitMs = Math.max(0, opts.waitMs ?? 0);
|
|
255
|
+
const pollMs = Math.max(1, opts.pollMs ?? DEFAULT_POLL_MS);
|
|
256
|
+
mkdirSync(opts.dir, { recursive: true });
|
|
257
|
+
const started = now();
|
|
258
|
+
// Track slept time too, so a frozen injected clock cannot wait forever.
|
|
259
|
+
let slept = 0;
|
|
260
|
+
for (;;) {
|
|
261
|
+
const result = tryAcquire(owner, opts.dir, now(), idleMs);
|
|
262
|
+
const waited = Math.max(now() - started, slept);
|
|
263
|
+
if (result.ok) return result;
|
|
264
|
+
const remaining = waitMs - waited;
|
|
265
|
+
if (remaining <= 0) return { ok: false, holder: result.holder, waitedMs: waited };
|
|
266
|
+
const step = Math.min(pollMs, remaining);
|
|
267
|
+
await sleep(step);
|
|
268
|
+
slept += step;
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
release(owner: LeaseOwner, opts: LeaseOptions): boolean {
|
|
273
|
+
try {
|
|
274
|
+
if (!statSync(opts.dir).isDirectory()) return false;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
return withLock(opts.dir, () => {
|
|
279
|
+
const current = readRaw(opts.dir);
|
|
280
|
+
if (!current || current.owner_id !== owner.id) return false;
|
|
281
|
+
const nowMs = (opts.now ?? Date.now)();
|
|
282
|
+
if (isExpired(current, nowMs)) return false;
|
|
283
|
+
// Expire the record instead of deleting it, so the next owner's
|
|
284
|
+
// generation still counts up from this one.
|
|
285
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
286
|
+
writeLease(opts.dir, { ...current, refreshed_at: nowIso, expires_at: nowIso });
|
|
287
|
+
return true;
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
read(opts: LeaseOptions): DesktopLease | null {
|
|
292
|
+
const now = opts.now ?? Date.now;
|
|
293
|
+
const lease = readRaw(opts.dir);
|
|
294
|
+
return lease && !isExpired(lease, now()) ? lease : null;
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const desktopLease: LeaseApi = createDesktopLease();
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contracts for `harn computer` (ADR 0228).
|
|
3
|
+
*
|
|
4
|
+
* The command layer and the desktop lease depend only on these types. Every
|
|
5
|
+
* driver-specific detail (release URLs, archive layout, CLI arguments, config
|
|
6
|
+
* files) lives behind `DesktopDriver` in a driver module such as `cua.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Internal module: not exported from package.json.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Where the desktop being driven lives, relative to the calling process. */
|
|
12
|
+
export type HostKind =
|
|
13
|
+
/** A native Windows process driving its own desktop. */
|
|
14
|
+
| "windows"
|
|
15
|
+
/** A WSL2 process driving the Windows desktop through interop. */
|
|
16
|
+
| "wsl"
|
|
17
|
+
/** Any host without a supported driver build (macOS, Linux desktops). */
|
|
18
|
+
| "unsupported";
|
|
19
|
+
|
|
20
|
+
export interface HostInfo {
|
|
21
|
+
kind: HostKind;
|
|
22
|
+
/** Human-readable reason when kind is "unsupported". */
|
|
23
|
+
reason?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Directory (in the CALLER's path syntax) that is shared by every caller of
|
|
26
|
+
* this desktop: on Windows and WSL, `%LOCALAPPDATA%\harnery` expressed as a
|
|
27
|
+
* Windows path (windows) or its `/mnt/<drive>/...` form (wsl). The lease file
|
|
28
|
+
* and driver installs live under it.
|
|
29
|
+
*/
|
|
30
|
+
desktopStateDir?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A pinned, checksum-verified build of one driver for one platform. */
|
|
34
|
+
export interface PinnedArtifact {
|
|
35
|
+
/** Driver version, e.g. "0.38.0". */
|
|
36
|
+
version: string;
|
|
37
|
+
/** Archive file name as published. */
|
|
38
|
+
fileName: string;
|
|
39
|
+
/** Download URL. */
|
|
40
|
+
url: string;
|
|
41
|
+
/** Lowercase hex SHA-256 of the archive. */
|
|
42
|
+
sha256: string;
|
|
43
|
+
/** Path of the driver executable inside the archive. */
|
|
44
|
+
executableEntry: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InstallPlan {
|
|
48
|
+
driver: string;
|
|
49
|
+
version: string;
|
|
50
|
+
host: HostKind;
|
|
51
|
+
artifact: PinnedArtifact;
|
|
52
|
+
/** Install directory in the caller's path syntax (versioned). */
|
|
53
|
+
installDir: string;
|
|
54
|
+
/** Executable path in the caller's path syntax. */
|
|
55
|
+
executable: string;
|
|
56
|
+
/** Already installed and the executable's archive hash was verified at install time. */
|
|
57
|
+
alreadyInstalled: boolean;
|
|
58
|
+
/** Driver preference file written before first run, caller's path syntax. */
|
|
59
|
+
preferenceFile: string;
|
|
60
|
+
/** Preferences merged into that file (e.g. telemetry off). */
|
|
61
|
+
preferences: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface InstallResult extends InstallPlan {
|
|
65
|
+
installed: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DaemonStatus {
|
|
69
|
+
running: boolean;
|
|
70
|
+
/** Raw first line(s) from the driver's status output, for display. */
|
|
71
|
+
detail: string;
|
|
72
|
+
pid?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DriverCallResult {
|
|
76
|
+
/** Process exit code of the driver invocation. */
|
|
77
|
+
exitCode: number;
|
|
78
|
+
/** Driver stdout with CRLF normalized to LF. */
|
|
79
|
+
stdout: string;
|
|
80
|
+
/** Driver stderr with CRLF normalized to LF. */
|
|
81
|
+
stderr: string;
|
|
82
|
+
/** Parsed stdout when it is a single JSON value, else undefined. */
|
|
83
|
+
json?: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The adapter boundary. One implementation today (`cua.ts`); a second driver
|
|
88
|
+
* would implement this interface rather than branch inside the command.
|
|
89
|
+
*/
|
|
90
|
+
export interface DesktopDriver {
|
|
91
|
+
/** Stable id, e.g. "cua". */
|
|
92
|
+
readonly id: string;
|
|
93
|
+
/** Pinned version this Harnery build installs. */
|
|
94
|
+
readonly version: string;
|
|
95
|
+
/** Resolve the install plan for this host without side effects. */
|
|
96
|
+
planInstall(host: HostInfo): InstallPlan;
|
|
97
|
+
/** Download, verify, extract and write preferences. Idempotent. */
|
|
98
|
+
install(plan: InstallPlan): Promise<InstallResult>;
|
|
99
|
+
/** Executable path if the pinned version is installed, else undefined. */
|
|
100
|
+
installedExecutable(host: HostInfo): string | undefined;
|
|
101
|
+
daemonStatus(host: HostInfo): DaemonStatus;
|
|
102
|
+
/** Start the daemon unelevated if it is not running. Returns the new status. */
|
|
103
|
+
startDaemon(host: HostInfo): Promise<DaemonStatus>;
|
|
104
|
+
stopDaemon(host: HostInfo): DaemonStatus;
|
|
105
|
+
/** One tool call. `args` is the JSON object passed to the tool. */
|
|
106
|
+
call(host: HostInfo, tool: string, args: Record<string, unknown>): DriverCallResult;
|
|
107
|
+
/** Tool catalog lines ("name: description"). */
|
|
108
|
+
listTools(host: HostInfo): DriverCallResult;
|
|
109
|
+
/** Full description + input schema of one tool. */
|
|
110
|
+
describe(host: HostInfo, tool: string): DriverCallResult;
|
|
111
|
+
/** End-to-end diagnostics (driver's own health report). */
|
|
112
|
+
health(host: HostInfo): DriverCallResult;
|
|
113
|
+
/**
|
|
114
|
+
* Rewrite caller-side paths inside tool args to the driver's path syntax
|
|
115
|
+
* (e.g. WSL `/home/...` -> `\\wsl.localhost\...`, `/mnt/c/...` -> `C:\...`),
|
|
116
|
+
* and the reverse for paths in results. Identity on native hosts.
|
|
117
|
+
*/
|
|
118
|
+
translateArgsPaths(
|
|
119
|
+
host: HostInfo,
|
|
120
|
+
tool: string,
|
|
121
|
+
args: Record<string, unknown>,
|
|
122
|
+
): Record<string, unknown>;
|
|
123
|
+
translateResultPaths(host: HostInfo, stdout: string): string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Who is asking for the desktop. Resolved by the command layer. */
|
|
127
|
+
export interface LeaseOwner {
|
|
128
|
+
/** Stable id: the agent instance id, or `pid:<pid>@<host>` without one. */
|
|
129
|
+
id: string;
|
|
130
|
+
/** Display label, e.g. "agent-Dexter". Also the default driver session. */
|
|
131
|
+
label: string;
|
|
132
|
+
/** Project root of the caller, informational. */
|
|
133
|
+
project?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** On-disk lease record (one per desktop). */
|
|
137
|
+
export interface DesktopLease {
|
|
138
|
+
schema: 1;
|
|
139
|
+
owner_id: string;
|
|
140
|
+
owner_label: string;
|
|
141
|
+
project?: string;
|
|
142
|
+
/** Increments on every fresh acquisition (no lease, or the previous one expired or was released). */
|
|
143
|
+
generation: number;
|
|
144
|
+
acquired_at: string;
|
|
145
|
+
refreshed_at: string;
|
|
146
|
+
/** ISO time after which the lease is free for anyone. */
|
|
147
|
+
expires_at: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface LeaseOptions {
|
|
151
|
+
/** Directory holding the lease file (HostInfo.desktopStateDir/computer). */
|
|
152
|
+
dir: string;
|
|
153
|
+
/** Idle expiry, refreshed on every acquire by the same owner. Default 120s. */
|
|
154
|
+
idleMs?: number;
|
|
155
|
+
/** How long to wait for another owner's lease. Default 0 (fail at once). */
|
|
156
|
+
waitMs?: number;
|
|
157
|
+
/** Poll interval while waiting. Default 500ms. */
|
|
158
|
+
pollMs?: number;
|
|
159
|
+
/** Injectable clock for tests. */
|
|
160
|
+
now?: () => number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type LeaseAcquireResult =
|
|
164
|
+
| {
|
|
165
|
+
ok: true;
|
|
166
|
+
lease: DesktopLease /** true when this call took the lease from nobody/expired. */;
|
|
167
|
+
fresh: boolean;
|
|
168
|
+
}
|
|
169
|
+
| { ok: false; holder: DesktopLease; waitedMs: number };
|
|
170
|
+
|
|
171
|
+
export interface LeaseApi {
|
|
172
|
+
acquire(owner: LeaseOwner, opts: LeaseOptions): Promise<LeaseAcquireResult>;
|
|
173
|
+
/** Release only if held by `owner`. Returns true when a lease was removed. */
|
|
174
|
+
release(owner: LeaseOwner, opts: LeaseOptions): boolean;
|
|
175
|
+
/** Current unexpired lease, or null. */
|
|
176
|
+
read(opts: LeaseOptions): DesktopLease | null;
|
|
177
|
+
}
|
|
@@ -287,7 +287,13 @@ function validateRelations(
|
|
|
287
287
|
const allowed = new Set(["depends_on", "supersedes", "superseded_by", "continues", "related"]);
|
|
288
288
|
for (const [key, value] of Object.entries(data.relations)) {
|
|
289
289
|
if (!allowed.has(key)) {
|
|
290
|
-
add(
|
|
290
|
+
add(
|
|
291
|
+
issues,
|
|
292
|
+
"error",
|
|
293
|
+
"unknown_relation",
|
|
294
|
+
`relations.${key}`,
|
|
295
|
+
`unknown relation '${key}'; use one of: ${[...allowed].join(", ")}`,
|
|
296
|
+
);
|
|
291
297
|
continue;
|
|
292
298
|
}
|
|
293
299
|
validateStringList(value, `relations.${key}`, issues);
|
|
@@ -311,7 +317,7 @@ function validateAccess(
|
|
|
311
317
|
"error",
|
|
312
318
|
"unknown_access_field",
|
|
313
319
|
`access.${key}`,
|
|
314
|
-
`unknown access field '${key}'`,
|
|
320
|
+
`unknown access field '${key}'; use ${[...allowed].join(" or ")}`,
|
|
315
321
|
);
|
|
316
322
|
}
|
|
317
323
|
}
|
|
@@ -334,7 +340,13 @@ function validateLifecycle(
|
|
|
334
340
|
const allowed =
|
|
335
341
|
type === "plan" ? PLAN_STATUSES : type === "issue" ? ISSUE_STATUSES : HANDOFF_STATUSES;
|
|
336
342
|
if (status && !allowed.has(status)) {
|
|
337
|
-
add(
|
|
343
|
+
add(
|
|
344
|
+
issues,
|
|
345
|
+
"error",
|
|
346
|
+
"invalid_status",
|
|
347
|
+
"status",
|
|
348
|
+
`status '${status}' is invalid for ${type}; use one of: ${[...allowed].join(", ")}`,
|
|
349
|
+
);
|
|
338
350
|
}
|
|
339
351
|
|
|
340
352
|
const changedAt = validateTimestamp(data, "status_changed_at", issues);
|
|
@@ -543,8 +555,15 @@ function validateInquiry(
|
|
|
543
555
|
): void {
|
|
544
556
|
requireString(data, "title", issues);
|
|
545
557
|
const status = requireString(data, "status", issues);
|
|
546
|
-
|
|
547
|
-
|
|
558
|
+
const inquiryStatuses = ["open", "resolved", "abandoned"];
|
|
559
|
+
if (status && !inquiryStatuses.includes(status)) {
|
|
560
|
+
add(
|
|
561
|
+
issues,
|
|
562
|
+
"error",
|
|
563
|
+
"invalid_status",
|
|
564
|
+
"status",
|
|
565
|
+
`inquiry status '${status}' is invalid; use one of: ${inquiryStatuses.join(", ")}`,
|
|
566
|
+
);
|
|
548
567
|
}
|
|
549
568
|
const changedAt = validateTimestamp(data, "status_changed_at", issues);
|
|
550
569
|
if (createdAt !== null && changedAt !== null && changedAt < createdAt) {
|
package/src/lib/exec.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function exec(cmd: string[], opts: ExecOpts = {}): Promise<ExecResult> {
|
|
|
46
46
|
const [file, ...args] = cmd;
|
|
47
47
|
return new Promise<ExecResult>((resolveResult) => {
|
|
48
48
|
const proc = spawn(file, args, {
|
|
49
|
+
windowsHide: true,
|
|
49
50
|
cwd: opts.cwd ?? process.cwd(),
|
|
50
51
|
env: opts.env ?? { ...process.env },
|
|
51
52
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -92,7 +92,8 @@ export function guardedRemove(
|
|
|
92
92
|
throw new Error("--root cannot be a filesystem root or home directory");
|
|
93
93
|
assertEntryLimitAuthority();
|
|
94
94
|
if (paths.length === 0 || paths.length > 100) throw new Error("Supply 1-100 explicit targets");
|
|
95
|
-
|
|
95
|
+
// Relative targets name paths under --root, never under the process cwd.
|
|
96
|
+
const targets = paths.map((path) => checkedPath(path, allowed, false, root));
|
|
96
97
|
const snapshots = new Map<string, string>();
|
|
97
98
|
const removedProtected = new Set<string>();
|
|
98
99
|
const links: RemovalLink[] = [];
|
|
@@ -349,7 +350,7 @@ function allowedProtected(
|
|
|
349
350
|
for (const input of paths) {
|
|
350
351
|
if (!input?.trim() || /[\0\r\n*?[\]$]/.test(input))
|
|
351
352
|
throw new Error("Use a nonempty explicit --allow-protected path without wildcards");
|
|
352
|
-
const path = resolve(input);
|
|
353
|
+
const path = resolve(root, input);
|
|
353
354
|
let cursor = dirname(path);
|
|
354
355
|
while (true) {
|
|
355
356
|
if (lstatSync(cursor).isSymbolicLink())
|
|
@@ -435,10 +436,11 @@ function checkedPath(
|
|
|
435
436
|
input: string,
|
|
436
437
|
allowed: ReadonlySet<string> = new Set(),
|
|
437
438
|
isRoot = false,
|
|
439
|
+
base?: string,
|
|
438
440
|
): string {
|
|
439
441
|
if (!input?.trim() || /[\0\r\n*?[\]$]/.test(input))
|
|
440
442
|
throw new Error("Use a nonempty explicit path without wildcards or unresolved variables");
|
|
441
|
-
const path = resolve(input);
|
|
443
|
+
const path = base === undefined ? resolve(input) : resolve(base, input);
|
|
442
444
|
let cursor = path;
|
|
443
445
|
while (true) {
|
|
444
446
|
if (
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
* PATH" is not "working", and under parallel load the first backend can
|
|
27
27
|
* flake on a fraction of calls while the second runs clean.
|
|
28
28
|
*
|
|
29
|
+
* - A claude-code call loads NO MCP servers and no claude.ai connectors by
|
|
30
|
+
* default (`claudeMcpLaunch`). Connectors attach about 130 tool schemas to
|
|
31
|
+
* every turn of a one-shot call that never uses them; a caller that needs
|
|
32
|
+
* one opts in per request with `mcp: "inherit"` or an explicit server map.
|
|
33
|
+
*
|
|
29
34
|
* Env knobs (read through coordEnv, so the HARNERY_ prefix stays in one
|
|
30
35
|
* place): HARNERY_HEADLESS_BACKEND forces one backend and disables the
|
|
31
36
|
* fallback walk; HARNERY_HEADLESS_MODEL overrides the model passed to
|
|
@@ -77,6 +82,55 @@ export interface HeadlessRequest {
|
|
|
77
82
|
timeoutMs?: number;
|
|
78
83
|
/** Agentic turn budget where the backend supports one (default 4). */
|
|
79
84
|
maxTurns?: number;
|
|
85
|
+
/** MCP servers the claude-code backend may load (default `"none"`; see
|
|
86
|
+
* `ClaudeMcpMode`). The codex and cursor backends ignore it. */
|
|
87
|
+
mcp?: ClaudeMcpMode;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which MCP servers a spawned `claude` child may load.
|
|
92
|
+
*
|
|
93
|
+
* - `"none"` (the default for every spawn Harnery owns): no MCP servers and no
|
|
94
|
+
* claude.ai connectors. Measured on a headless child, connectors add tens of
|
|
95
|
+
* thousands of tokens of tool schemas to every turn.
|
|
96
|
+
* - `"inherit"`: whatever Claude Code would load on its own, connectors
|
|
97
|
+
* included. The explicit opt-in for a workflow that really uses one.
|
|
98
|
+
* - `{ mcpServers }`: exactly these servers (the `--mcp-config` JSON shape),
|
|
99
|
+
* with claude.ai connectors still off.
|
|
100
|
+
*/
|
|
101
|
+
export type ClaudeMcpMode = "none" | "inherit" | { mcpServers: Record<string, unknown> };
|
|
102
|
+
|
|
103
|
+
/** The two guards that keep a `claude` child free of MCP servers: strict MCP
|
|
104
|
+
* config with an empty server map, plus the env switch that stops claude.ai
|
|
105
|
+
* connectors attaching mid-session (the flag alone does not cover them). */
|
|
106
|
+
export const CLAUDE_NO_MCP_ARGS: readonly string[] = [
|
|
107
|
+
"--strict-mcp-config",
|
|
108
|
+
"--mcp-config",
|
|
109
|
+
'{"mcpServers":{}}',
|
|
110
|
+
];
|
|
111
|
+
export const CLAUDE_NO_CONNECTORS_ENV: Readonly<Record<string, string>> = {
|
|
112
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: "false",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Resolve an MCP mode to the argv and env additions for a `claude` child.
|
|
116
|
+
* Throws on a malformed server map rather than launching with a guess. */
|
|
117
|
+
export function claudeMcpLaunch(mode: ClaudeMcpMode = "none"): {
|
|
118
|
+
args: string[];
|
|
119
|
+
env: Record<string, string>;
|
|
120
|
+
} {
|
|
121
|
+
if (mode === "inherit") return { args: [], env: {} };
|
|
122
|
+
if (mode === "none")
|
|
123
|
+
return { args: [...CLAUDE_NO_MCP_ARGS], env: { ...CLAUDE_NO_CONNECTORS_ENV } };
|
|
124
|
+
const servers = (mode as { mcpServers?: unknown } | null)?.mcpServers;
|
|
125
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'mcp must be "none", "inherit", or { mcpServers: { <name>: <server config> } }',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
args: ["--strict-mcp-config", "--mcp-config", JSON.stringify({ mcpServers: servers })],
|
|
132
|
+
env: { ...CLAUDE_NO_CONNECTORS_ENV },
|
|
133
|
+
};
|
|
80
134
|
}
|
|
81
135
|
|
|
82
136
|
export interface HeadlessResult {
|
|
@@ -135,6 +189,7 @@ interface StagedCall {
|
|
|
135
189
|
model?: string;
|
|
136
190
|
timeoutMs: number;
|
|
137
191
|
maxTurns: number;
|
|
192
|
+
mcp?: ClaudeMcpMode;
|
|
138
193
|
}
|
|
139
194
|
|
|
140
195
|
/** Write the request's images into a fresh neutral dir and resolve the
|
|
@@ -154,16 +209,23 @@ function stageCall(req: HeadlessRequest): StagedCall {
|
|
|
154
209
|
model: coordEnv("HEADLESS_MODEL") ?? req.model,
|
|
155
210
|
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
156
211
|
maxTurns: req.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
212
|
+
...(req.mcp ? { mcp: req.mcp } : {}),
|
|
157
213
|
};
|
|
158
214
|
}
|
|
159
215
|
|
|
160
216
|
/** Run a harness CLI from the neutral dir with stdin CLOSED (see module
|
|
161
217
|
* comment: an open stdin pipe deadlocks codex). */
|
|
162
|
-
function execHarness(
|
|
218
|
+
function execHarness(
|
|
219
|
+
bin: string,
|
|
220
|
+
args: string[],
|
|
221
|
+
call: StagedCall,
|
|
222
|
+
extraEnv: Record<string, string> = {},
|
|
223
|
+
) {
|
|
163
224
|
const pending = execFileAsync(bin, args, {
|
|
164
225
|
cwd: call.dir,
|
|
165
226
|
timeout: call.timeoutMs,
|
|
166
227
|
maxBuffer: MAX_BUFFER,
|
|
228
|
+
...(Object.keys(extraEnv).length ? { env: { ...process.env, ...extraEnv } } : {}),
|
|
167
229
|
});
|
|
168
230
|
pending.child.stdin?.end();
|
|
169
231
|
return pending;
|
|
@@ -178,7 +240,9 @@ async function runClaudeCode(bin: string, call: StagedCall): Promise<HeadlessRes
|
|
|
178
240
|
const args = ["-p", call.prompt, "--output-format", "json", "--max-turns", String(call.maxTurns)];
|
|
179
241
|
if (call.imagePaths.length) args.push("--allowedTools", "Read");
|
|
180
242
|
if (call.model) args.push("--model", call.model);
|
|
181
|
-
const
|
|
243
|
+
const mcp = claudeMcpLaunch(call.mcp);
|
|
244
|
+
args.push(...mcp.args);
|
|
245
|
+
const { stdout } = await execHarness(bin, args, call, mcp.env);
|
|
182
246
|
const envelope = JSON.parse(stdout) as { is_error?: boolean; result?: unknown };
|
|
183
247
|
if (envelope.is_error) {
|
|
184
248
|
throw new Error(
|