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 +1 -1
- package/src/cli.mjs +83 -2
- package/src/iterate-claim-recovery.mjs +29 -1
- package/src/run.mjs +29 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.11.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|