@oh-hai/cli 0.2.0 → 0.2.1

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 (74) hide show
  1. package/README.md +51 -8
  2. package/dist/auth/resolve-token.d.ts +2 -4
  3. package/dist/auth/resolve-token.js +4 -11
  4. package/dist/auth/resolve-token.js.map +1 -1
  5. package/dist/bindings.d.ts +65 -0
  6. package/dist/bindings.js +242 -0
  7. package/dist/bindings.js.map +1 -0
  8. package/dist/cli.js +171 -2
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands/ask.js +21 -17
  11. package/dist/commands/ask.js.map +1 -1
  12. package/dist/commands/binary-update.d.ts +61 -0
  13. package/dist/commands/binary-update.js +160 -0
  14. package/dist/commands/binary-update.js.map +1 -0
  15. package/dist/commands/context.d.ts +123 -0
  16. package/dist/commands/doctor.js +30 -3
  17. package/dist/commands/doctor.js.map +1 -1
  18. package/dist/commands/flags.d.ts +14 -2
  19. package/dist/commands/flags.js +34 -6
  20. package/dist/commands/flags.js.map +1 -1
  21. package/dist/commands/handlers.js +6 -0
  22. package/dist/commands/handlers.js.map +1 -1
  23. package/dist/commands/inbox.js +129 -33
  24. package/dist/commands/inbox.js.map +1 -1
  25. package/dist/commands/messaging/identity.d.ts +9 -0
  26. package/dist/commands/messaging/identity.js +21 -0
  27. package/dist/commands/messaging/identity.js.map +1 -1
  28. package/dist/commands/messaging/validate.d.ts +6 -5
  29. package/dist/commands/messaging/validate.js +6 -5
  30. package/dist/commands/messaging/validate.js.map +1 -1
  31. package/dist/commands/registry.js +71 -8
  32. package/dist/commands/registry.js.map +1 -1
  33. package/dist/commands/setup.d.ts +17 -0
  34. package/dist/commands/setup.js +103 -3
  35. package/dist/commands/setup.js.map +1 -1
  36. package/dist/commands/task.js +21 -24
  37. package/dist/commands/task.js.map +1 -1
  38. package/dist/commands/teach.d.ts +13 -0
  39. package/dist/commands/teach.js +317 -0
  40. package/dist/commands/teach.js.map +1 -0
  41. package/dist/commands/update-check.d.ts +49 -0
  42. package/dist/commands/update-check.js +203 -0
  43. package/dist/commands/update-check.js.map +1 -0
  44. package/dist/commands/upgrade.d.ts +2 -0
  45. package/dist/commands/upgrade.js +178 -0
  46. package/dist/commands/upgrade.js.map +1 -0
  47. package/dist/commands/use.d.ts +2 -0
  48. package/dist/commands/use.js +99 -0
  49. package/dist/commands/use.js.map +1 -0
  50. package/dist/commands/whoami.js +113 -27
  51. package/dist/commands/whoami.js.map +1 -1
  52. package/dist/config-file.js +4 -20
  53. package/dist/config-file.js.map +1 -1
  54. package/dist/config.d.ts +14 -0
  55. package/dist/config.js +39 -3
  56. package/dist/config.js.map +1 -1
  57. package/dist/exit-codes.d.ts +6 -0
  58. package/dist/exit-codes.js +6 -0
  59. package/dist/exit-codes.js.map +1 -1
  60. package/dist/help.js +4 -1
  61. package/dist/help.js.map +1 -1
  62. package/dist/os.d.ts +16 -0
  63. package/dist/os.js +32 -0
  64. package/dist/os.js.map +1 -0
  65. package/dist/url.d.ts +7 -0
  66. package/dist/url.js +14 -0
  67. package/dist/url.js.map +1 -1
  68. package/dist/version.d.ts +11 -3
  69. package/dist/version.js +20 -3
  70. package/dist/version.js.map +1 -1
  71. package/dist/watch-lock.d.ts +85 -0
  72. package/dist/watch-lock.js +358 -0
  73. package/dist/watch-lock.js.map +1 -0
  74. package/package.json +2 -2
package/dist/version.js CHANGED
@@ -5,11 +5,28 @@ import { fileURLToPath } from "node:url";
5
5
  /** The MA2H wire protocol version this CLI targets (docs/specs/cli.md §3). */
6
6
  export const MA2H_WIRE_VERSION = "v0.3";
