harnery 0.46.1 → 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 (176) 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/state/proc-start.d.ts +3 -1
  40. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  41. package/dist/core/agents/state/proc-start.js +26 -4
  42. package/dist/core/autoupdate/engine.d.ts +97 -0
  43. package/dist/core/autoupdate/engine.d.ts.map +1 -0
  44. package/dist/core/autoupdate/engine.js +548 -0
  45. package/dist/core/autoupdate/fingerprint.d.ts +16 -0
  46. package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
  47. package/dist/core/autoupdate/fingerprint.js +73 -0
  48. package/dist/core/autoupdate/git.d.ts +30 -0
  49. package/dist/core/autoupdate/git.d.ts.map +1 -0
  50. package/dist/core/autoupdate/git.js +82 -0
  51. package/dist/core/autoupdate/index.d.ts +6 -0
  52. package/dist/core/autoupdate/index.d.ts.map +1 -0
  53. package/dist/core/autoupdate/index.js +4 -0
  54. package/dist/core/autoupdate/state.d.ts +59 -0
  55. package/dist/core/autoupdate/state.d.ts.map +1 -0
  56. package/dist/core/autoupdate/state.js +214 -0
  57. package/dist/core/autoupdate/types.d.ts +113 -0
  58. package/dist/core/autoupdate/types.d.ts.map +1 -0
  59. package/dist/core/autoupdate/types.js +35 -0
  60. package/dist/core/config.d.ts +53 -1
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +114 -1
  63. package/dist/core/events/v3/bootstrap.js +1 -0
  64. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  65. package/dist/core/events/v3/runtime-build.js +1 -0
  66. package/dist/core/hooks/adapter/wiring.d.ts +12 -2
  67. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  68. package/dist/core/hooks/adapter/wiring.js +38 -8
  69. package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
  70. package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
  71. package/dist/core/hooks/autoupdate-cue.js +53 -0
  72. package/dist/core/hooks/cli.js +28 -3
  73. package/dist/core/hooks/effects/index.js +4 -4
  74. package/dist/core/hooks/resolve/coord-root.js +1 -0
  75. package/dist/core/semantic/contract.d.ts +3 -3
  76. package/dist/core/semantic/service.d.ts.map +1 -1
  77. package/dist/core/semantic/service.js +4 -2
  78. package/dist/core/storage/modes.d.ts +1 -1
  79. package/dist/core/storage/modes.d.ts.map +1 -1
  80. package/dist/core/storage/modes.js +5 -1
  81. package/dist/core/workflow/durable-record.d.ts +2 -0
  82. package/dist/core/workflow/durable-record.d.ts.map +1 -1
  83. package/dist/core/workflow/durable-record.js +13 -1
  84. package/dist/core/workflow/engine.js +4 -0
  85. package/dist/core/workflow/spawn-claude.d.ts +7 -0
  86. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  87. package/dist/core/workflow/spawn-claude.js +16 -4
  88. package/dist/core/workflow/specialists.d.ts.map +1 -1
  89. package/dist/core/workflow/specialists.js +1 -0
  90. package/dist/core/workflow/types.d.ts +10 -0
  91. package/dist/core/workflow/types.d.ts.map +1 -1
  92. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  93. package/dist/core/workflow/workspaces/leases.js +1 -9
  94. package/dist/lib/computer/cua.d.ts +50 -0
  95. package/dist/lib/computer/cua.d.ts.map +1 -0
  96. package/dist/lib/computer/cua.js +474 -0
  97. package/dist/lib/computer/host.d.ts +19 -0
  98. package/dist/lib/computer/host.d.ts.map +1 -0
  99. package/dist/lib/computer/host.js +72 -0
  100. package/dist/lib/computer/lease.d.ts +13 -0
  101. package/dist/lib/computer/lease.d.ts.map +1 -0
  102. package/dist/lib/computer/lease.js +269 -0
  103. package/dist/lib/computer/types.d.ts +163 -0
  104. package/dist/lib/computer/types.d.ts.map +1 -0
  105. package/dist/lib/computer/types.js +10 -0
  106. package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
  107. package/dist/lib/docs-metadata-v2.js +6 -5
  108. package/dist/lib/exec.d.ts.map +1 -1
  109. package/dist/lib/exec.js +1 -0
  110. package/dist/lib/guarded-remove.d.ts.map +1 -1
  111. package/dist/lib/guarded-remove.js +5 -4
  112. package/dist/lib/headless/index.d.ts +33 -0
  113. package/dist/lib/headless/index.d.ts.map +1 -1
  114. package/dist/lib/headless/index.js +38 -2
  115. package/dist/lib/http/client.d.ts +2 -9
  116. package/dist/lib/http/client.d.ts.map +1 -1
  117. package/dist/lib/http/client.js +30 -9
  118. package/dist/lib/instructions/apply.d.ts +9 -0
  119. package/dist/lib/instructions/apply.d.ts.map +1 -1
  120. package/dist/lib/instructions/apply.js +92 -14
  121. package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
  122. package/dist/lib/instructions/git-hooks.js +8 -4
  123. package/dist/lib/instructions/templates.d.ts +11 -0
  124. package/dist/lib/instructions/templates.d.ts.map +1 -1
  125. package/dist/lib/instructions/templates.js +81 -1
  126. package/package.json +5 -5
  127. package/schemas/config.schema.json +89 -1
  128. package/src/commander.ts +98 -8
  129. package/src/commands/agents.ts +100 -32
  130. package/src/commands/autoupdate.ts +250 -0
  131. package/src/commands/computer.ts +682 -0
  132. package/src/commands/docs.ts +3 -1
  133. package/src/commands/fetch.ts +11 -1
  134. package/src/commands/grep.ts +274 -59
  135. package/src/commands/init.ts +17 -7
  136. package/src/commands/rm.ts +2 -2
  137. package/src/commands/web.ts +2 -0
  138. package/src/core/agents/canonical-emit.ts +2 -3
  139. package/src/core/agents/coord-bin.ts +30 -0
  140. package/src/core/agents/coord-client.ts +6 -2
  141. package/src/core/agents/render/prompt-context.ts +152 -53
  142. package/src/core/agents/state/proc-start.ts +29 -5
  143. package/src/core/autoupdate/engine.ts +691 -0
  144. package/src/core/autoupdate/fingerprint.ts +89 -0
  145. package/src/core/autoupdate/git.ts +120 -0
  146. package/src/core/autoupdate/index.ts +29 -0
  147. package/src/core/autoupdate/state.ts +269 -0
  148. package/src/core/autoupdate/types.ts +135 -0
  149. package/src/core/config.ts +142 -1
  150. package/src/core/events/v3/bootstrap.ts +1 -0
  151. package/src/core/events/v3/runtime-build.ts +1 -0
  152. package/src/core/hooks/adapter/wiring.ts +51 -6
  153. package/src/core/hooks/autoupdate-cue.ts +72 -0
  154. package/src/core/hooks/cli.ts +27 -1
  155. package/src/core/hooks/effects/index.ts +4 -4
  156. package/src/core/hooks/resolve/coord-root.ts +1 -0
  157. package/src/core/semantic/service.ts +6 -2
  158. package/src/core/storage/modes.ts +7 -1
  159. package/src/core/workflow/durable-record.ts +15 -1
  160. package/src/core/workflow/engine.ts +3 -0
  161. package/src/core/workflow/spawn-claude.ts +16 -4
  162. package/src/core/workflow/specialists.ts +1 -0
  163. package/src/core/workflow/types.ts +10 -0
  164. package/src/core/workflow/workspaces/leases.ts +1 -9
  165. package/src/lib/computer/cua.ts +572 -0
  166. package/src/lib/computer/host.ts +87 -0
  167. package/src/lib/computer/lease.ts +299 -0
  168. package/src/lib/computer/types.ts +177 -0
  169. package/src/lib/docs-metadata-v2.ts +24 -5
  170. package/src/lib/exec.ts +1 -0
  171. package/src/lib/guarded-remove.ts +5 -3
  172. package/src/lib/headless/index.ts +66 -2
  173. package/src/lib/http/client.ts +33 -19
  174. package/src/lib/instructions/apply.ts +100 -12
  175. package/src/lib/instructions/git-hooks.ts +8 -4
  176. package/src/lib/instructions/templates.ts +95 -1
