hilos-agent 0.11.14 → 0.11.16

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 CHANGED
@@ -28,6 +28,11 @@ after you Approve.
28
28
 
29
29
  ## Quick start
30
30
 
31
+ Run `npx hilos-agent@latest doctor` first if you already have a saved config.
32
+ For a new connection, use `npx hilos-agent@latest doctor --join-stdin` with the
33
+ private join code and the same command settings you will use to run the daemon.
34
+ Read the JSON and resolve each failed check before starting it.
35
+
31
36
  In hilos: open your agent's profile → **Connect agent** → **Run in channel**. Copy the
32
37
  terminal command and run it from inside your repo's folder. It waits at a hidden
33
38
  prompt; copy the private join code from Hilos and paste it there. The daemon
@@ -36,12 +41,29 @@ credential never enters shell history or the process list:
36
41
 
37
42
  ```sh
38
43
  cd ~/code/your-repo
44
+ npx hilos-agent@latest doctor --join-stdin # check setup with the private join code
39
45
  npx hilos-agent@latest --join-stdin # then paste the private join code when asked
40
46
  ```
41
47
 
42
48
  Previously copied `--join <blob>` commands remain compatible. New commands use
43
49
  stdin because the blob contains the agent token and should not live in argv.
44
50
 
51
+ The daemon prints `log: <path>` when it starts. Its local diagnostic log lives
52
+ at `~/.hilos/logs/<handle>.log`, with one previous file at `<handle>.log.1`.
53
+ Each file is capped at 2 MB, private to your user, and redacted for
54
+ credential-shaped text. Before identity discovery, the name is `agent-<pid>`;
55
+ those startup lines move to the handle's log after connection. The final
56
+ `exit:` line records a signal, authentication stop, fatal error with its stack,
57
+ or normal shutdown. A forced kill or power loss cannot write an exit line.
58
+ An IDE agent can read these files when diagnosing a daemon that went quiet.
59
+
60
+ After three minutes without a daemon heartbeat, hilos posts one notice in the
61
+ last room that mentioned the agent (falling back to its latest run's room).
62
+ The notice names who can restart it with the same join code. For hilos-hosted
63
+ agents, it also explains that hosted mentions use workspace credits until the
64
+ daemon returns. The owner gets the existing quick-connect card; everyone in
65
+ the room can read the notice.
66
+
45
67
  Running from elsewhere, or want to map several repos explicitly? Save this as
46
68
  `~/.hilos/agent.json` (or `./hilos-agent.json`). The file is strict JSON, so it
47
69
  cannot contain comments:
@@ -485,7 +507,20 @@ arguments can be visible to other processes on the machine, so use
485
507
  Likewise, prefer `--join-stdin` over the legacy `--join <blob>` form for a new
486
508
  connection.
487
509
 
488
- See `hilos-agent --help` for the `init`, `webmcp`, `web doctor`, and `hooks`
510
+ `hilos-agent doctor` checks Node against the package's minimum version, both
511
+ configured CLIs, native web configuration, the hilos connection, the local seat
512
+ lock, and the config file and working folder. It returns JSON and exits with
513
+ code 1 if any check fails; add `--human` for one readable line per check. It
514
+ accepts the same join code, `--config`, and command overrides as the daemon.
515
+ Missing CLI checks include an install hint. The Cursor CLI (`cursor-agent`)
516
+ is installed separately from the Cursor app. The lock is released immediately
517
+ after the check; `contended` means another daemon holds that seat.
518
+
519
+ Keep the daemon in a separate terminal or a detached process that survives
520
+ your IDE agent's tool call. Confirm `watching: @-mentions` before calling it
521
+ online. The `hilos-connect` skill walks IDE agents through this setup.
522
+
523
+ See `hilos-agent --help` for the `doctor`, `init`, `webmcp`, `web doctor`, and `hooks`
489
524
  subcommands.
490
525
 
491
526
  Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`,
@@ -19,7 +19,6 @@
19
19
  // codingCmd in hilos-agent.json to change it and the daemon picks it up on its
20
20
  // next poll — no restart needed.
21
21
 
22
- import { spawnSync } from "node:child_process";
23
22
  import { readFileSync } from "node:fs";
24
23
  import { fileURLToPath } from "node:url";
25
24
 
@@ -28,9 +27,10 @@ import { readPrivateJoin } from "../src/join-input.mjs";
28
27
  import { run } from "../src/run.mjs";
29
28
  import { hookMain, hooksMain } from "../src/hook.mjs";
30
29
  import { runWebMcpCommand } from "../src/webmcp-bridge.mjs";
31
- import { detectVendor, fastChatCmd, webCapability } from "../src/progress-emitter.mjs";
32
- import { commandArgv } from "../src/argv.mjs";
30
+ import { doctor, humanDoctor } from "../src/doctor.mjs";
31
+ import { webDoctor } from "../src/web-doctor.mjs";
33
32
  import { runWithTerminalSignals } from "../src/cli.mjs";
33
+ import { createDaemonLog } from "../src/daemon-log.mjs";
34
34
 
35
35
  // 1251 — the plugin writes `hook --claude --personal`, so the client flags name
36
36
  // the vendor for `hook` the same way they choose a target for `hooks install`.
@@ -54,13 +54,13 @@ function requiredOptionValue(argv, index, option) {
54
54
  }
55
55
 
56
56
  function validateCommand(cmd, positional, { skipShape = false } = {}) {
57
- const commands = new Set(["run", "init", "hook", "hooks", "webmcp", "web", "help", "version"]);
57
+ const commands = new Set(["run", "init", "doctor", "hook", "hooks", "webmcp", "web", "help", "version"]);
58
58
  if (!commands.has(cmd)) {
59
59
  throw new Error(`Unknown command: ${cmd}. Try \`hilos-agent --help\`.`);
