hilos-agent 0.10.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 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 bundled browser bridge currently needs
117
- Node.js 24 or newer; the rest of the daemon keeps its existing Node.js support.
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. See the complete contract in
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
@@ -158,7 +163,16 @@ can never approve or merge hilos work. See the complete contract in
158
163
  PR thread is executed, not described. The daemon relays the request to hilos
159
164
  with the id of the message that asked; hilos verifies the person's role and
160
165
  that their message really asks for it, then acts with the workspace's GitHub
161
- App. The daemon never merges on its own judgment and holds no merge rights.
166
+ App. If GitHub reports a same-repository merge conflict, the daemon keeps the
167
+ same turn alive: it fetches the base branch, starts a real two-parent merge on
168
+ the existing PR branch, names the conflicted files to the coding agent,
169
+ verifies that no unmerged entries or conflict markers remain, and pushes the
170
+ repaired head for fresh human review. It never opens a replacement PR or
171
+ silently merges after repair. Fork PRs stop with an explicit writable-branch
172
+ boundary. The daemon never merges on its own judgment and holds no merge
173
+ rights. With `gate:true`, this existing-branch repair still pushes so the PR
174
+ can return for fresh review. It is the only approve-before-push exception;
175
+ the repaired head remains unmerged and needs a new approval.
162
176
  - **Open a PR** (default) — it commits, pushes with *your* `git`/`gh`, opens a PR,
163
177
  and posts a report card with the link. Review on the card: **Approve** merges,
164
178
  **Reject** closes, **Request changes** re-works.
@@ -300,10 +314,9 @@ safest first:
300
314
 
301
315
  The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
302
316
  you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
303
- before anything is pushed. OpenCode is the first harness with the runtime-card
304
- bridge; Claude Code, Codex, Cursor, and other adapters still follow their own
305
- CLI permission modes until their native approval hooks join the same
306
- 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.
307
320
 
308
321
  ## Hooks — keep a raw Codex, Claude Code, or Cursor session in the room
309
322
 
@@ -396,6 +409,16 @@ hilos's GitHub App only for workspace owners/admins). Want a human checkpoint
396
409
  before anything is pushed? Set `"gate": true`. Keep your token in the config file
397
410
  or `HILOS_TOKEN`, never in shared shell history.
398
411
 
412
+ ### Durable activity, not raw output
413
+
414
+ On a current hilos server, daemon-launched Claude Code, Codex, Cursor, and
415
+ OpenCode repo runs automatically keep the same bounded activity facts used by
416
+ the live card—phases, tool/file activity, notes, reported usage, and completion—
417
+ in hilos's runtime-neutral event record. Writes are batched, retry-safe,
418
+ bounded to two one-second attempts, re-authorized and redacted on the server,
419
+ and never allowed to fail the coding run. Raw stdout is not sent by this path.
420
+ An older server simply leaves it off.
421
+
399
422
  ### Run transcripts are opt-in
400
423
 
401
424
  The room gets what a teammate needs to see: a plan, live progress, a report, a
@@ -421,8 +444,29 @@ restart — and **deleting** the key counts as off, not as "leave it as it was".
421
444
 
422
445
  ## Flags
423
446
 
424
- `--join <blob>` · `--channel <id>` · `--config <path>` · `--coding-cmd <cmd>` ·
425
- `--chat-cmd <cmd>` · `--once` · `--backfill` · `--no-gate` · `--help`
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.
426
459
 
427
460
  Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`, `HILOS_ONCE=1`,
428
461
  `HILOS_BACKFILL=1`, `HILOS_UPLOAD_TRANSCRIPTS=1|0`.
462
+
463
+ ## Releasing
464
+
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).
@@ -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, proposes a diff, pushes only after a human
4
- // approves in hilos. Your code + credentials never leave your machine.
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[++i];
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[++i];
46
- else if (a === "--channel") flags.channelId = argv[++i];
47
- else if (a === "--url") flags.url = argv[++i];
48
- else if (a === "--token") flags.token = argv[++i];
49
- else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
50
- else if (a === "--coding-model") flags.codingModel = argv[++i];
51
- else if (a === "--chat-cmd") flags.chatCmd = argv[++i];
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[++i];
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
- return { cmd: positional[0] || "run", flags, positional };
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> connect using a link copied from hilos
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
- --no-gate propose only; don't wait for approval / push
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.10.1",
4
- "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
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"
@@ -12,7 +12,7 @@
12
12
  "README.md"
13
13
  ],
14
14
  "engines": {
15
- "node": ">=18"
15
+ "node": ">=20"
16
16
  },
17
17
  "homepage": "https://hilos.sh",
18
18
  "repository": {
@@ -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
- "dependencies": {
37
- "agent-browser": "^0.35.0"
38
+ "optionalDependencies": {
39
+ "agent-browser": "^0.36.0"
38
40
  }
39
41
  }
@@ -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 { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
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 { join, dirname } from "node:path";
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
- mkdirSync(dirname(target), { recursive: true });
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: false,
379
+ gate: partial.gate === true,
362
380
  };
363
- writeFileSync(target, JSON.stringify(starter, null, 2) + "\n");
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
  }