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,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
|
+
}
|
package/src/commands/docs.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|