7
7
  /**
8
- * The CLI's own version, read from package.json relative to this module. Works both when
9
- * run from `dist/version.js` and from `src/version.ts` via tsx — both sit one level under
10
- * the package root, so `../package.json` resolves to the manifest either way.
8
+ * The CLI's own version.
9
+ *
10
+ * A standalone binary built by `bun build --compile` (issue #344) has no package.json on the
11
+ * real filesystem to read, so the dist workflow inlines the version at compile time: it sets
12
+ * OH_HAI_BUILD_VERSION in the build env and passes `--env='OH_HAI_BUILD_*'`, which freezes this
13
+ * `process.env.OH_HAI_BUILD_VERSION` read into the bundle as a literal. We prefer that value **only
14
+ * when running as the compiled Bun executable** (`process.versions.bun` is set) — so a stray
15
+ * OH_HAI_BUILD_VERSION in the environment can NOT hijack `--version` on the npm/Node path, which
16
+ * stays exactly as before: fall back to reading the manifest relative to this module. That read
17
+ * works both from `dist/version.js` and `src/version.ts` via tsx — both sit one level under the
18
+ * package root, so `../package.json` resolves to the manifest either way.
11
19
  */
12
20
  export function cliVersion() {
21
+ // Build-time injected version — trusted ONLY under Bun (the compiled binary), never on the
22
+ // Node/npm path, so an ambient OH_HAI_BUILD_VERSION can't spoof the npm CLI's version. In the
23
+ // compiled binary `process.env.OH_HAI_BUILD_VERSION` is the frozen `--env` literal, not a live
24
+ // read. Empty string is not a valid version, so an unset/blank value falls through to the manifest.
25
+ const runningUnderBun = typeof process.versions.bun === "string";
26
+ const injected = process.env.OH_HAI_BUILD_VERSION;
27
+ if (runningUnderBun && typeof injected === "string" && injected.length > 0) {
28
+ return injected;
29
+ }
13
30
  try {
14
31
  const manifestUrl = new URL("../package.json", import.meta.url);
15
32
  const raw = readFileSync(fileURLToPath(manifestUrl), "utf8");
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,oFAAoF;AACpF,4FAA4F;AAE5F,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,8EAA8E;AAC9E,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC;AAExC;;;;GAIG;AACH,MAAM,UAAU,UAAU;IACxB,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAChE,MAAM,GAAG,GAAG,YAAY,CAAC,aAAa,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAA0B,CAAC;QACxD,OAAO,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;IACvE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,oFAAoF;AACpF,4FAA4F;AAE5F,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,8EAA8E;AAC9E,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC;AAExC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,UAAU;IACxB,2FAA2F;IAC3F,8FAA8F;IAC9F,+FAA+F;IAC/F,oGAAoG;IACpG,MAAM,eAAe,GAAG,OAAQ,OAAO,CAAC,QAA6B,CAAC,GAAG,KAAK,QAAQ,CAAC;IACvF,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC;IAClD,IAAI,eAAe,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC3E,OAAO,QAAQ,CAAC;IAClB,CAAC;IACD,IAAI,CAAC;QACH,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAChE,MAAM,GAAG,GAAG,YAAY,CAAC,aAAa,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAA0B,CAAC;QACxD,OAAO,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;IACvE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC"}
@@ -0,0 +1,85 @@
1
+ /** The injected seam: all disk / clock / pid / host access. Production is `nodeWatchLockIo()`; tests
2
+ * pass an in-memory fake (mirrors `ConfigFileIo` in config-file.ts, `TeachFs` in context.ts). */
3
+ export interface WatchLockIo {
4
+ /** Read a lock file's contents, or undefined when absent/unreadable. */
5
+ readFile(path: string): string | undefined;
6
+ /** Atomically create a lock file ONLY if it does not already exist (O_EXCL), creating parent dirs.
7
+ * Returns true when this call created it, false when the file already existed. This is what makes a
8
+ * same-host cold-start acquire atomic — the loser of a create race sees `false` and re-evaluates
9
+ * rather than clobbering the winner. Throws on a real I/O failure (→ acquire degrades to unavailable). */
10
+ createExclusive(path: string, content: string): boolean;
11
+ /** Atomically (over)write a lock file, creating parent dirs (0700 dir, 0600 file). Used for the
12
+ * heartbeat refresh and to steal a stale/`--force` lock (last-writer-wins). */
13
+ writeFile(path: string, content: string): void;
14
+ /** Best-effort delete; must not throw when the file is already gone. */
15
+ removeFile(path: string): void;
16
+ /** Is a process with this pid alive ON THIS HOST? (meaningful only same-host — pids are host-local). */
17
+ pidAlive(pid: number): boolean;
18
+ /** Epoch milliseconds. */
19
+ now(): number;
20
+ hostname: string;
21
+ pid: number;
22
+ homedir: string;
23
+ xdgStateHome: string | undefined;
24
+ }
25
+ /** A live handle over an acquired lock. */
26
+ export interface WatchLock {
27
+ /** Refresh the heartbeat (call each poll). Best-effort — swallows write errors so a transient disk
28
+ * failure never crashes the loop — and RETURNS whether the write succeeded so the caller can warn
29
+ * once a heartbeat has been failing long enough that a peer may have reclaimed the lock (a silent
30
+ * heartbeat death would otherwise let two watchers drain with no signal). */
31
+ heartbeat(): boolean;
32
+ /** Release on exit — deletes the lockfile IFF it is still ours (a reclaimer/`--force` steal may
33
+ * own it now). Best-effort. The re-read→delete is itself non-atomic vs. a concurrent `--force`
34
+ * steal landing between the two, so a stolen lock could in principle be deleted — a bounded,
35
+ * self-healing residual (the stealer just re-acquires), not fixable without a real CAS. */
36
+ release(): void;
37
+ }
38
+ /** Human-facing (stderr) info about a previous holder — never includes a secret. */
39
+ export interface HolderInfo {
40
+ pid: number;
41
+ hostname: string;
42
+ origin: string;
43
+ agentId: string;
44
+ startedAt: number;
45
+ heartbeatAt: number;
46
+ }
47
+ export type AcquireOutcome = {
48
+ kind: "acquired";
49
+ lock: WatchLock;
50
+ reclaimed: {
51
+ holder: HolderInfo;
52
+ wasLive: boolean;
53
+ } | null;
54
+ } | {
55
+ kind: "held";
56
+ holder: HolderInfo | undefined;
57
+ } | {
58
+ kind: "unavailable";
59
+ reason: string;
60
+ };
61
+ /** Resolve the lock directory: `$XDG_STATE_HOME/oh-hai/watch`, else `~/.local/state/oh-hai/watch`,
62
+ * else undefined (guard unavailable). Mirrors `loadUserConfig`'s absolute-dir gating — a trusted dir
63
+ * must be ABSOLUTE, so a relative XDG/home is ignored. `$XDG_RUNTIME_DIR` is deliberately NOT
64
+ * consulted: its presence varies per shell/session, which would split one identity across two dirs
65
+ * and defeat the guard. A single stable per-user location is the point. */
66
+ export declare function resolveWatchDir(io: WatchLockIo): string | undefined;
67
+ /** The lock filename for an identity. The Hub mailbox is scoped to the AGENT `(accountId, agentId)`,
68
+ * so the discriminator is `(origin, agentId)` — NOT the bearer (one agent can hold several tokens).
69
+ * sha256 gives a clean fixed-length, filesystem-safe name from an arbitrary agent id. NUL-separated
70
+ * so (`ab`, `c`) and (`a`, `bc`) can't collide. */
71
+ export declare function lockFilename(origin: string, agentId: string): string;
72
+ /**
73
+ * Acquire the per-identity watch lock. Refuses when a LIVE watcher already holds this
74
+ * `(origin, agentId)` (unless `force`), reclaims a stale/crashed holder, and degrades to
75
+ * `unavailable` (never throws) when no lock directory can be resolved or the write fails — locking
76
+ * is a safety net, not a hard dependency.
77
+ */
78
+ export declare function acquireWatchLock(io: WatchLockIo, params: {
79
+ origin: string;
80
+ agentId: string;
81
+ intervalMs: number;
82
+ force: boolean;
83
+ }): AcquireOutcome;
84
+ /** Production `WatchLockIo` — real filesystem, clock, process table, and environment. */
85
+ export declare function nodeWatchLockIo(): WatchLockIo;
@@ -0,0 +1,358 @@
1
+ // Concurrent-watch drain-safety guard for `oh-hai inbox watch` (issue #323).
2
+ //
3
+ // `inbox watch` is a DESTRUCTIVE drain+ack of the agent's mailbox — the Hub hands each directive to
4
+ // whichever watcher polls first, so two watchers sharing one identity race and the losing session
5
+ // silently never sees the message. This module is the local, cross-process guard: a lockfile keyed
6
+ // to the identity, with a pid + heartbeat liveness check, so one identity has at most one live
7
+ // watcher — while a crashed watcher's lock is reclaimed (never a permanent block) and a genuinely
8
+ // different identity is never affected.
9
+ //
10
+ // STRENGTH / LIMITS (this is a best-effort courtesy guard, NOT a kernel mutex): it reliably catches
11
+ // the common case — a second watcher started while one is ALREADY established — and closes the
12
+ // same-host cold-start race with an atomic exclusive create (O_EXCL). Two RESIDUAL windows remain,
13
+ // both bounded and self-healing (never a permanent wedge): (a) two watchers racing to RECLAIM the
14
+ // *same* stale lock in the same instant can both proceed — a write-then-verify re-read shrinks but
15
+ // can't fully close it (a userspace lockfile can't compare-and-swap a replace); (b) watchers on
16
+ // DIFFERENT hosts for one identity can't see each other (no shared state) and will split the mailbox.
17
+ // Both are inherent to a per-user-local lockfile; the server-side race they bound is not eliminable here.
18
+ //
19
+ // IDENTITY: the Hub mailbox is scoped to the AGENT — `(accountId, agentId)` (server drainInbox/
20
+ // ackDirectives) — and one agent can hold several active bearers at once (token rotation, env vs
21
+ // keychain). Keying on the token would give two watchers for the SAME agent different locks and miss
22
+ // the race, so the key is the resolved AGENT ID, not the bearer — which also means the token never
23
+ // enters this module at all (§5.4). The lock FILENAME is a sha256 of `(origin, agentId)` for a clean
24
+ // fixed-length name; the BODY carries pid / host / timestamps / origin / agentId / interval — no secret.
25
+ // All disk / clock / pid / host access goes through an injected `WatchLockIo` so the logic is
26
+ // unit-tested against an in-memory map with zero disk (mirrors `ConfigFileIo` and `TeachFs`).
27
+ import { createHash } from "node:crypto";
28
+ import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
29
+ import { homedir as osHomedir, hostname as osHostname } from "node:os";
30
+ import { dirname, isAbsolute, join } from "node:path";
31
+ import { writeSecureFile } from "./auth/secure-write.js";
32
+ import { safeOsValue } from "./os.js";
33
+ /** Freshness floor: a lock whose heartbeat is younger than this is fresh regardless of the holder's
34
+ * interval. Comfortably above the default 5s poll so a normal watcher between polls is never stale. */
35
+ const STALE_FLOOR_MS = 30_000;
36
+ /** A lock is stale once its heartbeat is older than `intervalMs * this` (or the floor, whichever is
37
+ * larger) — sized from the holder's OWN cadence (recorded in the lock) so a deliberately slow poller
38
+ * (`--interval 120000`) isn't falsely declared dead. Three missed heartbeats of slack. */
39
+ const STALE_MULTIPLIER = 3;
40
+ /** Window-sizing fallback for a record whose `intervalMs` is missing/absurd. Distinct from the poll
41
+ * default in inbox.ts (that one is the flag default handed to the loop) — this one is ONLY the stale
42
+ * window basis when a record's interval can't be trusted. The two are intentionally NOT coupled. */
43
+ const DEFAULT_INTERVAL_MS = 5000;
44
+ /** Ceiling on the interval used for stale-window sizing. Caps the window at `this * STALE_MULTIPLIER`
45
+ * (30 min) so a corrupt/hostile record with a huge `intervalMs` can't push a CROSS-host lock's
46
+ * freshness window out to weeks. Well above any realistic presence-poll cadence (default 5s), so a
47
+ * legitimate slow poller is unaffected; same-host liveness is governed by `pidAlive` regardless. */
48
+ const MAX_WINDOW_INTERVAL_MS = 600_000;
49
+ /** Bytes of the sha256 hex digest kept for the filename — 128 bits is collision-safe and one-way. */
50
+ const KEY_HEX_CHARS = 32;
51
+ /** The max epoch-ms `Date` can represent (`Date#toISOString` throws `RangeError` beyond ±this). A
52
+ * record whose timestamps fall outside [0, this] is treated as malformed, so a corrupt/hostile lock
53
+ * can't make a later `new Date(ts).toISOString()` throw. */
54
+ const MAX_TIMESTAMP_MS = 8_640_000_000_000_000;
55
+ /** The max pid `process.kill` accepts (a 32-bit signed positive int). A larger value throws
56
+ * `ERR_INVALID_ARG_TYPE` (NOT `ESRCH`), which `pidAlive` would otherwise read as "alive" and wedge the
57
+ * lock — so a record whose pid exceeds this is treated as malformed → reclaimable. */
58
+ const MAX_PID = 2_147_483_647;
59
+ /** Resolve the lock directory: `$XDG_STATE_HOME/oh-hai/watch`, else `~/.local/state/oh-hai/watch`,
60
+ * else undefined (guard unavailable). Mirrors `loadUserConfig`'s absolute-dir gating — a trusted dir
61
+ * must be ABSOLUTE, so a relative XDG/home is ignored. `$XDG_RUNTIME_DIR` is deliberately NOT
62
+ * consulted: its presence varies per shell/session, which would split one identity across two dirs
63
+ * and defeat the guard. A single stable per-user location is the point. */
64
+ export function resolveWatchDir(io) {
65
+ const xdg = io.xdgStateHome?.trim();
66
+ const home = io.homedir.trim();
67
+ const base = xdg !== undefined && xdg !== "" && isAbsolute(xdg)
68
+ ? xdg
69
+ : home !== "" && isAbsolute(home)
70
+ ? join(home, ".local", "state")
71
+ : undefined;
72
+ return base === undefined ? undefined : join(base, "oh-hai", "watch");
73
+ }
74
+ /** The lock filename for an identity. The Hub mailbox is scoped to the AGENT `(accountId, agentId)`,
75
+ * so the discriminator is `(origin, agentId)` — NOT the bearer (one agent can hold several tokens).
76
+ * sha256 gives a clean fixed-length, filesystem-safe name from an arbitrary agent id. NUL-separated
77
+ * so (`ab`, `c`) and (`a`, `bc`) can't collide. */
78
+ export function lockFilename(origin, agentId) {
79
+ const digest = createHash("sha256").update(`${origin}\u0000${agentId}`).digest("hex");
80
+ return `${digest.slice(0, KEY_HEX_CHARS)}.lock`;
81
+ }
82
+ /** Parse a lock file, tolerating anything malformed as "no valid lock" (→ reclaimable). */
83
+ function parseRecord(raw) {
84
+ if (raw === undefined)
85
+ return undefined;
86
+ let obj;
87
+ try {
88
+ obj = JSON.parse(raw);
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ if (typeof obj !== "object" || obj === null)
94
+ return undefined;
95
+ const r = obj;
96
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
97
+ // Timestamps must be within the range Date can format — a corrupt `heartbeatAt: 1e20` would otherwise
98
+ // survive parsing and later throw RangeError from `new Date(ts).toISOString()` in a warning. Treat
99
+ // out-of-range as malformed → reclaimable.
100
+ const ts = (v) => {
101
+ const n = num(v);
102
+ return n !== undefined && n >= 0 && n <= MAX_TIMESTAMP_MS ? n : undefined;
103
+ };
104
+ // The pid must be a POSITIVE integer within `process.kill`'s valid range. A corrupt `pid: 0` / `-1`
105
+ // / `1.5` would reach `process.kill(pid, 0)` — where 0 targets our process group and -1 every process
106
+ // — and a pid above MAX_PID throws `ERR_INVALID_ARG_TYPE` that `pidAlive` reads as "alive"; all of
107
+ // these could wedge the lock. Reject them as malformed → reclaimable.
108
+ const pidVal = num(r.pid);
109
+ const pid = pidVal !== undefined && Number.isInteger(pidVal) && pidVal >= 1 && pidVal <= MAX_PID ? pidVal : undefined;
110
+ const startedAt = ts(r.startedAt);
111
+ const heartbeatAt = ts(r.heartbeatAt);
112
+ if (pid === undefined || startedAt === undefined || heartbeatAt === undefined)
113
+ return undefined;
114
+ if (typeof r.hostname !== "string" || typeof r.origin !== "string" || typeof r.agentId !== "string")
115
+ return undefined;
116
+ const intervalMs = num(r.intervalMs);
117
+ return {
118
+ v: 1,
119
+ pid,
120
+ hostname: r.hostname,
121
+ startedAt,
122
+ heartbeatAt,
123
+ origin: r.origin,
124
+ agentId: r.agentId,
125
+ // A missing/absurd interval falls back to the default window sizing rather than making the
126
+ // holder look permanently fresh (a huge interval) or instantly stale (zero/negative).
127
+ intervalMs: intervalMs !== undefined && intervalMs > 0 ? intervalMs : DEFAULT_INTERVAL_MS,
128
+ };
129
+ }
130
+ /** Is this holder still live (→ a second watcher must refuse)?
131
+ *
132
+ * SAME-HOST: trust the pid, NOT the heartbeat. A live pid means the watcher is running, even if it's
133
+ * been stuck for a while in a long drain/ack/flush (`--timeout 60000`, a blocked stdout pipe) without
134
+ * refreshing its heartbeat — reclaiming it on heartbeat-age would let a second process drain the same
135
+ * mailbox concurrently, the exact double-drain the guard exists to prevent. A crashed watcher's pid is
136
+ * gone, so `!pidAlive` still reclaims it; our own leftover pid is never live. (The residual: a dead
137
+ * watcher's pid recycled by an UNRELATED live process reads as live until that process exits or a
138
+ * `--force`; heartbeat age is not consulted same-host, so this is bounded by `--force`, not weeks.)
139
+ *
140
+ * CROSS-HOST: pids are host-local and unverifiable, so fall back to heartbeat freshness alone. */
141
+ function isLive(rec, io) {
142
+ if (rec.hostname === io.hostname) {
143
+ if (rec.pid === io.pid)
144
+ return false; // our own stale leftover → reclaim
145
+ return io.pidAlive(rec.pid); // alive → live (regardless of heartbeat age); dead → reclaim
146
+ }
147
+ // Clamp the sizing interval so a corrupt/hostile record can't inflate the window to weeks (adversarial).
148
+ const sizingInterval = Math.min(rec.intervalMs, MAX_WINDOW_INTERVAL_MS);
149
+ const window = Math.max(STALE_FLOOR_MS, sizingInterval * STALE_MULTIPLIER);
150
+ const age = io.now() - rec.heartbeatAt;
151
+ // cross-host: fresh iff the heartbeat is within ±window of now. A small clock skew (either sign) is
152
+ // tolerated, but a heartbeat FAR in the future is as implausible as one far in the past — a corrupt or
153
+ // wildly-skewed-clock lock must NOT read as "live for years" and block crash self-healing (bound the
154
+ // future skew, don't let a negative age be unconditionally fresh).
155
+ return Math.abs(age) <= window;
156
+ }
157
+ function holderInfo(rec) {
158
+ return {
159
+ pid: rec.pid,
160
+ hostname: rec.hostname,
161
+ origin: rec.origin,
162
+ agentId: rec.agentId,
163
+ startedAt: rec.startedAt,
164
+ heartbeatAt: rec.heartbeatAt,
165
+ };
166
+ }
167
+ /** Do two records identify the same acquisition? Keyed on pid+host+startedAt — startedAt is our
168
+ * immutable acquisition stamp, while heartbeatAt changes each poll and is deliberately excluded. */
169
+ function sameHolder(a, b) {
170
+ return a.pid === b.pid && a.hostname === b.hostname && a.startedAt === b.startedAt;
171
+ }
172
+ function makeLock(io, path, record) {
173
+ return {
174
+ heartbeat() {
175
+ // Verify we STILL own the lock before refreshing it. If a reclaimer or a `--force` steal took it
176
+ // over while we ran, an unconditional rewrite would clobber their record with ours — we'd "take
177
+ // the lock back", then our release() would delete it, leaving them unguarded. A displaced holder
178
+ // must NOT write; report false so the caller can warn (it has effectively lost the lock).
179
+ const current = parseRecord(io.readFile(path));
180
+ if (current === undefined || !sameHolder(current, record))
181
+ return false;
182
+ try {
183
+ io.writeFile(path, JSON.stringify({ ...record, heartbeatAt: io.now() }));
184
+ return true;
185
+ }
186
+ catch {
187
+ return false; // best-effort — see the interface doc; the caller warns on sustained failure.
188
+ }
189
+ },
190
+ release() {
191
+ // Delete only if the lock on disk is still OURS. A reclaimer or a `--force` steal may have
192
+ // overwritten it; deleting theirs would drop their guard.
193
+ const current = parseRecord(io.readFile(path));
194
+ if (current !== undefined && sameHolder(current, record)) {
195
+ try {
196
+ io.removeFile(path);
197
+ }
198
+ catch {
199
+ // best-effort
200
+ }
201
+ }
202
+ },
203
+ };
204
+ }
205
+ /**
206
+ * Acquire the per-identity watch lock. Refuses when a LIVE watcher already holds this
207
+ * `(origin, agentId)` (unless `force`), reclaims a stale/crashed holder, and degrades to
208
+ * `unavailable` (never throws) when no lock directory can be resolved or the write fails — locking
209
+ * is a safety net, not a hard dependency.
210
+ */
211
+ export function acquireWatchLock(io, params) {
212
+ const dir = resolveWatchDir(io);
213
+ if (dir === undefined) {
214
+ return { kind: "unavailable", reason: "no XDG_STATE_HOME or home directory to place the lock file" };
215
+ }
216
+ const path = join(dir, lockFilename(params.origin, params.agentId));
217
+ // Read the RAW bytes so we can tell "absent" (undefined) from "present-but-unparseable" (a corrupt
218
+ // record) — the two are distinct: an absent slot is claimed via O_EXCL, a corrupt one is stolen.
219
+ const raw = io.readFile(path);
220
+ const existing = parseRecord(raw);
221
+ if (existing !== undefined && !params.force && isLive(existing, io)) {
222
+ return { kind: "held", holder: holderInfo(existing) };
223
+ }
224
+ const now = io.now();
225
+ const record = {
226
+ v: 1,
227
+ pid: io.pid,
228
+ hostname: io.hostname,
229
+ startedAt: now,
230
+ heartbeatAt: now,
231
+ origin: params.origin,
232
+ agentId: params.agentId,
233
+ intervalMs: params.intervalMs,
234
+ };
235
+ const content = JSON.stringify(record);
236
+ const fail = (error) => ({ kind: "unavailable", reason: error instanceof Error ? error.message : String(error) });
237
+ // Steal an existing lock (stale holder, `--force` takeover, or a corrupt record): overwrite
238
+ // last-writer-wins, then for a NON-force steal verify we actually won — a concurrent claimer whose
239
+ // write landed after ours shows a mismatch, so we back off to `held` rather than double-drain. This
240
+ // shrinks (does not fully close) the two-reclaimers window; `--force` is an explicit takeover so it
241
+ // skips the verify.
242
+ const steal = (reclaimed) => {
243
+ try {
244
+ io.writeFile(path, content);
245
+ }
246
+ catch (error) {
247
+ return fail(error);
248
+ }
249
+ if (!params.force) {
250
+ const after = parseRecord(io.readFile(path));
251
+ if (after !== undefined && !sameHolder(after, record)) {
252
+ return { kind: "held", holder: holderInfo(after) };
253
+ }
254
+ }
255
+ return { kind: "acquired", lock: makeLock(io, path, record), reclaimed };
256
+ };
257
+ if (raw === undefined) {
258
+ // TRULY absent → claim it with an ATOMIC exclusive create, closing the same-host cold-start race.
259
+ let created;
260
+ try {
261
+ created = io.createExclusive(path, content);
262
+ }
263
+ catch (error) {
264
+ return fail(error);
265
+ }
266
+ if (created) {
267
+ return { kind: "acquired", lock: makeLock(io, path, record), reclaimed: null };
268
+ }
269
+ // Lost the create race — a peer created the lock between our read and our create. Re-read the winner.
270
+ const winner = parseRecord(io.readFile(path));
271
+ if (winner === undefined) {
272
+ // The winner's record exists (EEXIST) but isn't readable/parseable — a transient read failure, or a
273
+ // pre-existing UNREADABLE/corrupt lock (bad perms, a manual repair). Back off rather than steal a
274
+ // possibly-live peer — UNLESS `--force`, the documented escape hatch: a rename-based write clears a
275
+ // lock even when the read couldn't open it, so `--force` must not dead-end on an unreadable file.
276
+ return params.force ? steal(null) : { kind: "held", holder: undefined };
277
+ }
278
+ if (!params.force && isLive(winner, io)) {
279
+ return { kind: "held", holder: holderInfo(winner) };
280
+ }
281
+ // The winner is already stale (raced then died), or we're forcing → steal it.
282
+ return steal({ holder: holderInfo(winner), wasLive: isLive(winner, io) });
283
+ }
284
+ // `raw` was DEFINED: a stale/force-over live lock (live+!force was handled above), or an unparseable
285
+ // record → steal it. When `existing === undefined` the on-disk bytes are corrupt — reclaimable, no
286
+ // holder info. This is safe because every writer creates the lock ATOMICALLY (createExclusive links a
287
+ // fully-written temp; writeFile is a temp+rename), so a fresh winner is NEVER partially visible — an
288
+ // unparseable present record is genuine corruption, not a mid-write winner, so reclaiming it can't
289
+ // clobber a live watcher. (`--force` reaches here too and skips the steal's verify.)
290
+ return steal(existing !== undefined ? { holder: holderInfo(existing), wasLive: isLive(existing, io) } : null);
291
+ }
292
+ // Per-process counter so two createExclusive temp files from this process never collide.
293
+ let tmpSeq = 0;
294
+ /** Production `WatchLockIo` — real filesystem, clock, process table, and environment. */
295
+ export function nodeWatchLockIo() {
296
+ return {
297
+ readFile: (path) => {
298
+ try {
299
+ return readFileSync(path, "utf8");
300
+ }
301
+ catch {
302
+ return undefined; // absent / unreadable → "no lock here"
303
+ }
304
+ },
305
+ createExclusive: (path, content) => {
306
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
307
+ // Write the FULL content to a temp file, then hard-link it into place. `link()` is atomic and
308
+ // fails with EEXIST if the target exists (the O_EXCL semantics we need) — and, crucially, the
309
+ // target appears with its COMPLETE content in a single step. So a concurrent reader never sees an
310
+ // empty/partial fresh lock (the window in which it could be mistaken for a corrupt record and
311
+ // stolen, letting both watchers drain). A plain `wx` write does NOT give this: it creates the file
312
+ // empty and then fills it, so a reader can catch it mid-write.
313
+ const tmp = `${path}.tmp.${process.pid}.${tmpSeq++}`;
314
+ try {
315
+ writeFileSync(tmp, content, { mode: 0o600 });
316
+ linkSync(tmp, path);
317
+ return true;
318
+ }
319
+ catch (error) {
320
+ if (error.code === "EEXIST")
321
+ return false;
322
+ throw error; // a real I/O error → acquire degrades to unavailable
323
+ }
324
+ finally {
325
+ try {
326
+ rmSync(tmp, { force: true }); // drop the temp link; the target keeps the content via its own link
327
+ }
328
+ catch {
329
+ // best-effort temp cleanup
330
+ }
331
+ }
332
+ },
333
+ writeFile: (path, content) => writeSecureFile(path, content),
334
+ removeFile: (path) => rmSync(path, { force: true }),
335
+ pidAlive: (pid) => {
336
+ try {
337
+ // Signal 0 performs the existence/permission check without actually signalling.
338
+ process.kill(pid, 0);
339
+ return true;
340
+ }
341
+ catch (error) {
342
+ // Alive ONLY when the error proves the process exists: EPERM (it exists but is owned by another
343
+ // user). ESRCH (no such process) and every OTHER error — notably `ERR_INVALID_ARG_TYPE`/
344
+ // `RangeError` for an out-of-range pid — count as NOT alive, so a malformed pid can't wedge the
345
+ // lock by masquerading as live. (parseRecord already rejects out-of-range pids; this is the
346
+ // defense-in-depth backstop.) POSIX-oriented: on Windows the distinction is less reliable, so
347
+ // same-host reclaim degrades toward the safe heartbeat-freshness path — the guard still holds.
348
+ return error.code === "EPERM";
349
+ }
350
+ },
351
+ now: () => Date.now(),
352
+ hostname: safeOsValue(osHostname),
353
+ pid: process.pid,
354
+ homedir: safeOsValue(osHomedir),
355
+ xdgStateHome: process.env.XDG_STATE_HOME,
356
+ };
357
+ }
358
+ //# sourceMappingURL=watch-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch-lock.js","sourceRoot":"","sources":["../src/watch-lock.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-hai/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "OH HAI — the `oh-hai` CLI: reach a human from your agent through an MA2H Hub (notify / ask / task). Placement + machine contract; command bodies land in #106–#108.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^26.1.0",
29
- "tsx": "^4.19.2",
29
+ "tsx": "^4.23.0",
30
30
  "typescript": "^6.0.3"
31
31
  },
32
32
  "scripts": {