hilos-agent 0.11.1 → 0.11.2
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/LICENSE +21 -0
- package/README.md +31 -16
- package/bin/hilos-agent.mjs +87 -14
- package/package.json +6 -4
- package/src/acp-session.mjs +16 -1
- package/src/cli.mjs +34 -1
- package/src/codex-mcp-session.mjs +36 -0
- package/src/config.mjs +39 -5
- package/src/cursor-store.mjs +43 -8
- package/src/handler.mjs +277 -37
- package/src/iterate-claim-recovery.mjs +574 -0
- package/src/mcp.mjs +411 -34
- package/src/model-resolve.mjs +3 -1
- package/src/opencode-session.mjs +22 -2
- package/src/progress-emitter.mjs +1 -1
- package/src/queue.mjs +70 -3
- package/src/run.mjs +453 -76
- package/src/webmcp-bridge.mjs +77 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pablo Stanley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -113,8 +113,11 @@ tools in the machine's config:
|
|
|
113
113
|
The site does not authorize itself: `readOnlyHint` is informational, while this
|
|
114
114
|
person-owned list decides what may run. Site descriptions are omitted, schema
|
|
115
115
|
prose is stripped, results are bounded and labeled untrusted, and cookies stay
|
|
116
|
-
inside a separate browser profile. The
|
|
117
|
-
Node.js
|
|
116
|
+
inside a separate browser profile. The optional browser bridge needs Node.js 24
|
|
117
|
+
or newer. On Node.js 20 or 22 WebMCP stays unavailable, while the rest of the
|
|
118
|
+
daemon keeps working regardless of whether that optional package was omitted by
|
|
119
|
+
the installer. Run `hilos-agent webmcp doctor` after upgrading Node.js and
|
|
120
|
+
reinstalling the package.
|
|
118
121
|
|
|
119
122
|
```sh
|
|
120
123
|
hilos-agent webmcp doctor
|
|
@@ -128,7 +131,9 @@ hilos-agent webmcp close
|
|
|
128
131
|
The daemon adds this capability and its citation rules to agent prompts only
|
|
129
132
|
when the config is valid. Unlisted tools — including writes — are refused with
|
|
130
133
|
`human_approval_required`; there is no approval flag the agent can set. WebMCP
|
|
131
|
-
can never approve or merge hilos work.
|
|
134
|
+
can never approve or merge hilos work. This wrapper is a policy boundary for
|
|
135
|
+
the bridge, not a sandbox around the coding CLI, which still has the machine
|
|
136
|
+
access its operator granted. See the complete contract in
|
|
132
137
|
[WebMCP in hilos](https://hilos.sh/docs/webmcp).
|
|
133
138
|
|
|
134
139
|
## How it works
|
|
@@ -309,10 +314,9 @@ safest first:
|
|
|
309
314
|
|
|
310
315
|
The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
|
|
311
316
|
you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
|
|
312
|
-
before anything is pushed.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
vendor-neutral hilos substrate.
|
|
317
|
+
before anything is pushed. When the workspace grants runtime approvals,
|
|
318
|
+
OpenCode, Cursor, Claude Code, and Codex all pause on the same durable hilos
|
|
319
|
+
permission card. A refusal or broken decision path fails closed.
|
|
316
320
|
|
|
317
321
|
## Hooks — keep a raw Codex, Claude Code, or Cursor session in the room
|
|
318
322
|
|
|
@@ -440,18 +444,29 @@ restart — and **deleting** the key counts as off, not as "leave it as it was".
|
|
|
440
444
|
|
|
441
445
|
## Flags
|
|
442
446
|
|
|
443
|
-
`--join <blob>` · `--channel <id>` · `--config <path>` ·
|
|
444
|
-
`--
|
|
447
|
+
`--join <blob>` · `--join-stdin` · `--channel <id>` · `--config <path>` ·
|
|
448
|
+
`--url <endpoint>` · `--token <token>` ·
|
|
449
|
+
`--coding-cmd <cmd>` · `--coding-model <tier>` · `--chat-cmd <cmd>` ·
|
|
450
|
+
`--web-search` · `--no-web-search` · `--once` · `--backfill` · `--gate` ·
|
|
451
|
+
`--no-gate` · `--no-reply-bridge` · `--version` · `--help`. Hook installation
|
|
452
|
+
also accepts `--global` or one of `--claude`, `--codex`, and `--cursor`.
|
|
453
|
+
|
|
454
|
+
`--token` remains only for compatibility with older scripts. Command-line
|
|
455
|
+
arguments can be visible to other processes on the machine, so use
|
|
456
|
+
`--join-stdin`, `HILOS_TOKEN`, or the private config file for credentials.
|
|
457
|
+
Likewise, prefer `--join-stdin` over the legacy `--join <blob>` form for a new
|
|
458
|
+
connection.
|
|
445
459
|
|
|
446
460
|
Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`, `HILOS_ONCE=1`,
|
|
447
461
|
`HILOS_BACKFILL=1`, `HILOS_UPLOAD_TRANSCRIPTS=1|0`.
|
|
448
462
|
|
|
449
463
|
## Releasing
|
|
450
464
|
|
|
451
|
-
Bump `version` in `package.json`, merge, then tag that commit
|
|
452
|
-
`hilos-agent-v<version>` and push the tag. The `npm-publish` workflow
|
|
453
|
-
|
|
454
|
-
and
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
`NPM_TOKEN` repository secret
|
|
465
|
+
Bump `version` in `package.json`, merge, wait for main CI, then tag that commit
|
|
466
|
+
`hilos-agent-v<version>` and push the tag. The `npm-publish` workflow requires
|
|
467
|
+
that exact version, a commit contained in `main`, and a successful `verify`
|
|
468
|
+
check. It packs and clean-installs the artifact on Node.js 20, 22, and 24 before
|
|
469
|
+
publishing, then installs the registry copy and checks its version and help.
|
|
470
|
+
A manual workflow dispatch is always a dry run and cannot publish. The current
|
|
471
|
+
release path needs the `NPM_TOKEN` repository secret until npm trusted
|
|
472
|
+
publishing is configured (1094, 1211).
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// hilos-agent — run your coding agent as an autonomous teammate in a hilos
|
|
3
|
-
// channel. Picks up @mentions
|
|
4
|
-
//
|
|
3
|
+
// channel. Picks up @mentions and opens a PR by default; --gate adds a human
|
|
4
|
+
// approval checkpoint before the push. Your code + credentials never leave
|
|
5
|
+
// your machine.
|
|
5
6
|
//
|
|
6
7
|
// Usage:
|
|
7
8
|
// hilos-agent --join <blob> connect with a copy-paste link from hilos
|
|
@@ -35,43 +36,107 @@ function packageVersion() {
|
|
|
35
36
|
return JSON.parse(readFileSync(pkgPath, "utf8")).version;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function requiredOptionValue(argv, index, option) {
|
|
40
|
+
const value = argv[index + 1];
|
|
41
|
+
if (value === undefined || value.startsWith("-")) {
|
|
42
|
+
throw new Error(`Option ${option} requires a value.`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateCommand(cmd, positional, { skipShape = false } = {}) {
|
|
48
|
+
const commands = new Set(["run", "init", "hook", "hooks", "webmcp", "web", "help", "version"]);
|
|
49
|
+
if (!commands.has(cmd)) {
|
|
50
|
+
throw new Error(`Unknown command: ${cmd}. Try \`hilos-agent --help\`.`);
|
|
51
|
+
}
|
|
52
|
+
if (skipShape) return;
|
|
53
|
+
|
|
54
|
+
if (["run", "init", "hook", "help", "version"].includes(cmd) && positional.length > 1) {
|
|
55
|
+
throw new Error(`Unexpected argument for ${cmd}: ${positional[1]}. Try \`hilos-agent --help\`.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (cmd === "hooks") {
|
|
59
|
+
const subcommand = positional[1];
|
|
60
|
+
if (!subcommand) throw new Error("A hooks command is required: install or print.");
|
|
61
|
+
if (!new Set(["install", "print"]).has(subcommand)) {
|
|
62
|
+
throw new Error(`Unknown hooks command: ${subcommand}. Use install or print.`);
|
|
63
|
+
}
|
|
64
|
+
if (positional.length > 2) {
|
|
65
|
+
throw new Error(`Unexpected argument for hooks ${subcommand}: ${positional[2]}.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (cmd === "web") {
|
|
70
|
+
const subcommand = positional[1] || "doctor";
|
|
71
|
+
if (subcommand !== "doctor") {
|
|
72
|
+
throw new Error(`Unknown web command: ${subcommand}. Try \`hilos-agent web doctor\`.`);
|
|
73
|
+
}
|
|
74
|
+
if (positional.length > 2) {
|
|
75
|
+
throw new Error(`Unexpected argument for web doctor: ${positional[2]}.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (cmd === "webmcp") {
|
|
80
|
+
const operation = positional[1] || "doctor";
|
|
81
|
+
const allowed = new Set(["doctor", "login", "open", "tools", "call", "close"]);
|
|
82
|
+
if (!allowed.has(operation)) {
|
|
83
|
+
throw new Error(`Unknown webmcp command: ${operation}. Use doctor, login, open, tools, call, or close.`);
|
|
84
|
+
}
|
|
85
|
+
const argumentCount = positional.length - 2;
|
|
86
|
+
const validCount = operation === "login" || operation === "open"
|
|
87
|
+
? argumentCount === 1
|
|
88
|
+
: operation === "call"
|
|
89
|
+
? argumentCount === 1 || argumentCount === 2
|
|
90
|
+
: argumentCount === 0;
|
|
91
|
+
if (!validCount) {
|
|
92
|
+
throw new Error(`Invalid arguments for webmcp ${operation}. Try \`hilos-agent --help\`.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
38
97
|
function parseArgs(argv) {
|
|
39
98
|
const flags = {};
|
|
40
99
|
const positional = [];
|
|
41
100
|
for (let i = 0; i < argv.length; i++) {
|
|
42
101
|
const a = argv[i];
|
|
43
|
-
if (a === "--join") flags.join = argv
|
|
102
|
+
if (a === "--join") flags.join = requiredOptionValue(argv, i++, a);
|
|
44
103
|
else if (a === "--join-stdin") flags.joinStdin = true;
|
|
45
|
-
else if (a === "--config") flags.config = argv
|
|
46
|
-
else if (a === "--channel") flags.channelId = argv
|
|
47
|
-
else if (a === "--url") flags.url = argv
|
|
48
|
-
else if (a === "--token") flags.token = argv
|
|
49
|
-
else if (a === "--coding-cmd") flags.codingCmd = argv
|
|
50
|
-
else if (a === "--coding-model") flags.codingModel = argv
|
|
51
|
-
else if (a === "--chat-cmd") flags.chatCmd = argv
|
|
104
|
+
else if (a === "--config") flags.config = requiredOptionValue(argv, i++, a);
|
|
105
|
+
else if (a === "--channel") flags.channelId = requiredOptionValue(argv, i++, a);
|
|
106
|
+
else if (a === "--url") flags.url = requiredOptionValue(argv, i++, a);
|
|
107
|
+
else if (a === "--token") flags.token = requiredOptionValue(argv, i++, a);
|
|
108
|
+
else if (a === "--coding-cmd") flags.codingCmd = requiredOptionValue(argv, i++, a);
|
|
109
|
+
else if (a === "--coding-model") flags.codingModel = requiredOptionValue(argv, i++, a);
|
|
110
|
+
else if (a === "--chat-cmd") flags.chatCmd = requiredOptionValue(argv, i++, a);
|
|
52
111
|
else if (a === "--no-web-search") flags.webSearch = false;
|
|
53
112
|
else if (a === "--web-search") flags.webSearch = true;
|
|
54
113
|
else if (a === "--once") flags.once = true;
|
|
55
114
|
else if (a === "--backfill") flags.backfill = true;
|
|
115
|
+
else if (a === "--gate") flags.gate = true;
|
|
56
116
|
else if (a === "--no-gate") flags.gate = false;
|
|
57
117
|
else if (a === "--no-reply-bridge") flags.replyBridge = false;
|
|
58
118
|
else if (a === "--global") flags.global = true;
|
|
59
119
|
else if (a === "--claude") flags.hookClient = "claude";
|
|
60
120
|
else if (a === "--codex") flags.hookClient = "codex";
|
|
61
121
|
else if (a === "--cursor") flags.hookClient = "cursor";
|
|
62
|
-
else if (a === "--vendor") flags.vendor = argv
|
|
122
|
+
else if (a === "--vendor") flags.vendor = requiredOptionValue(argv, i++, a);
|
|
63
123
|
else if (a === "--scope-managed") flags.scopeManaged = true;
|
|
124
|
+
// Marker embedded in self-contained hook commands. It is intentionally
|
|
125
|
+
// internal: hook.mjs uses it to recognize and replace managed installs.
|
|
64
126
|
else if (a === "--managed-runtime") flags.managedRuntime = true;
|
|
65
127
|
else if (a === "-h" || a === "--help") flags.help = true;
|
|
66
128
|
else if (a === "-v" || a === "--version") flags.version = true;
|
|
129
|
+
else if (a.startsWith("-")) throw new Error(`Unknown option: ${a}. Try \`hilos-agent --help\`.`);
|
|
67
130
|
else positional.push(a);
|
|
68
131
|
}
|
|
69
|
-
|
|
132
|
+
const cmd = positional[0] || "run";
|
|
133
|
+
validateCommand(cmd, positional, { skipShape: flags.help || flags.version });
|
|
134
|
+
return { cmd, flags, positional };
|
|
70
135
|
}
|
|
71
136
|
|
|
72
137
|
const HELP = `hilos-agent — your coding agent as a teammate in hilos
|
|
73
138
|
|
|
74
|
-
hilos-agent --join <blob>
|
|
139
|
+
hilos-agent --join <blob> legacy argv-compatible connect link
|
|
75
140
|
hilos-agent --join-stdin paste the private link at a no-echo prompt
|
|
76
141
|
hilos-agent init write a starter config to ~/.hilos/agent.json
|
|
77
142
|
hilos-agent webmcp doctor verify the local WebMCP browser bridge
|
|
@@ -82,16 +147,21 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
|
|
|
82
147
|
hilos-agent webmcp close close the isolated browser session
|
|
83
148
|
hilos-agent web doctor report this CLI's native public-web capability
|
|
84
149
|
hilos-agent run the daemon (watch @mentions, propose diffs)
|
|
150
|
+
hilos-agent run same as above, explicit
|
|
85
151
|
hilos-agent hooks install stream this repo's Codex, Claude, and Cursor
|
|
86
152
|
sessions to hilos and continue replies in the same
|
|
87
153
|
local session. Installs all three hook formats;
|
|
88
154
|
use --codex, --claude, or --cursor to choose, and
|
|
89
155
|
--global for every repo. HILOS_HOOKS=off pauses
|
|
90
156
|
streaming; HILOS_REPLY_BRIDGE=off pauses pickup.
|
|
157
|
+
hilos-agent hooks print preview the hook configuration without writing
|
|
91
158
|
|
|
92
159
|
Options:
|
|
93
160
|
--channel <id> watch only one channel (per-channel override)
|
|
94
161
|
--config <path> use a specific config file
|
|
162
|
+
--url <endpoint> override the MCP endpoint (or use HILOS_URL/config)
|
|
163
|
+
--token <token> legacy token override; argv may be visible to other local
|
|
164
|
+
processes. Prefer --join-stdin or HILOS_TOKEN.
|
|
95
165
|
--coding-cmd <cmd> the coding agent to run — claude -p, codex exec,
|
|
96
166
|
cursor-agent -p --trust, opencode run, agy -p, hermes -z,
|
|
97
167
|
or any command
|
|
@@ -104,10 +174,12 @@ Options:
|
|
|
104
174
|
--chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
|
|
105
175
|
derived from the coding command, so a Codex or Cursor
|
|
106
176
|
daemon chats with its own tool)
|
|
177
|
+
--web-search allow hilos to request native public web tools (default)
|
|
107
178
|
--no-web-search stop hilos from enabling/requesting native public web
|
|
108
179
|
--once one poll then exit (cron-friendly)
|
|
109
180
|
--backfill also act on mentions that predate startup
|
|
110
|
-
--
|
|
181
|
+
--gate wait for approval in hilos before pushing a branch
|
|
182
|
+
--no-gate open a PR directly without pre-push approval (default)
|
|
111
183
|
--no-reply-bridge don't resume local sessions from replies in bound threads
|
|
112
184
|
-v, --version print the installed version
|
|
113
185
|
-h, --help this help
|
|
@@ -158,6 +230,7 @@ async function main() {
|
|
|
158
230
|
const starter = { ...(joinPayload || {}) };
|
|
159
231
|
if (flags.codingCmd) starter.codingCmd = flags.codingCmd;
|
|
160
232
|
if (flags.codingModel) starter.codingModel = flags.codingModel;
|
|
233
|
+
if (flags.gate !== undefined) starter.gate = flags.gate;
|
|
161
234
|
const path = writeStarterConfig(joinPayload ? GLOBAL_CONFIG : flags.config, starter);
|
|
162
235
|
console.log(`Wrote ${path}.`);
|
|
163
236
|
console.log(joinPayload ? "Token + endpoint set from your link." : "Fill in token + repos, then run `hilos-agent`.");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.11.
|
|
4
|
-
"description": "Run your own coding agent (Claude Code
|
|
3
|
+
"version": "0.11.2",
|
|
4
|
+
"description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. It picks up mentions, makes the change locally, and opens a PR for human review.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"hilos-agent": "bin/hilos-agent.mjs"
|
|
@@ -30,10 +30,12 @@
|
|
|
30
30
|
"claude-code",
|
|
31
31
|
"codex",
|
|
32
32
|
"cursor",
|
|
33
|
+
"opencode",
|
|
34
|
+
"hermes-agent",
|
|
33
35
|
"coding-agent"
|
|
34
36
|
],
|
|
35
37
|
"license": "MIT",
|
|
36
|
-
"
|
|
37
|
-
"agent-browser": "^0.
|
|
38
|
+
"optionalDependencies": {
|
|
39
|
+
"agent-browser": "^0.36.0"
|
|
38
40
|
}
|
|
39
41
|
}
|
package/src/acp-session.mjs
CHANGED
|
@@ -191,6 +191,7 @@ export function createNdjsonParser() {
|
|
|
191
191
|
* requestPermission?: (request: object, context: object) => Promise<unknown>,
|
|
192
192
|
* getPermissionDecision?: (handle: unknown, context: object) => Promise<unknown>,
|
|
193
193
|
* mcpServers?: object[],
|
|
194
|
+
* beforeSpawn?: () => boolean | Promise<boolean>,
|
|
194
195
|
* spawnImpl?: (cmd: string, args: string[], options: object) => import("node:child_process").ChildProcess,
|
|
195
196
|
* setTimer?: typeof setTimeout,
|
|
196
197
|
* clearTimer?: typeof clearTimeout,
|
|
@@ -217,6 +218,7 @@ export async function runAcpSession({
|
|
|
217
218
|
/** Session to continue (0778); null starts a fresh one. */
|
|
218
219
|
resumeSessionId = null,
|
|
219
220
|
mcpServers = [],
|
|
221
|
+
beforeSpawn,
|
|
220
222
|
spawnImpl = spawn,
|
|
221
223
|
setTimer = setTimeout,
|
|
222
224
|
clearTimer = clearTimeout,
|
|
@@ -264,6 +266,7 @@ export async function runAcpSession({
|
|
|
264
266
|
let currentMessageId = null;
|
|
265
267
|
let messageBuffer = "";
|
|
266
268
|
const eventMapper = createAcpEventMapper();
|
|
269
|
+
let authorityLost = false;
|
|
267
270
|
|
|
268
271
|
const emitOutput = (text) => {
|
|
269
272
|
try {
|
|
@@ -411,6 +414,17 @@ export async function runAcpSession({
|
|
|
411
414
|
}
|
|
412
415
|
|
|
413
416
|
try {
|
|
417
|
+
if (typeof beforeSpawn === "function") {
|
|
418
|
+
try {
|
|
419
|
+
if ((await beforeSpawn()) === false) {
|
|
420
|
+
authorityLost = true;
|
|
421
|
+
throw new Error("execution authority lost before ACP spawn");
|
|
422
|
+
}
|
|
423
|
+
} catch (error) {
|
|
424
|
+
authorityLost = true;
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
414
428
|
child = spawnImpl(cmd, acpArgs, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
415
429
|
const spawned = new Promise((resolve, reject) => {
|
|
416
430
|
child.once("spawn", resolve);
|
|
@@ -508,13 +522,14 @@ export async function runAcpSession({
|
|
|
508
522
|
};
|
|
509
523
|
} catch (error) {
|
|
510
524
|
flushMessage();
|
|
511
|
-
const aborted = abortKind === "cancelled";
|
|
525
|
+
const aborted = abortKind === "cancelled" || authorityLost;
|
|
512
526
|
const timedOut = abortKind === "timeout";
|
|
513
527
|
return {
|
|
514
528
|
status: null,
|
|
515
529
|
stdout: stdout.trimEnd(),
|
|
516
530
|
stderr: stderr.trimEnd(),
|
|
517
531
|
...(aborted ? { aborted: true } : {}),
|
|
532
|
+
...(authorityLost ? { authorityLost: true } : {}),
|
|
518
533
|
...(sessionId ? { sessionId } : {}),
|
|
519
534
|
error:
|
|
520
535
|
error instanceof Error && !timedOut
|
package/src/cli.mjs
CHANGED
|
@@ -179,6 +179,9 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
|
|
|
179
179
|
* BEFORE the ungated compat retry is spawned when a CLI rejects the 0777
|
|
180
180
|
* permission flags (0785), so the caller can warn its room while the run can
|
|
181
181
|
* still be stopped. A throw here never fails the run.
|
|
182
|
+
* @property {() => (boolean | Promise<boolean>)} [beforeSpawn] - fail-closed
|
|
183
|
+
* authority check, awaited immediately before each child-process attempt.
|
|
184
|
+
* Returning false or throwing prevents that spawn and marks the run aborted.
|
|
182
185
|
*/
|
|
183
186
|
|
|
184
187
|
/**
|
|
@@ -191,7 +194,7 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
|
|
|
191
194
|
*
|
|
192
195
|
* @param {RunCliOptions} opts
|
|
193
196
|
*/
|
|
194
|
-
function runCliOnce(opts) {
|
|
197
|
+
async function runCliOnce(opts) {
|
|
195
198
|
const {
|
|
196
199
|
cmd,
|
|
197
200
|
args = [],
|
|
@@ -204,7 +207,37 @@ function runCliOnce(opts) {
|
|
|
204
207
|
signal,
|
|
205
208
|
onData,
|
|
206
209
|
env,
|
|
210
|
+
beforeSpawn,
|
|
207
211
|
} = opts || {};
|
|
212
|
+
// This guard belongs at the process boundary, after all potentially-slow
|
|
213
|
+
// caller setup and once per compatibility retry. Checking in runCli's caller
|
|
214
|
+
// leaves a race before the first child and lets its internal retries escape.
|
|
215
|
+
if (signal?.aborted) {
|
|
216
|
+
return { status: null, stdout: "", stderr: "", aborted: true, error: new Error("cancelled") };
|
|
217
|
+
}
|
|
218
|
+
if (typeof beforeSpawn === "function") {
|
|
219
|
+
try {
|
|
220
|
+
if ((await beforeSpawn()) === false) {
|
|
221
|
+
return {
|
|
222
|
+
status: null,
|
|
223
|
+
stdout: "",
|
|
224
|
+
stderr: "",
|
|
225
|
+
aborted: true,
|
|
226
|
+
authorityLost: true,
|
|
227
|
+
error: new Error("execution authority lost before spawn"),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return {
|
|
232
|
+
status: null,
|
|
233
|
+
stdout: "",
|
|
234
|
+
stderr: "",
|
|
235
|
+
aborted: true,
|
|
236
|
+
authorityLost: true,
|
|
237
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
208
241
|
return new Promise((resolve) => {
|
|
209
242
|
// Already cancelled before we even start.
|
|
210
243
|
if (signal?.aborted) {
|
|
@@ -160,6 +160,7 @@ export function mapCodexDecision(reply, availableDecisions) {
|
|
|
160
160
|
* onEvent?: (event: object) => void,
|
|
161
161
|
* requestPermission?: (request: object, context: object) => Promise<unknown>,
|
|
162
162
|
* getPermissionDecision?: (handle: unknown, context: object) => Promise<unknown>,
|
|
163
|
+
* beforeSpawn?: () => boolean | Promise<boolean>,
|
|
163
164
|
* spawnImpl?: typeof spawn,
|
|
164
165
|
* sleep?: (ms: number) => Promise<void>,
|
|
165
166
|
* now?: () => number,
|
|
@@ -185,6 +186,7 @@ export async function runCodexMcpSession({
|
|
|
185
186
|
onEvent,
|
|
186
187
|
requestPermission,
|
|
187
188
|
getPermissionDecision,
|
|
189
|
+
beforeSpawn,
|
|
188
190
|
spawnImpl = spawn,
|
|
189
191
|
sleep,
|
|
190
192
|
now = () => Date.now(),
|
|
@@ -199,6 +201,40 @@ export async function runCodexMcpSession({
|
|
|
199
201
|
if (signal?.aborted) onOuterAbort();
|
|
200
202
|
else signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
201
203
|
|
|
204
|
+
if (typeof beforeSpawn === "function") {
|
|
205
|
+
try {
|
|
206
|
+
if ((await beforeSpawn()) === false) {
|
|
207
|
+
signal?.removeEventListener("abort", onOuterAbort);
|
|
208
|
+
return {
|
|
209
|
+
status: null,
|
|
210
|
+
stdout: "",
|
|
211
|
+
stderr: "",
|
|
212
|
+
error: new Error("execution authority lost before Codex MCP spawn"),
|
|
213
|
+
sessionId: null,
|
|
214
|
+
initialized: false,
|
|
215
|
+
sawFrame: false,
|
|
216
|
+
exitCode: null,
|
|
217
|
+
aborted: true,
|
|
218
|
+
authorityLost: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
signal?.removeEventListener("abort", onOuterAbort);
|
|
223
|
+
return {
|
|
224
|
+
status: null,
|
|
225
|
+
stdout: "",
|
|
226
|
+
stderr: "",
|
|
227
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
228
|
+
sessionId: null,
|
|
229
|
+
initialized: false,
|
|
230
|
+
sawFrame: false,
|
|
231
|
+
exitCode: null,
|
|
232
|
+
aborted: true,
|
|
233
|
+
authorityLost: true,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
202
238
|
const child = spawnImpl(cmd, serverArgs, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
203
239
|
const parser = createNdjsonParser();
|
|
204
240
|
const pending = new Map();
|
package/src/config.mjs
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
// overlaid by env vars and CLI flags / a --join blob. The join blob carries the
|
|
3
3
|
// MCP url + token (+ optional channel) so a user can paste one command.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
chmodSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
6
15
|
import { homedir } from "node:os";
|
|
7
|
-
import {
|
|
16
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
17
|
|
|
9
18
|
export const GLOBAL_CONFIG = join(homedir(), ".hilos", "agent.json");
|
|
10
19
|
export const LOCAL_CONFIG = "hilos-agent.json";
|
|
@@ -347,7 +356,16 @@ export function reloadConfig(prev) {
|
|
|
347
356
|
/** Write a starter config file (used by `hilos-agent init`). */
|
|
348
357
|
export function writeStarterConfig(path, partial = {}) {
|
|
349
358
|
const target = path || GLOBAL_CONFIG;
|
|
350
|
-
|
|
359
|
+
const targetDir = dirname(target);
|
|
360
|
+
const directoryExisted = existsSync(targetDir);
|
|
361
|
+
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
362
|
+
// A newly created credentials directory must be private. Also repair the
|
|
363
|
+
// default ~/.hilos directory if an older install created it permissively,
|
|
364
|
+
// without unexpectedly chmod'ing an arbitrary existing directory supplied
|
|
365
|
+
// through --config (which may be a repository root).
|
|
366
|
+
if (!directoryExisted || resolve(targetDir) === resolve(dirname(GLOBAL_CONFIG))) {
|
|
367
|
+
chmodSync(targetDir, 0o700);
|
|
368
|
+
}
|
|
351
369
|
const starter = {
|
|
352
370
|
url: partial.url || DEFAULTS.url,
|
|
353
371
|
token: partial.token || "",
|
|
@@ -358,8 +376,24 @@ export function writeStarterConfig(path, partial = {}) {
|
|
|
358
376
|
webSearch: partial.webSearch !== false,
|
|
359
377
|
defaultBranch: DEFAULTS.defaultBranch,
|
|
360
378
|
// false = open a PR directly (bias to action); true = approve-before-push.
|
|
361
|
-
gate:
|
|
379
|
+
gate: partial.gate === true,
|
|
362
380
|
};
|
|
363
|
-
|
|
381
|
+
// Write beside the destination and rename it into place so a crash cannot
|
|
382
|
+
// leave a truncated token file. The temporary starts private, and replacing
|
|
383
|
+
// an older 0644 file also tightens the final path to 0600 in one rename.
|
|
384
|
+
const temporary = join(
|
|
385
|
+
targetDir,
|
|
386
|
+
`.${basename(target)}.${process.pid}.${randomUUID()}.tmp`,
|
|
387
|
+
);
|
|
388
|
+
try {
|
|
389
|
+
writeFileSync(temporary, JSON.stringify(starter, null, 2) + "\n", {
|
|
390
|
+
flag: "wx",
|
|
391
|
+
mode: 0o600,
|
|
392
|
+
});
|
|
393
|
+
renameSync(temporary, target);
|
|
394
|
+
chmodSync(target, 0o600);
|
|
395
|
+
} finally {
|
|
396
|
+
rmSync(temporary, { force: true });
|
|
397
|
+
}
|
|
364
398
|
return target;
|
|
365
399
|
}
|
package/src/cursor-store.mjs
CHANGED
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
// restart resumes from it — bounded by `catchupMs`, because replaying a week of
|
|
8
8
|
// stale asks after a vacation would be worse than skipping them.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// longer happen is a mention arriving into a dead daemon and never being seen.
|
|
10
|
+
// Persisting the cursor acknowledges a safely completed queue lifecycle, not
|
|
11
|
+
// delivery into volatile memory. New servers add the mention-table ingestion
|
|
12
|
+
// sequence, so edits/rebuilds and equal-timestamp siblings cannot be skipped.
|
|
14
13
|
//
|
|
15
14
|
// Dependency-free and injectable-dir like hook.mjs's state store, so tests run
|
|
16
15
|
// against a temp dir and never touch a real ~/.hilos.
|
|
@@ -38,6 +37,13 @@ export function cursorPath(agentId, dir = CURSOR_STATE_DIR) {
|
|
|
38
37
|
* @returns {string|null} ISO timestamp
|
|
39
38
|
*/
|
|
40
39
|
export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_DIR, now = Date.now } = {}) {
|
|
40
|
+
return loadMentionCursorState(agentId, { catchupMs, dir, now })?.createdAt ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadMentionCursorState(
|
|
44
|
+
agentId,
|
|
45
|
+
{ catchupMs = 0, dir = CURSOR_STATE_DIR, now = Date.now } = {},
|
|
46
|
+
) {
|
|
41
47
|
if (!agentId || !(catchupMs > 0)) return null;
|
|
42
48
|
let parsed;
|
|
43
49
|
try {
|
|
@@ -50,7 +56,24 @@ export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_D
|
|
|
50
56
|
const at = new Date(value).getTime();
|
|
51
57
|
if (Number.isNaN(at)) return null;
|
|
52
58
|
const floor = now() - catchupMs;
|
|
53
|
-
|
|
59
|
+
const mentionSeq = Number.isSafeInteger(parsed.mentionCursorSeq) && parsed.mentionCursorSeq >= 0
|
|
60
|
+
? parsed.mentionCursorSeq
|
|
61
|
+
: null;
|
|
62
|
+
if (mentionSeq != null) {
|
|
63
|
+
// Sequence progress belongs to the ingestion checkpoint, not to the
|
|
64
|
+
// original message timestamp: editing a year-old message creates fresh
|
|
65
|
+
// sequence work. New state records when the checkpoint was written so the
|
|
66
|
+
// catch-up window still bounds genuinely abandoned daemons. Pre-release
|
|
67
|
+
// sequence files without this additive field remain usable.
|
|
68
|
+
if (typeof parsed.mentionCursorSavedAt === "string") {
|
|
69
|
+
const savedAt = new Date(parsed.mentionCursorSavedAt).getTime();
|
|
70
|
+
if (!Number.isFinite(savedAt)) return null;
|
|
71
|
+
if (savedAt < floor) return { createdAt: new Date(floor).toISOString(), mentionSeq: null };
|
|
72
|
+
}
|
|
73
|
+
return { createdAt: value, mentionSeq };
|
|
74
|
+
}
|
|
75
|
+
if (at < floor) return { createdAt: new Date(floor).toISOString(), mentionSeq: null };
|
|
76
|
+
return { createdAt: value, mentionSeq: null };
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
/**
|
|
@@ -60,14 +83,26 @@ export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_D
|
|
|
60
83
|
*
|
|
61
84
|
* @param {string} agentId
|
|
62
85
|
* @param {string} isoTimestamp
|
|
63
|
-
* @param {{ dir?: string, log?: { error?: (m: string) => void } }} [opts]
|
|
86
|
+
* @param {{ dir?: string, log?: { error?: (m: string) => void }, mentionSeq?: number|null, now?: () => number }} [opts]
|
|
64
87
|
* @returns {boolean} whether the write landed
|
|
65
88
|
*/
|
|
66
|
-
export function saveMentionCursor(
|
|
89
|
+
export function saveMentionCursor(
|
|
90
|
+
agentId,
|
|
91
|
+
isoTimestamp,
|
|
92
|
+
{ dir = CURSOR_STATE_DIR, log, mentionSeq = null, now = Date.now } = {},
|
|
93
|
+
) {
|
|
67
94
|
if (!agentId || typeof isoTimestamp !== "string" || !isoTimestamp) return false;
|
|
68
95
|
try {
|
|
69
96
|
mkdirSync(dir, { recursive: true });
|
|
70
|
-
writeFileSync(
|
|
97
|
+
writeFileSync(
|
|
98
|
+
cursorPath(agentId, dir),
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
mentionCursor: isoTimestamp,
|
|
101
|
+
...(Number.isSafeInteger(mentionSeq) && mentionSeq >= 0
|
|
102
|
+
? { mentionCursorSeq: mentionSeq, mentionCursorSavedAt: new Date(now()).toISOString() }
|
|
103
|
+
: {}),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
71
106
|
return true;
|
|
72
107
|
} catch (e) {
|
|
73
108
|
log?.error?.(`mention cursor not persisted: ${e?.message || e}`);
|