hilos-agent 0.11.14 → 0.11.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.14",
3
+ "version": "0.11.15",
4
4
  "description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. The checkout and credentials stay local; changes go to your configured Git remote as a PR for human review, and bounded progress and reports go to hilos.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -6,8 +6,86 @@
6
6
  // logs a periodic "still working…" heartbeat so the run visibly stays alive.
7
7
 
8
8
  import { spawn } from "node:child_process";
9
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
10
+ import { delimiter, extname, isAbsolute, join } from "node:path";
9
11
  import { truncate } from "./util.mjs";
10
12
 
13
+ /**
14
+ * Where a bare command name resolves on this machine, or null when it is not
15
+ * on PATH. On Windows npm installs every CLI (`claude`, `codex`,
16
+ * `cursor-agent`) as a `.cmd` shim, and `PATHEXT` is what cmd.exe consults to
17
+ * find it; Node's spawn consults nothing, so `spawn("claude")` answers ENOENT
18
+ * on a machine where `claude` works in every terminal (1330). Used both by the
19
+ * spawn below and by the daemon's startup check.
20
+ * @param {string} cmd
21
+ * @param {{ env?: Record<string, string | undefined>, platform?: string, cwd?: string }} [opts]
22
+ * @returns {string | null}
23
+ */
24
+ export function resolveCommand(cmd, { env = process.env, platform = process.platform, cwd } = {}) {
25
+ if (!cmd) return null;
26
+ const win = platform === "win32";
27
+ const runnable = (file) => {
28
+ try {
29
+ if (!statSync(file).isFile()) return false;
30
+ if (!win) accessSync(file, fsConstants.X_OK);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ };
36
+ const exts = win
37
+ ? [...new Set(["", ...String(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.toLowerCase())])]
38
+ : [""];
39
+ const withExt = (base) => {
40
+ for (const ext of exts) {
41
+ // "claude" → claude.cmd, but "claude.cmd" stays as written.
42
+ if (ext && extname(base).toLowerCase() === ext) continue;
43
+ if (runnable(base + ext)) return base + ext;
44
+ }
45
+ return null;
46
+ };
47
+ if (isAbsolute(cmd) || /[\\/]/.test(cmd)) return withExt(cwd && !isAbsolute(cmd) ? join(cwd, cmd) : cmd);
48
+ for (const entry of String(env.PATH || env.Path || "").split(win ? ";" : delimiter)) {
49
+ if (!entry) continue;
50
+ const hit = withExt(join(entry.replace(/^"|"$/g, ""), cmd));
51
+ if (hit) return hit;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // cmd.exe reads the whole command line back as text, so a `.cmd` shim has to be
57
+ // launched through ComSpec with every argument re-quoted for that parser. This
58
+ // is the same escaping cross-spawn ships (double quotes around the argument,
59
+ // backslashes doubled before a quote, quotes escaped, cmd metacharacters
60
+ // caret-escaped). Executables (`.exe`) keep the plain spawn.
61
+ function cmdShellQuote(arg) {
62
+ let s = String(arg);
63
+ s = s.replace(/(\\*)"/g, '$1$1\\"');
64
+ s = s.replace(/(\\*)$/, "$1$1");
65
+ s = `"${s}"`;
66
+ return s.replace(/[()\][%!^"`<>&|;, *?]/g, "^$&");
67
+ }
68
+
69
+ /**
70
+ * The spawn plan for `cmd args`: the file to execute and the argv to pass.
71
+ * On Windows a `.cmd`/`.bat` shim goes through ComSpec; everything else is
72
+ * spawned as written (Node resolves `.exe` itself).
73
+ * @param {string} cmd
74
+ * @param {string[]} args
75
+ * @param {{ env?: Record<string, string | undefined>, platform?: string, cwd?: string }} [opts]
76
+ * @returns {{ file: string, args: string[], windowsVerbatimArguments?: boolean }}
77
+ */
78
+ export function spawnPlan(cmd, args, opts = {}) {
79
+ const platform = opts.platform ?? process.platform;
80
+ if (platform !== "win32") return { file: cmd, args };
81
+ const resolved = resolveCommand(cmd, opts) || cmd;
82
+ const ext = extname(resolved).toLowerCase();
83
+ if (ext !== ".cmd" && ext !== ".bat") return { file: resolved, args };
84
+ const comspec = (opts.env ?? process.env).ComSpec || "cmd.exe";
85
+ const line = [resolved, ...args].map(cmdShellQuote).join(" ");
86
+ return { file: comspec, args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true };
87
+ }
88
+
11
89
  /**
12
90
  * Let the daemon finish its existing abort/cleanup path before exiting.
13
91
  * @param {(signal: AbortSignal) => Promise<any>} task
@@ -279,11 +357,14 @@ async function runCliOnce(opts) {
279
357
  // from stdin…"). Nothing we spawn is ever fed via stdin.
280
358
  // Always strip hilos's own token from the child's env (see scrubHilosEnv),
281
359
  // and keep PWD honest about the directory we run in (see envForCwd).
282
- child = spawn(cmd, args, {
360
+ const childEnv = envForCwd(scrubHilosEnv(env || process.env), cwd);
361
+ const plan = spawnPlan(cmd, args, { env: childEnv, cwd });
362
+ child = spawn(plan.file, plan.args, {
283
363
  cwd,
284
364
  detached: true,
285
365
  stdio: ["ignore", "pipe", "pipe"],
286
- env: envForCwd(scrubHilosEnv(env || process.env), cwd),
366
+ env: childEnv,
367
+ ...(plan.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
287
368
  });
288
369
  } catch (error) {
289
370
  resolve({ status: null, stdout: "", stderr: "", error });
@@ -138,10 +138,23 @@ export function defaultProcessInstanceIdentity(
138
138
  }
139
139
  }
140
140
 
141
+ // A directory fsync only hardens the rename/link against power loss; it never
142
+ // decides who owns the seat. Windows refuses FlushFileBuffers on a directory
143
+ // handle (EPERM), and some filesystems answer EINVAL or EBADF. Before 1330 that
144
+ // throw was caught by acquireLock's publish step and reported as "another
145
+ // local daemon already owns this agent", so every Windows daemon refused to
146
+ // start against an empty lock directory.
141
147
  function syncDirectory(fs, path) {
142
- const fd = fs.openSync(path, "r");
148
+ let fd;
149
+ try {
150
+ fd = fs.openSync(path, "r");
151
+ } catch {
152
+ return;
153
+ }
143
154
  try {
144
155
  fs.fsyncSync(fd);
156
+ } catch {
157
+ /* durability nicety only; the link/unlink already happened */
145
158
  } finally {
146
159
  fs.closeSync(fd);
147
160
  }
@@ -178,6 +191,10 @@ export function createIterateClaimRecoveryStore({
178
191
  const processInstanceIdentity = getProcessInstanceIdentity(pid);
179
192
  let lockHeld = false;
180
193
  let heldElectionPath = null;
194
+ // Why the last acquireLock() returned false: "contended" when a live daemon
195
+ // holds the seat, otherwise the filesystem error. run.mjs turns this into
196
+ // an honest startup message (1330).
197
+ let lastAcquireFailure = null;
181
198
 
182
199
  function ensureScope() {
183
200
  fs.mkdirSync(scopeDir, { recursive: true, mode: 0o700 });
@@ -360,9 +377,11 @@ export function createIterateClaimRecoveryStore({
360
377
  acquireLock() {
361
378
  if (lockHeld) return true;
362
379
  if (!ownerKey || !processInstanceIdentity) return false;
380
+ lastAcquireFailure = null;
363
381
  try {
364
382
  ensureScope();
365
383
  } catch (error) {
384
+ lastAcquireFailure = `lock directory unavailable (${scopeDir}): ${error?.message || error}`;
366
385
  log?.error?.(`iterate claim scope unavailable: ${error?.message || error}`);
367
386
  return false;
368
387
  }
@@ -392,12 +411,14 @@ export function createIterateClaimRecoveryStore({
392
411
  }
393
412
  }
394
413
  removeOwnCandidate();
414
+ lastAcquireFailure = `lock file not written (${scopeDir}): ${error?.message || error}`;
395
415
  log?.error?.(`iterate claim scope not locked: ${error?.message || error}`);
396
416
  return false;
397
417
  }
398
418
 
399
419
  if (!acquireElection()) {
400
420
  removeOwnCandidate();
421
+ lastAcquireFailure = "contended";
401
422
  return false;
402
423
  }
403
424
 
@@ -408,6 +429,7 @@ export function createIterateClaimRecoveryStore({
408
429
  .filter((name) => name.startsWith(".owner.") && name.endsWith(".lock"));
409
430
  } catch (error) {
410
431
  removeOwnPublishedLock();
432
+ lastAcquireFailure = `lock directory not readable (${scopeDir}): ${error?.message || error}`;
411
433
  log?.error?.(`iterate claim scope not inspected: ${error?.message || error}`);
412
434
  return false;
413
435
  }
@@ -440,6 +462,7 @@ export function createIterateClaimRecoveryStore({
440
462
 
441
463
  if (!ownSeen || liveContender) {
442
464
  removeOwnPublishedLock();
465
+ lastAcquireFailure = "contended";
443
466
  return false;
444
467
  }
445
468
 
@@ -460,6 +483,11 @@ export function createIterateClaimRecoveryStore({
460
483
  return true;
461
484
  },
462
485
 
486
+ /** Why the last acquireLock() failed: "contended" or a filesystem reason. */
487
+ acquireFailure() {
488
+ return lastAcquireFailure;
489
+ },
490
+
463
491
  releaseLock() {
464
492
  if (!ownsPublishedLock()) return false;
465
493
  if (!removeOwnPublishedLock()) {
package/src/run.mjs CHANGED
@@ -15,6 +15,7 @@ import { startWake, createWakeGate } from "./wake.mjs";
15
15
  import { scanReplyBridge, handleReplyBridgeJob } from "./reply-bridge.mjs";
16
16
  import { detectVendor, fastChatCmd, webCapability } from "./progress-emitter.mjs";
17
17
  import { commandArgv } from "./argv.mjs";
18
+ import { resolveCommand } from "./cli.mjs";
18
19
  import {
19
20
  createIterateClaimRecoveryStore,
20
21
  reconcileDaemonIterateClaims,
@@ -31,6 +32,15 @@ const HILOS_DAEMON_TOOLS = Object.freeze({
31
32
  decisionWaitMs: 20_000,
32
33
  });
33
34
 
35
+ /** Where to get the vendor CLI a daemon command names (1330). */
36
+ function installHint(vendor) {
37
+ if (vendor === "cursor") return "the Cursor CLI is separate from the Cursor app (macOS/Linux: curl https://cursor.com/install -fsS | bash; Windows: irm https://cursor.com/install -useb | iex)";
38
+ if (vendor === "claude_code") return "npm install -g @anthropic-ai/claude-code";
39
+ if (vendor === "codex") return "npm install -g @openai/codex";
40
+ if (vendor === "opencode") return "https://opencode.ai";
41
+ return "";
42
+ }
43
+
34
44
  /**
35
45
  * The poll loop. Embeddable: pass a `signal` to stop it cleanly (interrupts the
36
46
  * inter-poll sleep, cancels the active job, then resolves) and an `onEvent`
@@ -188,6 +198,19 @@ export async function run(
188
198
  enabled: cfg.webSearch !== false,
189
199
  args: commandArgv(cfg.codingCmd).slice(1),
190
200
  });
201
+ // 1330 — say at startup, not at the first mention, when the CLI this daemon
202
+ // is configured to drive is not installed. A person who connected through an
203
+ // IDE agent read "Hilo is connected and online" and then got "(my chat
204
+ // command `cursor-agent …` isn't installed or on PATH.)" in the room.
205
+ for (const [role, command] of [["chat", chatCommand], ["code", cfg.codingCmd]]) {
206
+ const bin = commandArgv(command)[0];
207
+ if (!bin || resolveCommand(bin)) continue;
208
+ const hint = installHint(detectVendor(command));
209
+ log.error(
210
+ `${role} command not found: \`${bin}\` is not installed or not on PATH${hint ? ` — ${hint}` : ""}. Mentions will fail until it is.`,
211
+ );
212
+ emit({ type: "status", text: `${role} command missing: ${bin}` });
213
+ }
191
214
  log.log(
192
215
  chatVendor === codingVendor && web.status === codingWeb.status
193
216
  ? `web: ${web.status} — ${web.source}`
@@ -218,7 +241,12 @@ export async function run(
218
241
  log,
219
242
  });
220
243
  if (!iterateClaimRecoveryStore.acquireLock()) {
221
- throw new Error("Another local daemon already owns this agent and server connection.");
244
+ const why = iterateClaimRecoveryStore.acquireFailure?.();
245
+ throw new Error(
246
+ why && why !== "contended"
247
+ ? `The local run lock could not be taken: ${why}. Make that directory writable and start again.`
248
+ : "Another local daemon already owns this agent and server connection.",
249
+ );
222
250
  }
223
251
  reconcileClaimsOnce = async (context) => {
224
252
  try {