@@ -0,0 +1,682 @@
1
+ // `computer`: opt-in desktop control through one pinned desktop driver
2
+ // (ADR 0228). The driver adapter (src/lib/computer/cua.ts), host detection and
3
+ // the cross-call desktop lease live in src/lib/computer/; this command owns
4
+ // the config gate, flag parsing, lease policy for `call`, screenshot redaction,
5
+ // rendering, and exit codes.
6
+ //
7
+ // Exit codes: 0 ok · 1 error (disabled, unsupported host, not installed,
8
+ // driver failure; `call` propagates the driver's own exit code) · 3 the
9
+ // desktop lease is held by another agent.
10
+
11
+ import { hostname, userInfo } from "node:os";
12
+ import { Command } from "commander";
13
+ import type { EmitContext } from "../commander.ts";
14
+ import { resolveCoordRoot, resolveOwner } from "../core/agents/coord-client.ts";
15
+ import { agentDisplayName, resolveShortName } from "../core/agents/state/activity-log.ts";
16
+ import { type ComputerProjectConfig, readComputerConfig } from "../core/config.ts";
17
+ import type {
18
+ DaemonStatus,
19
+ DesktopDriver,
20
+ DesktopLease,
21
+ DriverCallResult,
22
+ HostInfo,
23
+ LeaseApi,
24
+ LeaseOptions,
25
+ LeaseOwner,
26
+ } from "../lib/computer/types.ts";
27
+
28
+ /** Exit code for a desktop held by another agent. */
29
+ export const EXIT_LEASE_HELD = 3;
30
+ /** `call` waits this long for another agent's lease when nothing overrides it. */
31
+ export const DEFAULT_CALL_WAIT_SECONDS = 30;
32
+
33
+ /** Everything the command touches outside itself; tests inject fakes. */
34
+ export interface ComputerDeps {
35
+ emit: EmitContext;
36
+ binName: string;
37
+ host: () => HostInfo;
38
+ driver: DesktopDriver;
39
+ lease: LeaseApi;
40
+ owner: () => LeaseOwner;
41
+ config: () => ComputerProjectConfig;
42
+ /** Lease directory for a desktop state dir. */
43
+ leaseDir: (desktopStateDir: string) => string;
44
+ /** Idle expiry when config sets none. */
45
+ defaultIdleMs: number;
46
+ /** Driver stderr passthrough. */
47
+ writeStderr: (text: string) => void;
48
+ now: () => number;
49
+ }
50
+
51
+ export type ComputerDepsInput = Pick<ComputerDeps, "emit" | "binName"> & Partial<ComputerDeps>;
52
+
53
+ /**
54
+ * Who is asking for the desktop: the coordinated agent when one is resolvable,
55
+ * else the calling OS user (`shell:<user>@<hostname>`, label `shell-<user>`), so
56
+ * separate shell invocations by the same person share one lease.
57
+ */
58
+ export function resolveLeaseOwner(): LeaseOwner {
59
+ const root = resolveCoordRoot();
60
+ const id = resolveOwner();
61
+ if (id) {
62
+ return {
63
+ id,
64
+ label: root ? resolveShortName(root, id) : agentDisplayName(id),
65
+ ...(root ? { project: root } : {}),
66
+ };
67
+ }
68
+ const user = userInfo().username;
69
+ return {
70
+ id: `shell:${user}@${hostname()}`,
71
+ label: `shell-${user}`,
72
+ ...(root ? { project: root } : {}),
73
+ };
74
+ }
75
+
76
+ async function loadDefaultDeps(): Promise<
77
+ Pick<ComputerDeps, "host" | "driver" | "lease" | "leaseDir" | "defaultIdleMs">
78
+ > {
79
+ const [{ detectHost }, { cuaDriver }, { desktopLease, leaseDir, DEFAULT_LEASE_IDLE_MS }] =
80
+ await Promise.all([
81
+ import("../lib/computer/host.ts"),
82
+ import("../lib/computer/cua.ts"),
83
+ import("../lib/computer/lease.ts"),
84
+ ]);
85
+ return {
86
+ host: () => detectHost(),
87
+ driver: cuaDriver,
88
+ lease: desktopLease,
89
+ leaseDir,
90
+ defaultIdleMs: DEFAULT_LEASE_IDLE_MS,
91
+ };
92
+ }
93
+
94
+ /** Fill in every dependency the caller did not inject; driver modules load lazily. */
95
+ async function resolveDeps(input: ComputerDepsInput): Promise<ComputerDeps> {
96
+ const needsDefaults =
97
+ !input.host ||
98
+ !input.driver ||
99
+ !input.lease ||
100
+ !input.leaseDir ||
101
+ input.defaultIdleMs === undefined;
102
+ const defaults = needsDefaults ? await loadDefaultDeps() : undefined;
103
+ return {
104
+ emit: input.emit,
105
+ binName: input.binName,
106
+ host: input.host ?? (defaults?.host as ComputerDeps["host"]),
107
+ driver: input.driver ?? (defaults?.driver as DesktopDriver),
108
+ lease: input.lease ?? (defaults?.lease as LeaseApi),
109
+ leaseDir: input.leaseDir ?? (defaults?.leaseDir as ComputerDeps["leaseDir"]),
110
+ defaultIdleMs: input.defaultIdleMs ?? (defaults?.defaultIdleMs as number),
111
+ owner: input.owner ?? resolveLeaseOwner,
112
+ config: input.config ?? (() => readComputerConfig()),
113
+ writeStderr: input.writeStderr ?? ((text) => process.stderr.write(text)),
114
+ now: input.now ?? Date.now,
115
+ };
116
+ }
117
+
118
+ class ComputerError extends Error {
119
+ constructor(
120
+ readonly code: string,
121
+ message: string,
122
+ readonly exitCode = 1,
123
+ readonly hint?: string,
124
+ ) {
125
+ super(message);
126
+ }
127
+ }
128
+
129
+ function enableHint(): string {
130
+ return 'Add `"computer": { "enabled": true }` to .harnery/config.jsonc to enable it for this project.';
131
+ }
132
+
133
+ function requireEnabled(deps: ComputerDeps): void {
134
+ if (deps.config().enabled) return;
135
+ throw new ComputerError(
136
+ "computer_disabled",
137
+ `Computer use is off in this project. ${enableHint()}`,
138
+ );
139
+ }
140
+
141
+ type SupportedHost = HostInfo & { desktopStateDir: string };
142
+
143
+ function requireSupported(host: HostInfo): SupportedHost {
144
+ if (host.kind === "unsupported" || !host.desktopStateDir) {
145
+ throw new ComputerError(
146
+ "computer_unsupported_host",
147
+ `Computer use is not available on this host: ${host.reason ?? "no supported desktop driver build"}`,
148
+ );
149
+ }
150
+ return host as SupportedHost;
151
+ }
152
+
153
+ function requireInstalled(deps: ComputerDeps, host: HostInfo): string {
154
+ const exe = deps.driver.installedExecutable(host);
155
+ if (exe) return exe;
156
+ throw new ComputerError(
157
+ "computer_not_installed",
158
+ `The pinned desktop driver (${deps.driver.id} ${deps.driver.version}) is not installed. Run \`${deps.binName} computer install --yes\`.`,
159
+ );
160
+ }
161
+
162
+ function leaseOptions(deps: ComputerDeps, host: SupportedHost, waitMs = 0): LeaseOptions {
163
+ const idleSeconds = deps.config().idle_seconds;
164
+ return {
165
+ dir: deps.leaseDir(host.desktopStateDir),
166
+ idleMs: idleSeconds !== undefined ? Math.round(idleSeconds * 1000) : deps.defaultIdleMs,
167
+ waitMs,
168
+ now: deps.now,
169
+ };
170
+ }
171
+
172
+ function expiresInSeconds(lease: DesktopLease, nowMs: number): number {
173
+ return Math.max(0, Math.ceil((Date.parse(lease.expires_at) - nowMs) / 1000));
174
+ }
175
+
176
+ function describeLease(lease: DesktopLease, nowMs: number): string {
177
+ return `${lease.owner_label} (generation ${lease.generation}, idle-expires in ${expiresInSeconds(lease, nowMs)}s)`;
178
+ }
179
+
180
+ function heldError(deps: ComputerDeps, holder: DesktopLease): ComputerError {
181
+ return new ComputerError(
182
+ "computer_lease_held",
183
+ `Desktop is held by ${describeLease(holder, deps.now())}. Wait, or ask them to run \`${deps.binName} computer lease release\`.`,
184
+ EXIT_LEASE_HELD,
185
+ );
186
+ }
187
+
188
+ function parseSeconds(raw: string | undefined, flag: string): number | undefined {
189
+ if (raw === undefined) return undefined;
190
+ const value = Number(raw);
191
+ if (!Number.isFinite(value) || value < 0 || value > 3600) {
192
+ throw new ComputerError("computer_usage", `${flag} must be a number of seconds from 0 to 3600`);
193
+ }
194
+ return value;
195
+ }
196
+
197
+ function parseCallArgs(raw: string | undefined): Record<string, unknown> {
198
+ if (raw === undefined || raw.trim() === "") return {};
199
+ let parsed: unknown;
200
+ try {
201
+ parsed = JSON.parse(raw);
202
+ } catch (err) {
203
+ throw new ComputerError(
204
+ "computer_usage",
205
+ `Tool arguments must be JSON: ${(err as Error).message}`,
206
+ );
207
+ }
208
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
209
+ throw new ComputerError("computer_usage", "Tool arguments must be a JSON object.");
210
+ }
211
+ return parsed as Record<string, unknown>;
212
+ }
213
+
214
+ const PNG_B64_RE = /"([A-Za-z0-9_]*png_b64)"(\s*:\s*)"((?:[^"\\]|\\.)*)"/g;
215
+
216
+ /**
217
+ * Replace inline base64 screenshots in driver output with a short note, so a
218
+ * single state read cannot flood the calling agent's context. Works on the raw
219
+ * text, so the driver's formatting survives.
220
+ */
221
+ export function redactInlineScreenshots(stdout: string): string {
222
+ return stdout.replace(
223
+ PNG_B64_RE,
224
+ (_match, key: string, sep: string, value: string) =>
225
+ `"${key}"${sep}"<omitted ${value.length} chars; pass screenshot_out_file or --keep-screenshot>"`,
226
+ );
227
+ }
228
+
229
+ function printDriverResult(deps: ComputerDeps, result: DriverCallResult, stdout = result.stdout) {
230
+ if (stdout) emitLine(deps.emit, stdout);
231
+ if (result.stderr)
232
+ deps.writeStderr(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`);
233
+ if (result.exitCode !== 0) deps.emit.setExitCode(result.exitCode);
234
+ }
235
+
236
+ async function ensureDaemon(deps: ComputerDeps, host: HostInfo): Promise<DaemonStatus> {
237
+ const current = deps.driver.daemonStatus(host);
238
+ if (current.running) return current;
239
+ const started = await deps.driver.startDaemon(host);
240
+ if (!started.running) {
241
+ throw new ComputerError(
242
+ "computer_daemon_failed",
243
+ `The desktop driver daemon did not start${started.detail ? `: ${started.detail}` : "."}`,
244
+ );
245
+ }
246
+ return started;
247
+ }
248
+
249
+ /** Write human-readable text, ending it with exactly one newline. */
250
+ function emitLine(emit: { text(s: string): void }, s: string): void {
251
+ emit.text(s.endsWith("\n") ? s : `${s}\n`);
252
+ }
253
+
254
+ /** Run one action, turning a ComputerError (or any throw) into emit.error + exit code. */
255
+ function guarded<A extends unknown[]>(
256
+ input: ComputerDepsInput,
257
+ body: (deps: ComputerDeps, ...args: A) => Promise<void> | void,
258
+ ): (...args: A) => Promise<void> {
259
+ return async (...args: A) => {
260
+ const deps = await resolveDeps(input);
261
+ try {
262
+ await body(deps, ...args);
263
+ } catch (err) {
264
+ if (err instanceof ComputerError) {
265
+ deps.emit.error({
266
+ code: err.code,
267
+ message: err.message,
268
+ ...(err.hint ? { hint: err.hint } : {}),
269
+ });
270
+ deps.emit.setExitCode(err.exitCode);
271
+ return;
272
+ }
273
+ deps.emit.error({ code: "computer_error", message: (err as Error).message ?? String(err) });
274
+ deps.emit.setExitCode(1);
275
+ }
276
+ };
277
+ }
278
+
279
+ interface DoctorCheck {
280
+ name: string;
281
+ status: string;
282
+ message?: string;
283
+ }
284
+
285
+ /** Pull `{name, status, message}` checks out of the driver's health report, leniently. */
286
+ function healthChecks(json: unknown): DoctorCheck[] | undefined {
287
+ const source = Array.isArray(json)
288
+ ? json
289
+ : json && typeof json === "object"
290
+ ? (json as Record<string, unknown>).checks
291
+ : undefined;
292
+ const toCheck = (name: string, value: unknown): DoctorCheck | undefined => {
293
+ if (!value || typeof value !== "object") return undefined;
294
+ const record = value as Record<string, unknown>;
295
+ const status =
296
+ record.status ??
297
+ record.state ??
298
+ (record.ok === true ? "pass" : record.ok === false ? "fail" : undefined);
299
+ if (typeof status !== "string") return undefined;
300
+ const message = record.message ?? record.detail;
301
+ return {
302
+ name: typeof record.name === "string" ? record.name : name,
303
+ status,
304
+ ...(typeof message === "string" ? { message } : {}),
305
+ };
306
+ };
307
+ if (Array.isArray(source)) {
308
+ const checks = source
309
+ .map((value, i) => toCheck(`check-${i + 1}`, value))
310
+ .filter((c): c is DoctorCheck => c !== undefined);
311
+ return checks.length > 0 ? checks : undefined;
312
+ }
313
+ if (source && typeof source === "object") {
314
+ const checks = Object.entries(source as Record<string, unknown>)
315
+ .map(([name, value]) => toCheck(name, value))
316
+ .filter((c): c is DoctorCheck => c !== undefined);
317
+ return checks.length > 0 ? checks : undefined;
318
+ }
319
+ return undefined;
320
+ }
321
+
322
+ // "skip" is a check that does not apply on this platform, not a failure.
323
+ const PASSING = new Set(["pass", "passed", "ok", "healthy", "success", "skip", "skipped", "info"]);
324
+
325
+ /**
326
+ * Build the `computer` command tree under `parent` (a fresh root when omitted)
327
+ * and return it. Tests pass fakes for host/driver/lease/owner/config.
328
+ */
329
+ export function createComputerCommand(
330
+ input: ComputerDepsInput,
331
+ parent: Command = new Command(),
332
+ ): Command {
333
+ const bin = input.binName;
334
+ const computer = parent
335
+ .command("computer")
336
+ .description(
337
+ "Opt-in desktop control through a pinned desktop driver: install it, run its daemon, " +
338
+ "and call its tools while holding a desktop lease shared by every agent on this machine.",
339
+ )
340
+ .addHelpText(
341
+ "after",
342
+ `\nEnable per project with "computer": { "enabled": true } in .harnery/config.jsonc.\n` +
343
+ `Exit codes: 0 ok · 1 error · ${EXIT_LEASE_HELD} desktop held by another agent.\n` +
344
+ `Start with \`${bin} computer status\`.`,
345
+ );
346
+
347
+ // ------------------------------------------------------------------ status
348
+ computer
349
+ .command("status")
350
+ .description(
351
+ "Show the host, pinned driver, install, daemon, desktop lease holder, and enabled flag.",
352
+ )
353
+ .option("--json", "Emit the status as JSON.")
354
+ .action(
355
+ guarded(input, (deps, opts: { json?: boolean }) => {
356
+ const config = deps.config();
357
+ const host = deps.host();
358
+ const supported = host.kind !== "unsupported" && Boolean(host.desktopStateDir);
359
+ const installed = supported ? (deps.driver.installedExecutable(host) ?? null) : null;
360
+ let daemon: DaemonStatus | null = null;
361
+ if (installed) {
362
+ try {
363
+ daemon = deps.driver.daemonStatus(host);
364
+ } catch (err) {
365
+ daemon = { running: false, detail: (err as Error).message };
366
+ }
367
+ }
368
+ const lease = supported ? deps.lease.read(leaseOptions(deps, host as SupportedHost)) : null;
369
+ const nowMs = deps.now();
370
+ if (opts.json) {
371
+ deps.emit.data({
372
+ enabled: config.enabled,
373
+ host: { kind: host.kind, ...(host.reason ? { reason: host.reason } : {}) },
374
+ driver: { id: deps.driver.id, version: deps.driver.version },
375
+ installed,
376
+ daemon,
377
+ lease: lease ? { ...lease, expires_in_seconds: expiresInSeconds(lease, nowMs) } : null,
378
+ });
379
+ return;
380
+ }
381
+ const lines = [
382
+ `enabled: ${config.enabled ? "yes" : `no (${enableHint()})`}`,
383
+ `host: ${host.kind}${host.reason ? ` (${host.reason})` : ""}`,
384
+ `driver: ${deps.driver.id} ${deps.driver.version} (pinned)`,
385
+ ];
386
+ if (supported) {
387
+ lines.push(`installed: ${installed ?? "not installed"}`);
388
+ lines.push(
389
+ `daemon: ${
390
+ daemon
391
+ ? daemon.running
392
+ ? `running${daemon.pid !== undefined ? ` (pid ${daemon.pid})` : ""}`
393
+ : `stopped${daemon.detail ? ` (${daemon.detail})` : ""}`
394
+ : "n/a"
395
+ }`,
396
+ );
397
+ lines.push(`lease: ${lease ? `held by ${describeLease(lease, nowMs)}` : "free"}`);
398
+ }
399
+ emitLine(deps.emit, lines.join("\n"));
400
+ }),
401
+ );
402
+
403
+ // ----------------------------------------------------------------- install
404
+ computer
405
+ .command("install")
406
+ .description(
407
+ "Preview the pinned driver install (URL, SHA-256, install dir, preference file); --yes downloads, verifies, and installs it.",
408
+ )
409
+ .option("--yes", "Apply the install. Without it, only the plan is shown.")
410
+ .option("--json", "Emit the plan or result as JSON.")
411
+ .action(
412
+ guarded(input, async (deps, opts: { yes?: boolean; json?: boolean }) => {
413
+ requireEnabled(deps);
414
+ const host = requireSupported(deps.host());
415
+ const plan = deps.driver.planInstall(host);
416
+ if (!opts.yes) {
417
+ if (opts.json) {
418
+ deps.emit.data({ applied: false, plan });
419
+ return;
420
+ }
421
+ emitLine(
422
+ deps.emit,
423
+ [
424
+ `${plan.alreadyInstalled ? "Already installed" : "Would install"}: ${plan.driver} ${plan.version} for ${plan.host}`,
425
+ ` url: ${plan.artifact.url}`,
426
+ ` sha256: ${plan.artifact.sha256}`,
427
+ ` install dir: ${plan.installDir}`,
428
+ ` executable: ${plan.executable}`,
429
+ ` preferences: ${plan.preferenceFile} ${JSON.stringify(plan.preferences)}`,
430
+ plan.alreadyInstalled
431
+ ? "Nothing to do."
432
+ : `Re-run with --yes to install: \`${deps.binName} computer install --yes\`.`,
433
+ ].join("\n"),
434
+ );
435
+ return;
436
+ }
437
+ const result = await deps.driver.install(plan);
438
+ if (opts.json) {
439
+ deps.emit.data({ applied: true, result });
440
+ return;
441
+ }
442
+ emitLine(
443
+ deps.emit,
444
+ result.installed
445
+ ? `Installed ${result.driver} ${result.version} at ${result.executable}.\nStart it with \`${deps.binName} computer up\`.`
446
+ : `${result.driver} ${result.version} was already installed at ${result.executable}.`,
447
+ );
448
+ }),
449
+ );
450
+
451
+ // ---------------------------------------------------------------- up/down
452
+ computer
453
+ .command("up")
454
+ .description("Start the desktop driver daemon (unelevated) if it is not running.")
455
+ .action(
456
+ guarded(input, async (deps) => {
457
+ requireEnabled(deps);
458
+ const host = requireSupported(deps.host());
459
+ requireInstalled(deps, host);
460
+ const status = await ensureDaemon(deps, host);
461
+ emitLine(
462
+ deps.emit,
463
+ `Daemon running${status.pid !== undefined ? ` (pid ${status.pid})` : ""}.`,
464
+ );
465
+ }),
466
+ );
467
+
468
+ computer
469
+ .command("down")
470
+ .description("Stop the desktop driver daemon.")
471
+ .action(
472
+ guarded(input, (deps) => {
473
+ const host = requireSupported(deps.host());
474
+ requireInstalled(deps, host);
475
+ const status = deps.driver.stopDaemon(host);
476
+ emitLine(
477
+ deps.emit,
478
+ status.running ? `Daemon still running: ${status.detail}` : "Daemon stopped.",
479
+ );
480
+ if (status.running) deps.emit.setExitCode(1);
481
+ }),
482
+ );
483
+
484
+ // ------------------------------------------------------------------ doctor
485
+ computer
486
+ .command("doctor")
487
+ .description("Run the driver's own health report; any non-passing check exits 1.")
488
+ .option("--json", "Emit the checks as JSON.")
489
+ .action(
490
+ guarded(input, (deps, opts: { json?: boolean }) => {
491
+ const host = requireSupported(deps.host());
492
+ requireInstalled(deps, host);
493
+ const result = deps.driver.health(host);
494
+ let json = result.json;
495
+ if (json === undefined) {
496
+ try {
497
+ json = JSON.parse(result.stdout);
498
+ } catch {
499
+ json = undefined;
500
+ }
501
+ }
502
+ const checks = healthChecks(json);
503
+ const failing = checks?.filter((c) => !PASSING.has(c.status.toLowerCase())) ?? [];
504
+ const ok = result.exitCode === 0 && checks !== undefined && failing.length === 0;
505
+ if (opts.json) {
506
+ deps.emit.data({
507
+ ok,
508
+ exit_code: result.exitCode,
509
+ checks: checks ?? null,
510
+ report: json ?? result.stdout,
511
+ });
512
+ } else if (checks) {
513
+ emitLine(
514
+ deps.emit,
515
+ checks
516
+ .map((c) => `${c.status.padEnd(6)} ${c.name}${c.message ? `: ${c.message}` : ""}`)
517
+ .join("\n"),
518
+ );
519
+ } else {
520
+ if (result.stdout) emitLine(deps.emit, result.stdout);
521
+ emitLine(deps.emit, "No structured checks in the driver's health report.");
522
+ }
523
+ if (result.stderr) deps.writeStderr(result.stderr);
524
+ if (!ok) deps.emit.setExitCode(1);
525
+ }),
526
+ );
527
+
528
+ // ---------------------------------------------------------- tools/describe
529
+ computer
530
+ .command("tools")
531
+ .description("List the driver's tools (name: description).")
532
+ .action(
533
+ guarded(input, (deps) => {
534
+ const host = requireSupported(deps.host());
535
+ requireInstalled(deps, host);
536
+ printDriverResult(deps, deps.driver.listTools(host));
537
+ }),
538
+ );
539
+
540
+ computer
541
+ .command("describe <tool>")
542
+ .description("Show one tool's description and input schema.")
543
+ .action(
544
+ guarded(input, (deps, tool: string) => {
545
+ const host = requireSupported(deps.host());
546
+ requireInstalled(deps, host);
547
+ printDriverResult(deps, deps.driver.describe(host, tool));
548
+ }),
549
+ );
550
+
551
+ // -------------------------------------------------------------------- call
552
+ computer
553
+ .command("call <tool> [json]")
554
+ .description(
555
+ "Call one driver tool with a JSON object of arguments, holding the desktop lease. " +
556
+ "`session` defaults to your agent name; inline base64 screenshots are redacted unless --keep-screenshot.",
557
+ )
558
+ .option(
559
+ "--wait <seconds>",
560
+ "How long to wait for another agent's lease (default: config wait_seconds or 30).",
561
+ )
562
+ .option("--keep-screenshot", "Print inline base64 screenshots instead of redacting them.")
563
+ .action(
564
+ guarded(
565
+ input,
566
+ async (
567
+ deps,
568
+ tool: string,
569
+ rawJson: string | undefined,
570
+ opts: { wait?: string; keepScreenshot?: boolean },
571
+ ) => {
572
+ requireEnabled(deps);
573
+ const host = requireSupported(deps.host());
574
+ requireInstalled(deps, host);
575
+ const args = parseCallArgs(rawJson);
576
+ const waitSeconds =
577
+ parseSeconds(opts.wait, "--wait") ??
578
+ deps.config().wait_seconds ??
579
+ DEFAULT_CALL_WAIT_SECONDS;
580
+ const owner = deps.owner();
581
+ if (!("session" in args)) args.session = owner.label;
582
+
583
+ const acquired = await deps.lease.acquire(
584
+ owner,
585
+ leaseOptions(deps, host, Math.round(waitSeconds * 1000)),
586
+ );
587
+ if (!acquired.ok) throw heldError(deps, acquired.holder);
588
+
589
+ await ensureDaemon(deps, host);
590
+ const translated = deps.driver.translateArgsPaths(host, tool, args);
591
+ const result = deps.driver.call(host, tool, translated);
592
+ let stdout = deps.driver.translateResultPaths(host, result.stdout);
593
+ if (!opts.keepScreenshot) stdout = redactInlineScreenshots(stdout);
594
+ printDriverResult(deps, result, stdout);
595
+ },
596
+ ),
597
+ );
598
+
599
+ // ------------------------------------------------------------------- lease
600
+ const lease = computer
601
+ .command("lease")
602
+ .description(
603
+ "Inspect, take, or release the desktop lease shared by every agent on this machine.",
604
+ );
605
+
606
+ lease
607
+ .command("status")
608
+ .description("Show the current desktop lease holder, or that the desktop is free.")
609
+ .option("--json", "Emit the lease as JSON (null when free).")
610
+ .action(
611
+ guarded(input, (deps, opts: { json?: boolean }) => {
612
+ const host = requireSupported(deps.host());
613
+ const current = deps.lease.read(leaseOptions(deps, host));
614
+ const nowMs = deps.now();
615
+ if (opts.json) {
616
+ deps.emit.data({
617
+ lease: current
618
+ ? { ...current, expires_in_seconds: expiresInSeconds(current, nowMs) }
619
+ : null,
620
+ });
621
+ return;
622
+ }
623
+ emitLine(deps.emit, current ? `held by ${describeLease(current, nowMs)}` : "free");
624
+ }),
625
+ );
626
+
627
+ lease
628
+ .command("acquire")
629
+ .description("Take (or refresh) the desktop lease for yourself without calling a tool.")
630
+ .option("--wait <seconds>", "How long to wait for another agent's lease (default 0).")
631
+ .action(
632
+ guarded(input, async (deps, opts: { wait?: string }) => {
633
+ requireEnabled(deps);
634
+ const host = requireSupported(deps.host());
635
+ const waitSeconds = parseSeconds(opts.wait, "--wait") ?? 0;
636
+ const result = await deps.lease.acquire(
637
+ deps.owner(),
638
+ leaseOptions(deps, host, Math.round(waitSeconds * 1000)),
639
+ );
640
+ if (!result.ok) throw heldError(deps, result.holder);
641
+ emitLine(
642
+ deps.emit,
643
+ `${result.fresh ? "Acquired" : "Refreshed"} the desktop lease: ${describeLease(result.lease, deps.now())}.`,
644
+ );
645
+ }),
646
+ );
647
+
648
+ lease
649
+ .command("release")
650
+ .description("Release the desktop lease if you hold it.")
651
+ .action(
652
+ guarded(input, (deps) => {
653
+ const host = requireSupported(deps.host());
654
+ const owner = deps.owner();
655
+ const released = deps.lease.release(owner, leaseOptions(deps, host));
656
+ if (released) {
657
+ emitLine(deps.emit, `Released the desktop lease held by ${owner.label}.`);
658
+ return;
659
+ }
660
+ const current = deps.lease.read(leaseOptions(deps, host));
661
+ if (!current) {
662
+ emitLine(deps.emit, "The desktop was not leased.");
663
+ return;
664
+ }
665
+ throw new ComputerError(
666
+ "computer_lease_held",
667
+ `Not released: the desktop is held by ${describeLease(current, deps.now())}, not ${owner.label}.`,
668
+ EXIT_LEASE_HELD,
669
+ );
670
+ }),
671
+ );
672
+
673
+ return computer;
674
+ }
675
+
676
+ export function registerComputerCommand(
677
+ program: Command,
678
+ emit: EmitContext,
679
+ binName: string,
680
+ ): void {
681
+ createComputerCommand({ emit, binName }, program);
682
+ }
@@ -344,7 +344,9 @@ async function handleLint(opts: {
344
344
  error_count: errors.length,
345
345
  warning_count: warnings.length,
346
346
  cold_handoffs: cold,
347
- violations,
347
+ // Errors first: a reader (or a truncated pipe) sees what blocks before
348
+ // thousands of advisory warnings.
349
+ violations: [...errors, ...warnings],
348
350
  });
349
351
 
350
352
  if (errors.length > 0) emit.setExitCode(1);