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.
Files changed (187) hide show
  1. package/README.md +24 -0
  2. package/bin/README.md +10 -0
  3. package/bin/agent-coord.mjs +3 -0
  4. package/bin/agent-hook.mjs +3 -0
  5. package/bin/harn.mjs +3 -0
  6. package/bin/run.mjs +43 -0
  7. package/dist/commander.d.ts +18 -6
  8. package/dist/commander.d.ts.map +1 -1
  9. package/dist/commander.js +74 -2
  10. package/dist/commands/agents.d.ts +25 -0
  11. package/dist/commands/agents.d.ts.map +1 -1
  12. package/dist/commands/agents.js +70 -27
  13. package/dist/commands/autoupdate.d.ts +12 -0
  14. package/dist/commands/autoupdate.d.ts.map +1 -0
  15. package/dist/commands/autoupdate.js +190 -0
  16. package/dist/commands/computer.d.ts +45 -0
  17. package/dist/commands/computer.d.ts.map +1 -0
  18. package/dist/commands/computer.js +509 -0
  19. package/dist/commands/docs.js +3 -1
  20. package/dist/commands/fetch.js +11 -1
  21. package/dist/commands/grep.d.ts +72 -0
  22. package/dist/commands/grep.d.ts.map +1 -1
  23. package/dist/commands/grep.js +200 -47
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +15 -9
  26. package/dist/commands/rm.js +2 -2
  27. package/dist/commands/web.d.ts.map +1 -1
  28. package/dist/commands/web.js +2 -0
  29. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  30. package/dist/core/agents/canonical-emit.js +2 -3
  31. package/dist/core/agents/coord-bin.d.ts +3 -0
  32. package/dist/core/agents/coord-bin.d.ts.map +1 -1
  33. package/dist/core/agents/coord-bin.js +6 -0
  34. package/dist/core/agents/coord-client.d.ts.map +1 -1
  35. package/dist/core/agents/coord-client.js +6 -2
  36. package/dist/core/agents/render/prompt-context.d.ts +55 -7
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +122 -48
  39. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  40. package/dist/core/agents/rules/claim-conflict.js +36 -16
  41. package/dist/core/agents/rules/stop-hook.d.ts +6 -0
  42. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  43. package/dist/core/agents/rules/stop-hook.js +20 -1
  44. package/dist/core/agents/state/proc-start.d.ts +3 -1
  45. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  46. package/dist/core/agents/state/proc-start.js +26 -4
  47. package/dist/core/autoupdate/engine.d.ts +97 -0
  48. package/dist/core/autoupdate/engine.d.ts.map +1 -0
  49. package/dist/core/autoupdate/engine.js +548 -0
  50. package/dist/core/autoupdate/fingerprint.d.ts +16 -0
  51. package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
  52. package/dist/core/autoupdate/fingerprint.js +73 -0
  53. package/dist/core/autoupdate/git.d.ts +30 -0
  54. package/dist/core/autoupdate/git.d.ts.map +1 -0
  55. package/dist/core/autoupdate/git.js +82 -0
  56. package/dist/core/autoupdate/index.d.ts +6 -0
  57. package/dist/core/autoupdate/index.d.ts.map +1 -0
  58. package/dist/core/autoupdate/index.js +4 -0
  59. package/dist/core/autoupdate/state.d.ts +59 -0
  60. package/dist/core/autoupdate/state.d.ts.map +1 -0
  61. package/dist/core/autoupdate/state.js +214 -0
  62. package/dist/core/autoupdate/types.d.ts +113 -0
  63. package/dist/core/autoupdate/types.d.ts.map +1 -0
  64. package/dist/core/autoupdate/types.js +35 -0
  65. package/dist/core/config.d.ts +53 -1
  66. package/dist/core/config.d.ts.map +1 -1
  67. package/dist/core/config.js +114 -1
  68. package/dist/core/events/v3/bootstrap.js +1 -0
  69. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  70. package/dist/core/events/v3/runtime-build.js +1 -0
  71. package/dist/core/hooks/adapter/wiring.d.ts +12 -2
  72. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/wiring.js +38 -8
  74. package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
  75. package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
  76. package/dist/core/hooks/autoupdate-cue.js +53 -0
  77. package/dist/core/hooks/cli.js +52 -4
  78. package/dist/core/hooks/effects/index.js +4 -4
  79. package/dist/core/hooks/resolve/coord-root.js +1 -0
  80. package/dist/core/hooks/resolve/transcript.d.ts +12 -0
  81. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  82. package/dist/core/hooks/resolve/transcript.js +54 -0
  83. package/dist/core/semantic/contract.d.ts +3 -3
  84. package/dist/core/semantic/service.d.ts.map +1 -1
  85. package/dist/core/semantic/service.js +4 -2
  86. package/dist/core/storage/modes.d.ts +1 -1
  87. package/dist/core/storage/modes.d.ts.map +1 -1
  88. package/dist/core/storage/modes.js +5 -1
  89. package/dist/core/workflow/durable-record.d.ts +2 -0
  90. package/dist/core/workflow/durable-record.d.ts.map +1 -1
  91. package/dist/core/workflow/durable-record.js +13 -1
  92. package/dist/core/workflow/engine.js +4 -0
  93. package/dist/core/workflow/spawn-claude.d.ts +7 -0
  94. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  95. package/dist/core/workflow/spawn-claude.js +16 -4
  96. package/dist/core/workflow/specialists.d.ts.map +1 -1
  97. package/dist/core/workflow/specialists.js +1 -0
  98. package/dist/core/workflow/types.d.ts +10 -0
  99. package/dist/core/workflow/types.d.ts.map +1 -1
  100. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  101. package/dist/core/workflow/workspaces/leases.js +1 -9
  102. package/dist/lib/computer/cua.d.ts +50 -0
  103. package/dist/lib/computer/cua.d.ts.map +1 -0
  104. package/dist/lib/computer/cua.js +474 -0
  105. package/dist/lib/computer/host.d.ts +19 -0
  106. package/dist/lib/computer/host.d.ts.map +1 -0
  107. package/dist/lib/computer/host.js +72 -0
  108. package/dist/lib/computer/lease.d.ts +13 -0
  109. package/dist/lib/computer/lease.d.ts.map +1 -0
  110. package/dist/lib/computer/lease.js +269 -0
  111. package/dist/lib/computer/types.d.ts +163 -0
  112. package/dist/lib/computer/types.d.ts.map +1 -0
  113. package/dist/lib/computer/types.js +10 -0
  114. package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
  115. package/dist/lib/docs-metadata-v2.js +6 -5
  116. package/dist/lib/exec.d.ts.map +1 -1
  117. package/dist/lib/exec.js +1 -0
  118. package/dist/lib/guarded-remove.d.ts.map +1 -1
  119. package/dist/lib/guarded-remove.js +5 -4
  120. package/dist/lib/headless/index.d.ts +33 -0
  121. package/dist/lib/headless/index.d.ts.map +1 -1
  122. package/dist/lib/headless/index.js +38 -2
  123. package/dist/lib/http/client.d.ts +2 -9
  124. package/dist/lib/http/client.d.ts.map +1 -1
  125. package/dist/lib/http/client.js +30 -9
  126. package/dist/lib/instructions/apply.d.ts +9 -0
  127. package/dist/lib/instructions/apply.d.ts.map +1 -1
  128. package/dist/lib/instructions/apply.js +92 -14
  129. package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
  130. package/dist/lib/instructions/git-hooks.js +8 -4
  131. package/dist/lib/instructions/templates.d.ts +11 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +81 -1
  134. package/package.json +5 -5
  135. package/schemas/config.schema.json +89 -1
  136. package/src/commander.ts +98 -8
  137. package/src/commands/agents.ts +100 -32
  138. package/src/commands/autoupdate.ts +250 -0
  139. package/src/commands/computer.ts +682 -0
  140. package/src/commands/docs.ts +3 -1
  141. package/src/commands/fetch.ts +11 -1
  142. package/src/commands/grep.ts +274 -59
  143. package/src/commands/init.ts +17 -7
  144. package/src/commands/rm.ts +2 -2
  145. package/src/commands/web.ts +2 -0
  146. package/src/core/agents/canonical-emit.ts +2 -3
  147. package/src/core/agents/coord-bin.ts +30 -0
  148. package/src/core/agents/coord-client.ts +6 -2
  149. package/src/core/agents/render/prompt-context.ts +152 -53
  150. package/src/core/agents/rules/claim-conflict.ts +50 -18
  151. package/src/core/agents/rules/stop-hook.ts +28 -1
  152. package/src/core/agents/state/proc-start.ts +29 -5
  153. package/src/core/autoupdate/engine.ts +691 -0
  154. package/src/core/autoupdate/fingerprint.ts +89 -0
  155. package/src/core/autoupdate/git.ts +120 -0
  156. package/src/core/autoupdate/index.ts +29 -0
  157. package/src/core/autoupdate/state.ts +269 -0
  158. package/src/core/autoupdate/types.ts +135 -0
  159. package/src/core/config.ts +142 -1
  160. package/src/core/events/v3/bootstrap.ts +1 -0
  161. package/src/core/events/v3/runtime-build.ts +1 -0
  162. package/src/core/hooks/adapter/wiring.ts +51 -6
  163. package/src/core/hooks/autoupdate-cue.ts +72 -0
  164. package/src/core/hooks/cli.ts +54 -1
  165. package/src/core/hooks/effects/index.ts +4 -4
  166. package/src/core/hooks/resolve/coord-root.ts +1 -0
  167. package/src/core/hooks/resolve/transcript.ts +61 -0
  168. package/src/core/semantic/service.ts +6 -2
  169. package/src/core/storage/modes.ts +7 -1
  170. package/src/core/workflow/durable-record.ts +15 -1
  171. package/src/core/workflow/engine.ts +3 -0
  172. package/src/core/workflow/spawn-claude.ts +16 -4
  173. package/src/core/workflow/specialists.ts +1 -0
  174. package/src/core/workflow/types.ts +10 -0
  175. package/src/core/workflow/workspaces/leases.ts +1 -9
  176. package/src/lib/computer/cua.ts +572 -0
  177. package/src/lib/computer/host.ts +87 -0
  178. package/src/lib/computer/lease.ts +299 -0
  179. package/src/lib/computer/types.ts +177 -0
  180. package/src/lib/docs-metadata-v2.ts +24 -5
  181. package/src/lib/exec.ts +1 -0
  182. package/src/lib/guarded-remove.ts +5 -3
  183. package/src/lib/headless/index.ts +66 -2
  184. package/src/lib/http/client.ts +33 -19
  185. package/src/lib/instructions/apply.ts +100 -12
  186. package/src/lib/instructions/git-hooks.ts +8 -4
  187. 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(issues, "error", "unknown_relation", `relations.${key}`, `unknown relation '${key}'`);
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(issues, "error", "invalid_status", "status", `status '${status}' is invalid for ${type}`);
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
- if (status && !new Set(["open", "resolved", "abandoned"]).has(status)) {
547
- add(issues, "error", "invalid_status", "status", `inquiry status '${status}' is invalid`);
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
- const targets = paths.map((path) => checkedPath(path, allowed));
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(bin: string, args: string[], call: StagedCall) {
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 { stdout } = await execHarness(bin, args, call);
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(