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