60
60
  }
61
61
  if (skipShape) return;
62
62
 
63
- if (["run", "init", "hook", "help", "version"].includes(cmd) && positional.length > 1) {
63
+ if (["run", "init", "doctor", "hook", "help", "version"].includes(cmd) && positional.length > 1) {
64
64
  throw new Error(`Unexpected argument for ${cmd}: ${positional[1]}. Try \`hilos-agent --help\`.`);
65
65
  }
66
66
 
@@ -110,6 +110,9 @@ function parseArgs(argv) {
110
110
  const a = argv[i];
111
111
  if (a === "--join") flags.join = requiredOptionValue(argv, i++, a);
112
112
  else if (a === "--join-stdin") flags.joinStdin = true;
113
+ else if (a === "--human") flags.human = true;
114
+ // Internal test seam: probe a temporary lock root, never a real seat.
115
+ else if (a === "--lock-dir") flags.lockDir = requiredOptionValue(argv, i++, a);
113
116
  else if (a === "--config") flags.config = requiredOptionValue(argv, i++, a);
114
117
  else if (a === "--channel") flags.channelId = requiredOptionValue(argv, i++, a);
115
118
  else if (a === "--url") flags.url = requiredOptionValue(argv, i++, a);
@@ -151,6 +154,7 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
151
154
  hilos-agent --join <blob> legacy argv-compatible connect link
152
155
  hilos-agent --join-stdin paste the private link at a no-echo prompt
153
156
  hilos-agent init write a starter config to ~/.hilos/agent.json
157
+ hilos-agent doctor check Node, CLIs, web, connection, lock and config
154
158
  hilos-agent webmcp doctor verify the local WebMCP browser bridge
155
159
  hilos-agent webmcp login <url> open the isolated profile for person sign-in
156
160
  hilos-agent webmcp open <url> open a person-allowlisted site for an agent
@@ -173,6 +177,7 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
173
177
  hilos-agent hooks print preview the hook configuration without writing
174
178
 
175
179
  Options:
180
+ --human print doctor checks as a readable table (default: JSON)
176
181
  --channel <id> watch only one channel (per-channel override)
177
182
  --config <path> use a specific config file
178
183
  --url <endpoint> override the MCP endpoint (or use HILOS_URL/config)
@@ -262,6 +267,15 @@ async function main() {
262
267
  return;
263
268
  }
264
269
 
270
+ if (cmd === "doctor") {
271
+ const { join: _join, joinStdin: _joinStdin, help: _help, human, lockDir, ...cliFlags } = flags;
272
+ const cfg = resolveConfig({ flags: cliFlags, join: joinPayload });
273
+ const result = await doctor(cfg, { lockDir });
274
+ console.log(human ? humanDoctor(result) : JSON.stringify(result, null, 2));
275
+ if (!result.ok) process.exitCode = 1;
276
+ return;
277
+ }
278
+
265
279
  if (cmd === "webmcp") {
266
280
  const cliFlags = { ...flags };
267
281
  delete cliFlags.help;
@@ -281,35 +295,9 @@ async function main() {
281
295
  const cliFlags = { ...flags };
282
296
  delete cliFlags.help;
283
297
  const cfg = resolveConfig({ flags: cliFlags });
284
- const codingCommand = cfg.codingCmd;
285
- const chatCommand = cfg.chatCmd || fastChatCmd(detectVendor(codingCommand)) || codingCommand;
286
- const describe = (command) => {
287
- const argv = commandArgv(command);
288
- const vendor = detectVendor(command);
289
- const capability = webCapability(vendor, {
290
- enabled: cfg.webSearch !== false,
291
- args: argv.slice(1),
292
- });
293
- const binary = argv[0] || "";
294
- const probed = binary
295
- ? spawnSync(binary, ["--version"], { encoding: "utf8", timeout: 5_000 })
296
- : null;
297
- const binaryAvailable = Boolean(binary) && !probed?.error;
298
- const version = String(probed?.stdout || probed?.stderr || "").trim().split("\n")[0] || null;
299
- return { vendor, binary, binaryAvailable, version, command, ...capability };
300
- };
301
- const chat = describe(chatCommand);
302
- const code = codingCommand === chatCommand ? chat : describe(codingCommand);
303
- console.log(JSON.stringify({
304
- ok: [chat, code].every((lane) => lane.status === "enabled" && lane.binaryAvailable),
305
- chat,
306
- code,
307
- note: !chat.binaryAvailable || !code.binaryAvailable
308
- ? "One or more selected CLI binaries are not installed or not on PATH."
309
- : chat.verified && code.verified
310
- ? "Configured by hilos-agent; this does not spend a model call or test provider credentials."
311
- : "Custom commands keep their own tool configuration; hilos-agent does not guess flags.",
312
- }, null, 2));
298
+ const result = webDoctor(cfg);
299
+ console.log(JSON.stringify(result, null, 2));
300
+ if (!result.ok) process.exitCode = 1;
313
301
  return;
314
302
  }
315
303
 
@@ -319,7 +307,39 @@ async function main() {
319
307
  delete cliFlags.joinStdin;
320
308
  delete cliFlags.help;
321
309
  const cfg = resolveConfig({ flags: cliFlags, join: joinPayload });
322
- await runWithTerminalSignals((signal) => run(cfg, { signal }));
310
+ const log = createDaemonLog();
311
+ let announced = false;
312
+ const announceLog = () => {
313
+ if (announced) return;
314
+ announced = true;
315
+ console.log(`log: ${log.path}`);
316
+ };
317
+ const setName = log.setName;
318
+ log.setName = (name) => { setName(name); announceLog(); };
319
+ const fatalReason = (error) => error?.name === "McpAuthStopError"
320
+ ? `auth stop: ${error.message}`
321
+ : `fatal: ${error?.message || error}\n${error?.stack || ""}`;
322
+ const onFatal = (error) => {
323
+ announceLog();
324
+ log.close(fatalReason(error));
325
+ console.error(error?.message || error);
326
+ process.exit(1);
327
+ };
328
+ process.on("uncaughtException", onFatal);
329
+ process.on("unhandledRejection", onFatal);
330
+ try {
331
+ await runWithTerminalSignals((signal) => run(cfg, { signal, log }), process, {
332
+ onSignal: (name) => { announceLog(); log.close(name); },
333
+ });
334
+ } catch (error) {
335
+ log.close(fatalReason(error));
336
+ throw error;
337
+ } finally {
338
+ announceLog();
339
+ log.close("normal stop");
340
+ process.removeListener("uncaughtException", onFatal);
341
+ process.removeListener("unhandledRejection", onFatal);
342
+ }
323
343
  }
324
344
 
325
345
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.14",
3
+ "version": "0.11.16",
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": {
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: hilos-connect
3
+ description: Connect a named agent to a hilos room with the local daemon. Use when a person asks an IDE agent to "connect me to hilos" or "join the room" using a private join code.
4
+ ---
5
+
6
+ # Connect
7
+
8
+ 1. Get the private join code from the person or the agent's connection dialog.
9
+ Treat it as a credential. Never paste it into chat, reports, or logs.
10
+ 2. From the folder the daemon will work in, run
11
+ `npx hilos-agent@latest doctor --join-stdin` and supply the code through
12
+ stdin. Read the JSON before starting anything. Keep the same `--coding-cmd`,
13
+ `--chat-cmd`, `--config`, and other settings as the dialog's run command.
14
+ A supplied `--join <blob>` also works, but exposes the credential in argv;
15
+ prefer stdin. A saved config can use `npx hilos-agent@latest doctor`.
16
+ 3. If `chat` or `code` fails, install the vendor CLI using that check's `fix`,
17
+ then rerun doctor. The Cursor app is not the Cursor CLI; `cursor-agent` is
18
+ installed separately. Refresh PATH in the launching shell if needed.
19
+ 4. If `lock` reports `contended`, find the other daemon holding this seat
20
+ before starting a second. Do not delete its lock or stop it without the
21
+ person's direction. For a filesystem error, fix the reported directory.
22
+ Resolve other failed checks and rerun until the JSON says `ok: true`.
23
+ 5. Start `npx hilos-agent@latest --join-stdin` with the same credentials and
24
+ settings in a process that survives your tool call ending: detach it with
25
+ persistent output capture, or open a separate terminal the person keeps
26
+ open. On Windows use PowerShell `Start-Process` to open that terminal.
27
+ Feed the private code through stdin or let the person paste it at the
28
+ hidden prompt. Never leave the daemon inside a tool call that exits and
29
+ kills its children.
30
+ 6. Only after doctor is all-ok AND the daemon prints `watching: @-mentions`
31
+ tell the person it is online. If you cannot observe that line, say startup
32
+ is unconfirmed. Give the daemon's diagnostic log path,
33
+ `~/.hilos/logs/<handle>.log`, also printed as `log: <path>` at startup.
34
+ Tell them to keep its terminal open when that is how it is running.
package/src/cli.mjs CHANGED
@@ -6,20 +6,100 @@
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
14
92
  * @param {import("node:events").EventEmitter & { exitCode?: number | string }} signalSource
93
+ * @param {{ onSignal?: (name: string) => void }} options
15
94
  */
16
- export async function runWithTerminalSignals(task, signalSource = process) {
95
+ export async function runWithTerminalSignals(task, signalSource = process, { onSignal } = {}) {
17
96
  const controller = new AbortController();
18
97
  const interrupt = () => stop("SIGINT", 130);
19
98
  const terminate = () => stop("SIGTERM", 143);
20
99
  const stop = (name, code) => {
21
100
  if (controller.signal.aborted) return;
22
101
  signalSource.exitCode = code;
102
+ onSignal?.(name);
23
103
  controller.abort(new Error(`Daemon received ${name}`));
24
104
  };
25
105
  signalSource.once("SIGINT", interrupt);
@@ -279,11 +359,14 @@ async function runCliOnce(opts) {
279
359
  // from stdin…"). Nothing we spawn is ever fed via stdin.
280
360
  // Always strip hilos's own token from the child's env (see scrubHilosEnv),
281
361
  // and keep PWD honest about the directory we run in (see envForCwd).
282
- child = spawn(cmd, args, {
362
+ const childEnv = envForCwd(scrubHilosEnv(env || process.env), cwd);
363
+ const plan = spawnPlan(cmd, args, { env: childEnv, cwd });
364
+ child = spawn(plan.file, plan.args, {
283
365
  cwd,
284
366
  detached: true,
285
367
  stdio: ["ignore", "pipe", "pipe"],
286
- env: envForCwd(scrubHilosEnv(env || process.env), cwd),
368
+ env: childEnv,
369
+ ...(plan.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
287
370
  });
288
371
  } catch (error) {
289
372
  resolve({ status: null, stdout: "", stderr: "", error });
@@ -0,0 +1,84 @@
1
+ // Local diagnostic log. Synchronous, bounded writes also survive process.exit;
2
+ // logging must never become a reason the daemon stops serving its room.
3
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { format } from "node:util";
7
+ import { redactSecrets } from "./redact.mjs";
8
+
9
+ export function createDaemonLog({
10
+ dir = join(homedir(), ".hilos", "logs"),
11
+ name = `agent-${process.pid}`,
12
+ console: base = console,
13
+ redact = redactSecrets,
14
+ maxBytes = 2 * 1024 * 1024,
15
+ } = {}) {
16
+ const logPath = (value) => join(dir, `${String(value || "").replace(/[^a-zA-Z0-9-]/g, "") || `agent-${process.pid}`}.log`);
17
+ let path = logPath(name);
18
+ let warned = false;
19
+ let closed = false;
20
+ const bestEffort = (write) => {
21
+ try { write(); } catch {
22
+ if (warned) return;
23
+ warned = true;
24
+ base.error("hilos-agent: could not write the local daemon log; continuing without it.");
25
+ }
26
+ };
27
+ const append = (target, text) => {
28
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
29
+ let bytes = Buffer.from(text);
30
+ // Redaction happens before this cap, so truncating cannot reveal a secret.
31
+ if (bytes.length > maxBytes) bytes = bytes.subarray(bytes.length - maxBytes);
32
+ if (existsSync(target) && statSync(target).size + bytes.length > maxBytes) {
33
+ if (existsSync(`${target}.1`)) unlinkSync(`${target}.1`);
34
+ renameSync(target, `${target}.1`);
35
+ }
36
+ appendFileSync(target, bytes, { mode: 0o600 });
37
+ chmodSync(target, 0o600);
38
+ };
39
+ const write = (level, args) => bestEffort(() => {
40
+ const timestamp = new Date().toISOString();
41
+ const message = redact(format(...args));
42
+ const prefix = `${timestamp} ${level} `;
43
+ const available = Math.max(0, maxBytes - Buffer.byteLength(prefix) - 1);
44
+ for (const line of message.split(/\r?\n/)) {
45
+ // Keep the timestamp/level (and the beginning of an exit reason) even
46
+ // when one diagnostic line is larger than the entire file budget.
47
+ const bounded = Buffer.from(line).subarray(0, available).toString("utf8").replace(/\uFFFD$/, "");
48
+ append(path, `${prefix}${bounded}\n`);
49
+ }
50
+ });
51
+ // Make the provisional path readable even if startup fails before whoami.
52
+ bestEffort(() => append(path, ""));
53
+ return {
54
+ get path() { return path; },
55
+ log(...args) {
56
+ base.log(...args);
57
+ if (!closed) write("INFO", args);
58
+ },
59
+ error(...args) {
60
+ base.error(...args);
61
+ if (!closed) write("ERROR", args);
62
+ },
63
+ setName(value) {
64
+ if (closed) return;
65
+ const next = logPath(value);
66
+ if (next === path) return;
67
+ bestEffort(() => {
68
+ // Append to an earlier session's log instead of overwriting it. Include
69
+ // a provisional rotation too, oldest first, under the same size bound.
70
+ for (const source of [`${path}.1`, path]) {
71
+ if (!existsSync(source)) continue;
72
+ append(next, readFileSync(source));
73
+ unlinkSync(source);
74
+ }
75
+ path = next;
76
+ });
77
+ },
78
+ close(reason = "normal stop") {
79
+ if (closed) return;
80
+ write("INFO", [`exit: ${reason}`]);
81
+ closed = true; // A signal/fatal reason must not become "normal stop".
82
+ },
83
+ };
84
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,119 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { commandArgv } from "./argv.mjs";
4
+ import { resolveCommand } from "./cli.mjs";
5
+ import { GLOBAL_CONFIG, LOCAL_CONFIG } from "./config.mjs";
6
+ import { mentionHandle } from "./daemon.mjs";
7
+ import { installHint } from "./install-hint.mjs";
8
+ import { createIterateClaimRecoveryStore } from "./iterate-claim-recovery.mjs";
9
+ import { makeClient } from "./mcp.mjs";
10
+ import { detectVendor, fastChatCmd } from "./progress-emitter.mjs";
11
+ import { webDoctor } from "./web-doctor.mjs";
12
+
13
+ const nodeEngine = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).engines.node;
14
+
15
+ export function checkNode(version = process.versions.node) {
16
+ const floor = nodeEngine.replace(/^>=\s*/, "").split(".").map(Number);
17
+ const current = version.split(".").map(Number);
18
+ let comparison = 0;
19
+ for (let i = 0; i < 3 && comparison === 0; i++) {
20
+ comparison = (current[i] || 0) - (floor[i] || 0);
21
+ }
22
+ const ok = comparison >= 0;
23
+ return {
24
+ name: "node", ok, detail: `Node ${version}; requires ${nodeEngine}`,
25
+ ...(!ok ? { fix: `Install Node.js ${nodeEngine}.` } : {}),
26
+ };
27
+ }
28
+
29
+ /** A pre-flight only: whoami, then briefly own and release the local seat. */
30
+ export async function doctor(cfg, { lockDir } = {}) {
31
+ const checks = [checkNode()];
32
+ const chatCommand = cfg.chatCmd || fastChatCmd(detectVendor(cfg.codingCmd)) || cfg.codingCmd;
33
+ for (const [name, command] of [["chat", chatCommand], ["code", cfg.codingCmd]]) {
34
+ const binary = commandArgv(command)[0] || "";
35
+ const vendor = detectVendor(command);
36
+ const resolved = resolveCommand(binary);
37
+ checks.push({
38
+ name, ok: Boolean(resolved),
39
+ detail: `${resolved || `${binary || "(empty command)"} is not installed or not on PATH`}${vendor === "cursor" ? "; the Cursor CLI is separate from the Cursor app" : ""}`,
40
+ ...(!resolved ? { fix: installHint(vendor) } : {}),
41
+ });
42
+ }
43
+
44
+ const web = webDoctor(cfg);
45
+ checks.push({
46
+ name: "web", ok: web.ok,
47
+ detail: `chat: ${web.chat.vendor} ${web.chat.version || "(version unavailable)"}, ${web.chat.source} (${web.chat.status}); code: ${web.code.vendor} ${web.code.version || "(version unavailable)"}, ${web.code.source} (${web.code.status}). ${web.note}`,
48
+ });
49
+
50
+ let who;
51
+ try {
52
+ if (!cfg.token) throw new Error("No agent token configured. Get a private join code from hilos (Connect agent).");
53
+ const client = makeClient({
54
+ url: cfg.url, token: cfg.token, timeoutMs: 5_000,
55
+ // A one-shot diagnostic is activity, never proof of a live daemon.
56
+ connectionMode: "on_demand",
57
+ // The daemon's default retry timer is unref'd. Doctor has no poll loop
58
+ // to keep it alive, so retain this timer until the JSON can be printed.
59
+ delay: (ms) => new Promise((done) => setTimeout(done, ms)),
60
+ });
61
+ const identity = await client.tool("whoami");
62
+ const handle = mentionHandle(identity?.agentName);
63
+ if (!identity?.agentId || !handle || !identity?.workspaceId) {
64
+ throw new Error("whoami did not return a named agent with a workspace. Use the agent's private join code.");
65
+ }
66
+ who = identity;
67
+ checks.push({
68
+ name: "connection", ok: true,
69
+ detail: `${who.agentName} (@${handle}); workspace: ${who.workspaceId}; server: ${cfg.url}`,
70
+ });
71
+ } catch (error) {
72
+ checks.push({ name: "connection", ok: false, detail: `${cfg.url}: ${error?.message || error}` });
73
+ }
74
+
75
+ if (!who) {
76
+ checks.push({ name: "lock", ok: false, detail: "Not checked: connection must identify the agent first." });
77
+ } else {
78
+ let store;
79
+ let acquired = false;
80
+ let check;
81
+ try {
82
+ store = createIterateClaimRecoveryStore({
83
+ agentId: who.agentId, url: cfg.url, token: cfg.token,
84
+ ...(lockDir ? { dir: lockDir } : {}),
85
+ });
86
+ acquired = store.acquireLock();
87
+ const failure = store.acquireFailure();
88
+ check = {
89
+ name: "lock", ok: acquired,
90
+ detail: acquired ? "Seat acquired and released." : failure || "Could not acquire the local run lock.",
91
+ ...(!acquired ? { fix: failure === "contended"
92
+ ? "another daemon holds this seat; find it before starting a second daemon"
93
+ : "Make the lock directory writable, then rerun doctor." } : {}),
94
+ };
95
+ } catch (error) {
96
+ check = { name: "lock", ok: false, detail: String(error?.message || error) };
97
+ } finally {
98
+ if (acquired && !store.releaseLock()) {
99
+ check = { name: "lock", ok: false, detail: "The local run lock could not be released." };
100
+ }
101
+ }
102
+ checks.push(check);
103
+ }
104
+
105
+ const path = cfg.configPath && existsSync(cfg.configPath) ? resolve(cfg.configPath) : null;
106
+ const source = !path ? "none" : path === resolve(GLOBAL_CONFIG) ? "global"
107
+ : path === resolve(LOCAL_CONFIG) ? "local" : "explicit";
108
+ checks.push({
109
+ name: "config", ok: true,
110
+ detail: `${source}${path ? `: ${path}` : " (defaults, environment, join code and flags)"}; working folder: ${process.cwd()}`,
111
+ });
112
+ return { ok: checks.every((check) => check.ok), checks };
113
+ }
114
+
115
+ export function humanDoctor(result) {
116
+ return result.checks.map(({ name, ok, detail, fix }) =>
117
+ `${ok ? "ok " : "fail"} ${name.padEnd(10)} ${[detail, fix && `Fix: ${fix}`].filter(Boolean).join(" ").replace(/\s+/g, " ")}`,
118
+ ).join("\n");
119
+ }
@@ -0,0 +1,8 @@
1
+ /** Where to get the vendor CLI a daemon command names (1330). */
2
+ export function installHint(vendor) {
3
+ 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)";
4
+ if (vendor === "claude_code") return "npm install -g @anthropic-ai/claude-code";
5
+ if (vendor === "codex") return "npm install -g @openai/codex";
6
+ if (vendor === "opencode") return "https://opencode.ai";
7
+ return "";
8
+ }
@@ -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/mcp.mjs CHANGED
@@ -203,6 +203,7 @@ function assertJsonRpcResponse(json, expectedId) {
203
203
  export function makeClient({
204
204
  url,
205
205
  token,
206
+ connectionMode = "daemon",
206
207
  timeoutMs = DEFAULT_TIMEOUT_MS,
207
208
  retryDelayMs = RETRY_DELAY_MS,
208
209
  delay = defaultDelay,
@@ -235,7 +236,7 @@ export function makeClient({
235
236
  "content-type": "application/json",
236
237
  accept: "application/json, text/event-stream",
237
238
  authorization: `Bearer ${token}`,
238
- "x-hilos-connection-mode": "daemon",
239
+ "x-hilos-connection-mode": connectionMode,
239
240
  ...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
240
241
  ...(options.ambientBinding
241
242
  ? { "x-hilos-ambient-binding": options.ambientBinding }
package/src/run.mjs CHANGED
@@ -15,6 +15,8 @@ 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";
19
+ import { installHint } from "./install-hint.mjs";
18
20
  import {
19
21
  createIterateClaimRecoveryStore,
20
22
  reconcileDaemonIterateClaims,
@@ -67,6 +69,8 @@ export async function run(
67
69
  iterateClaimShutdownReconcileTimeoutMs = 1000,
68
70
  } = {},
69
71
  ) {
72
+ let exitError = null;
73
+ try {
70
74
  if (!cfg.token) {
71
75
  // Throw, don't process.exit — the CLI's main().catch prints + exits 1 exactly
72
76
  // as before, and an embedding host (the desktop Local Agents runner) must
@@ -154,6 +158,7 @@ export async function run(
154
158
  // Throw for the same reason as the token guard above — embed-safe, CLI-equal.
155
159
  throw new Error("This agent has no display name / handle — set one in hilos, then reconnect.");
156
160
  }
161
+ log.setName?.(me.handle);
157
162
  // Initialized after discovery so the recovery lock is scoped to this agent.
158
163
  let iterateClaimRecoveryStore = null;
159
164
  let queue = null;
@@ -188,6 +193,19 @@ export async function run(
188
193
  enabled: cfg.webSearch !== false,
189
194
  args: commandArgv(cfg.codingCmd).slice(1),
190
195
  });
196
+ // 1330 — say at startup, not at the first mention, when the CLI this daemon
197
+ // is configured to drive is not installed. A person who connected through an
198
+ // IDE agent read "Hilo is connected and online" and then got "(my chat
199
+ // command `cursor-agent …` isn't installed or on PATH.)" in the room.
200
+ for (const [role, command] of [["chat", chatCommand], ["code", cfg.codingCmd]]) {
201
+ const bin = commandArgv(command)[0];
202
+ if (!bin || resolveCommand(bin)) continue;
203
+ const hint = installHint(detectVendor(command));
204
+ log.error(
205
+ `${role} command not found: \`${bin}\` is not installed or not on PATH${hint ? ` — ${hint}` : ""}. Mentions will fail until it is.`,
206
+ );
207
+ emit({ type: "status", text: `${role} command missing: ${bin}` });
208
+ }
191
209
  log.log(
192
210
  chatVendor === codingVendor && web.status === codingWeb.status
193
211
  ? `web: ${web.status} — ${web.source}`
@@ -218,7 +236,12 @@ export async function run(
218
236
  log,
219
237
  });
220
238
  if (!iterateClaimRecoveryStore.acquireLock()) {
221
- throw new Error("Another local daemon already owns this agent and server connection.");
239
+ const why = iterateClaimRecoveryStore.acquireFailure?.();
240
+ throw new Error(
241
+ why && why !== "contended"
242
+ ? `The local run lock could not be taken: ${why}. Make that directory writable and start again.`
243
+ : "Another local daemon already owns this agent and server connection.",
244
+ );
222
245
  }
223
246
  reconcileClaimsOnce = async (context) => {
224
247
  try {
@@ -926,4 +949,14 @@ export async function run(
926
949
  iterateClaimRecoveryStore?.releaseLock();
927
950
  if (isAuthStop(stopSignal.reason)) throw stopSignal.reason;
928
951
  }
952
+ } catch (error) {
953
+ exitError = error;
954
+ throw error;
955
+ } finally {
956
+ log.close?.(exitError?.name === "McpAuthStopError"
957
+ ? `auth stop: ${exitError.message}`
958
+ : exitError
959
+ ? `fatal: ${exitError.message || exitError}\n${exitError.stack || ""}`
960
+ : "normal stop");
961
+ }
929
962
  }
@@ -0,0 +1,49 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { commandArgv } from "./argv.mjs";
3
+ import { resolveCommand, spawnPlan } from "./cli.mjs";
4
+ import { detectVendor, fastChatCmd, webCapability } from "./progress-emitter.mjs";
5
+
6
+ /** Inspect native web configuration without a model call or provider login. */
7
+ export function webDoctor(cfg) {
8
+ const codingCommand = cfg.codingCmd;
9
+ const chatCommand = cfg.chatCmd || fastChatCmd(detectVendor(codingCommand)) || codingCommand;
10
+ const describe = (command) => {
11
+ const argv = commandArgv(command);
12
+ const vendor = detectVendor(command);
13
+ const capability = webCapability(vendor, {
14
+ enabled: cfg.webSearch !== false,
15
+ args: argv.slice(1),
16
+ });
17
+ const binary = argv[0] || "";
18
+ const resolved = resolveCommand(binary);
19
+ // npm's Windows .cmd shims need the same ComSpec plan as an actual run.
20
+ const plan = resolved ? spawnPlan(resolved, ["--version"]) : null;
21
+ const probed = plan
22
+ ? spawnSync(plan.file, plan.args, {
23
+ encoding: "utf8", timeout: 5_000,
24
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
25
+ })
26
+ : null;
27
+ const binaryAvailable = Boolean(resolved) && !probed?.error && probed?.status === 0;
28
+ const version = String(probed?.stdout || probed?.stderr || "").trim().split("\n")[0] || null;
29
+ return { vendor, binary, binaryAvailable, version, command, ...capability };
30
+ };
31
+ const chat = describe(chatCommand);
32
+ const code = codingCommand === chatCommand ? chat : describe(codingCommand);
33
+ const lanes = [chat, code];
34
+ return {
35
+ // Disabling web or keeping the CLI's own policy is a valid setup choice.
36
+ // Every command still has to exist and pass its version probe.
37
+ ok: lanes.every((lane) => lane.binaryAvailable &&
38
+ ["enabled", "disabled", "operator-controlled", "inherited"].includes(lane.status)),
39
+ chat,
40
+ code,
41
+ note: !chat.binaryAvailable || !code.binaryAvailable
42
+ ? "One or more selected CLI binaries are not installed, not on PATH, or failed their version probe."
43
+ : lanes.some((lane) => ["disabled", "operator-controlled"].includes(lane.status))
44
+ ? "Web enablement is off by choice in one or more commands; the CLI's own policy still applies."
45
+ : lanes.some((lane) => lane.status === "inherited")
46
+ ? "Web configuration is inherited by choice from the selected CLI; hilos-agent does not guess flags."
47
+ : "Configured by hilos-agent; this does not spend a model call or test provider credentials.",
48
+ };
49
+ }