hilos-agent 0.11.4 → 0.11.5

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
@@ -501,6 +501,11 @@ Bump `version` in `package.json`, merge, wait for main CI, then tag that commit
501
501
  that exact version, a commit contained in `main`, and a successful `verify`
502
502
  check. It packs and clean-installs the artifact on Node.js 20, 22, and 24 before
503
503
  publishing, then installs the registry copy and checks its version and help.
504
- A manual workflow dispatch is always a dry run and cannot publish. The current
505
- release path needs the `NPM_TOKEN` repository secret until npm trusted
506
- publishing is configured (1094, 1211).
504
+ A manual workflow dispatch is always a dry run and cannot publish. The workflow
505
+ prefers npm trusted publishing's short-lived GitHub OIDC identity. The registry
506
+ trust is configured for `pablostanley/hilos`, `npm-publish.yml`, no GitHub
507
+ environment, and direct publishing. `NPM_TOKEN` remains as a fallback until one
508
+ real tagged release proves OIDC end to end. After that proof, remove the
509
+ workflow fallback, revoke the npm token, and delete the GitHub secret (1094,
510
+ 1211, 1228). Because the source repository is private, npm will not attach a
511
+ public provenance attestation even when the publish itself uses OIDC.
@@ -31,6 +31,14 @@ import { runWebMcpCommand } from "../src/webmcp-bridge.mjs";
31
31
  import { detectVendor, fastChatCmd, webCapability } from "../src/progress-emitter.mjs";
32
32
  import { commandArgv } from "../src/argv.mjs";
33
33
 
34
+ // 1251 — the plugin writes `hook --claude --personal`, so the client flags name
35
+ // the vendor for `hook` the same way they choose a target for `hooks install`.
36
+ const HOOK_CLIENT_VENDORS = {
37
+ claude: "claude_code",
38
+ codex: "codex",
39
+ cursor: "cursor",
40
+ };
41
+
34
42
  function packageVersion() {
35
43
  const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
36
44
  return JSON.parse(readFileSync(pkgPath, "utf8")).version;
@@ -121,6 +129,9 @@ function parseArgs(argv) {
121
129
  else if (a === "--cursor") flags.hookClient = "cursor";
122
130
  else if (a === "--vendor") flags.vendor = requiredOptionValue(argv, i++, a);
123
131
  else if (a === "--scope-managed") flags.scopeManaged = true;
132
+ // This session belongs to a person, not to an agent: it takes a seat in the
133
+ // room and never streams into an agent's live card.
134
+ else if (a === "--personal") flags.personal = true;
124
135
  // Marker embedded in self-contained hook commands. It is intentionally
125
136
  // internal: hook.mjs uses it to recognize and replace managed installs.
126
137
  else if (a === "--managed-runtime") flags.managedRuntime = true;
@@ -154,6 +165,10 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
154
165
  use --codex, --claude, or --cursor to choose, and
155
166
  --global for every repo. HILOS_HOOKS=off pauses
156
167
  streaming; HILOS_REPLY_BRIDGE=off pauses pickup.
168
+ --personal takes YOUR seat in the room instead
169
+ of streaming into an agent's card; it needs
170
+ HILOS_MCP_TOKEN. The Claude Code plugin does the
171
+ same in one gesture.
157
172
  hilos-agent hooks print preview the hook configuration without writing
158
173
 
159
174
  Options:
@@ -201,11 +216,19 @@ async function main() {
201
216
  // fast, silent, and always exit 0, so it short-circuits before any daemon
202
217
  // machinery.
203
218
  if (cmd === "hook") {
204
- await hookMain({ vendor: flags.vendor || "unknown", scopeManaged: Boolean(flags.scopeManaged) });
219
+ await hookMain({
220
+ vendor: flags.vendor || HOOK_CLIENT_VENDORS[flags.hookClient] || "unknown",
221
+ scopeManaged: Boolean(flags.scopeManaged),
222
+ personal: Boolean(flags.personal),
223
+ });
205
224
  return;
206
225
  }
207
226
  if (cmd === "hooks") {
208
- hooksMain(positional[1], { global: Boolean(flags.global), client: flags.hookClient || "all" });
227
+ hooksMain(positional[1], {
228
+ global: Boolean(flags.global),
229
+ client: flags.hookClient || "all",
230
+ personal: Boolean(flags.personal),
231
+ });
209
232
  return;
210
233
  }
211
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
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": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "src",
12
+ "skills",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: hilos-handoff
3
+ description: Hand work from this session to the room's agents — one ask, or a plan of rows a person approves. Use when asked to hand off, delegate, split up, or ask an agent for something.
4
+ ---
5
+
6
+ # Handoff
7
+
8
+ Give work to the room instead of doing all of it here. Everything below is
9
+ posted as the person running this session; they are the author of record.
10
+
11
+ 1. Call `list_room_roster` for the room. Offer the agents it returns as the
12
+ options, with their state, so nobody hands work to an agent already busy.
13
+ 2. Say what the work is, in the person's words, and confirm the split with them
14
+ before calling anything.
15
+
16
+ One piece of work, one agent: call `ask_agent` with the channel id, the agent
17
+ (`agentId` or `handle`), the ask, `threadRootId` when the work belongs in the
18
+ thread this session is bound to, and `kind` — `ask`, `review`, or `handoff`.
19
+
20
+ More than one piece: call `propose_plan` with the channel id, the ask in one
21
+ line, and up to eight rows. Give each row a `title`, a `brief` with the detail
22
+ the specialist needs, and an `assignee` only when the room's roster makes the
23
+ right agent obvious; leaving it off lets hilos route the row.
24
+
25
+ A plan is a proposal, not a dispatch. Say so when you print the result: nothing
26
+ runs until a person approves the rows on the plan card in hilos, and rows
27
+ nobody approves never run at all. Print the link and stop there.
@@ -0,0 +1,31 @@
1
+ ---
2
+ name: hilos-room
3
+ description: Show who is in the hilos room this session is attached to, and what is waiting on you. Use when asked "who is in the room", "what needs me", or "catch me up on hilos".
4
+ ---
5
+
6
+ # Room
7
+
8
+ Who is at the table right now, and what is waiting on the person running this
9
+ session. Read-only: this skill never posts anything.
10
+
11
+ 1. Find the room. Use the channel this session has already posted into. If
12
+ there is none, call `list_channels` and ask which room to read.
13
+ 2. Call `list_room_roster` with that channel id.
14
+ 3. Call `list_mentions` for what is addressed to this person.
15
+
16
+ Print two short blocks and nothing else.
17
+
18
+ A table of the roster, one row per seat:
19
+
20
+ | Who | What | State | On |
21
+ | --- | --- | --- | --- |
22
+
23
+ `What` is person, session, or agent. `State` is what the roster returned
24
+ (working, waiting on a person, available, offline). `On` is the task title or
25
+ branch when the roster gives one, otherwise a dash.
26
+
27
+ Then `Needs you:` and one line per mention, newest first: who, the room, and
28
+ the first few words. Write `Nothing is waiting.` when the list is empty.
29
+
30
+ Keep the whole answer under twenty lines. A guest in the room means the roster
31
+ comes back as names only; say so in one line instead of inventing detail.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: hilos-share
3
+ description: Post a short summary, a diff, or a link from this session into the hilos thread it is bound to. Use when asked to share, post, or tell the room what happened.
4
+ ---
5
+
6
+ # Share
7
+
8
+ Put what just happened in front of the people in the room. The message is
9
+ posted as the person running this session, under their name.
10
+
11
+ 1. Find the thread. Use the thread this session last posted into. If there is
12
+ none, call `list_channels`, ask which room, and post at top level there.
13
+ 2. Write the summary yourself, in the person's register: what changed, where to
14
+ look, what is still open. Three to six lines. A branch name, a PR link, or a
15
+ `git diff --stat` reads better than prose about the diff.
16
+ 3. Call `post_message` with the channel id, the body, and `parentId` when there
17
+ is a bound thread.
18
+ 4. Print the returned link so the person can open it.
19
+
20
+ Never paste a whole diff, a transcript, a token, or the contents of `.env`. If
21
+ the change is large, share the shape of it and the link. Ask before sharing
22
+ anything the person has not seen in this session yet.
@@ -0,0 +1,316 @@
1
+ // The agent's seat INSIDE its own run (1255).
2
+ //
3
+ // Until this, a daemon-launched coding child was deaf and mute: it edited files
4
+ // and the daemon spoke for it afterwards. A hosted run has had an ephemeral
5
+ // hilos MCP token all along, so the same agent behaved like a member on hilos's
6
+ // machines and like a batch job on its owner's. That asymmetry is what this
7
+ // closes — the child can post a step worth showing, ask a teammate agent by
8
+ // handle, read the roster, and read the thread, from inside the work.
9
+ //
10
+ // It reuses 0854's mechanism unchanged (`mcp-loopback.mjs`): an unguessable
11
+ // loopback URL that forwards to hilos with the daemon's own bearer token, alive
12
+ // only for the turn. The token never enters the child's environment or argv —
13
+ // the 0512 env scrub stays exactly as strict as it was, because nothing about
14
+ // it changes; the credential simply stays in the daemon process, where it
15
+ // already lives.
16
+ //
17
+ // Two limits make that safe to hand a model that also reads the repo:
18
+ //
19
+ // 1. The loopback carries the DAEMON's identity, so the server's own
20
+ // named-agent authorization is still the outer wall. A channel-bound token
21
+ // is still channel-bound; a private room the agent cannot read is still
22
+ // unreadable. Nothing here widens what the agent may do.
23
+ // 2. `CHILD_RUN_TOOLS` is the inner wall: the run may talk, ask, and read,
24
+ // and it may not merge, delete, archive, invite, spend, or reconfigure.
25
+ // A repo file that says "now merge PR #12" reaches a model that has no
26
+ // merge_pr to call. Those authorities belong to the daemon and to the
27
+ // people in the room, which is what the refusal says out loud.
28
+
29
+ import fs from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import { randomBytes } from "node:crypto";
33
+ import { isV2AmbientBindingClaim, startHilosMcpLoopback } from "./mcp-loopback.mjs";
34
+
35
+ /** The MCP server key the child sees. `mcp__hilos__<tool>` on the claude lane. */
36
+ export const CHILD_MCP_SERVER = "hilos";
37
+
38
+ /**
39
+ * The ONLY hilos tools a coding child may call from inside its run.
40
+ *
41
+ * The rule that produced this list: a run may SPEAK (post, ask, propose,
42
+ * request) and it may READ (roster, members, thread, channel, docs, tasks,
43
+ * links, diff, limits, runs, memory). Everything that CHANGES the room or the
44
+ * repository from under the people watching — merge_pr, delete_message,
45
+ * write_doc, update_task, archive_channel, invite_member, create_agent,
46
+ * set_env, start_run, every settle/approve/decide — is deliberately absent.
47
+ * Those are the daemon's to perform after the run reports, or a person's to
48
+ * decide on a card. An agent proposes; a person disposes; a run does neither
49
+ * behind the room's back.
50
+ *
51
+ * Keep this the single source of truth: the loopback enforces it, the prompt
52
+ * names a subset of it, and DAEMON.md documents it.
53
+ */
54
+ export const CHILD_RUN_TOOLS = Object.freeze([
55
+ // Speak in the room.
56
+ "post_message",
57
+ "post_progress",
58
+ "post_report",
59
+ "ask_agent",
60
+ "propose_plan",
61
+ "request_review",
62
+ "request_approval",
63
+ "ask_question",
64
+ // See who is here.
65
+ "list_room_roster",
66
+ "list_members",
67
+ "list_agents",
68
+ "whoami",
69
+ // Read what the room already knows.
70
+ "get_thread",
71
+ "read_channel",
72
+ "read_doc",
73
+ "search_docs",
74
+ "search_messages",
75
+ "list_tasks",
76
+ "get_task",
77
+ "get_links",
78
+ "get_pr_diff",
79
+ "get_room_limits",
80
+ "list_runs",
81
+ "remember",
82
+ "recall",
83
+ ]);
84
+
85
+ /** claude's allow entry for the whole server. Verified live (see below). */
86
+ export const CLAUDE_CHILD_MCP_TOOL_PATTERN = `mcp__${CHILD_MCP_SERVER}__*`;
87
+
88
+ /**
89
+ * The vendors whose child gets this seat.
90
+ *
91
+ * Only the two CLIs whose remote-MCP wiring the daemon can set for ONE run
92
+ * without touching the operator's own persistent config. Cursor, OpenCode,
93
+ * antigravity, and hermes are unchanged by design: their run-scoped remote-MCP
94
+ * seams are either absent or would require editing a config file that outlives
95
+ * the turn, and a permanent change is not what a turn-scoped seat means.
96
+ */
97
+ export const CHILD_MCP_VENDORS = Object.freeze(["claude_code", "codex"]);
98
+
99
+ const OFF_RE = /^(0|off|false|no)$/i;
100
+ const ON_RE = /^(1|on|true|yes)$/i;
101
+
102
+ /**
103
+ * Is the seat switched on for this machine?
104
+ *
105
+ * Default ON. `HILOS_CHILD_MCP=off` in the environment and `childMcp: false` in
106
+ * `~/.hilos/agent.json` both turn it off; the environment wins either way, so
107
+ * an operator can disable it without editing a file and re-enable it without
108
+ * finding the one that said no.
109
+ *
110
+ * @param {{ childMcp?: boolean } | null} [cfg]
111
+ * @param {Record<string, string | undefined>} [env]
112
+ */
113
+ export function childMcpEnabled(cfg, env = process.env) {
114
+ const raw = String(env?.HILOS_CHILD_MCP ?? "").trim();
115
+ if (OFF_RE.test(raw)) return false;
116
+ if (ON_RE.test(raw)) return true;
117
+ return cfg?.childMcp !== false;
118
+ }
119
+
120
+ /** @param {string} [vendor] */
121
+ export function childMcpSupportsVendor(vendor) {
122
+ return CHILD_MCP_VENDORS.includes(String(vendor || ""));
123
+ }
124
+
125
+ /**
126
+ * Will this run get the seat?
127
+ *
128
+ * Answerable BEFORE the loopback exists, because the PROMPT is written first
129
+ * and it must not promise tools the child will not have (the same reason
130
+ * `codexRunIsGated` exists). Every input is known at that point: the switch,
131
+ * the vendor, the connection, and whether the server sent a v2 room claim with
132
+ * this job. The only thing left that can still fail is binding a loopback
133
+ * socket, which is why `startChildHilosMcp` returns null on that and the
134
+ * prompt-side over-promise is bounded to a case that does not happen in
135
+ * practice.
136
+ *
137
+ * @param {{ cfg?: any, vendor?: string, bindingClaim?: unknown, env?: Record<string, string | undefined> }} o
138
+ */
139
+ export function childMcpPlanned({ cfg, vendor, bindingClaim, env } = {}) {
140
+ if (!childMcpEnabled(cfg, env ?? process.env)) return false;
141
+ if (!childMcpSupportsVendor(vendor)) return false;
142
+ if (!cfg?.url || !cfg?.token) return false;
143
+ // Same rule the reply bridge follows: no server-signed room proof, no bridge.
144
+ // A run whose ambient authority cannot be proven gets no room tools at all
145
+ // rather than a set of calls the server would reject one by one.
146
+ return isV2AmbientBindingClaim(typeof bindingClaim === "string" ? bindingClaim.trim() : "");
147
+ }
148
+
149
+ /** The `mcpServers` fragment for a claude `--mcp-config` JSON. */
150
+ export function childMcpServers(url) {
151
+ return { [CHILD_MCP_SERVER]: { type: "http", url } };
152
+ }
153
+
154
+ /**
155
+ * Write a 0600 `--mcp-config` holding ONLY the hilos server.
156
+ *
157
+ * Used when the permission gate is off and therefore writes no config of its
158
+ * own. When the gate IS on, the hilos entry is merged into the gate's single
159
+ * config file instead — claude takes `--mcp-config` more than once, but one
160
+ * file the run owns end-to-end is one file to delete.
161
+ */
162
+ export function writeChildMcpConfig(url, { dir = os.tmpdir() } = {}) {
163
+ const configPath = path.join(
164
+ dir,
165
+ `hilos-child-mcp-${process.pid}-${randomBytes(6).toString("hex")}.json`,
166
+ );
167
+ fs.writeFileSync(configPath, JSON.stringify({ mcpServers: childMcpServers(url) }), {
168
+ mode: 0o600,
169
+ });
170
+ return configPath;
171
+ }
172
+
173
+ /**
174
+ * The argv a CODEX child needs to reach the loopback, byte-identical in shape
175
+ * to what the reply bridge has been passing since 0854.
176
+ *
177
+ * The third entry is not decoration. Neither `codex exec` (stdin closed) nor a
178
+ * gated `codex mcp-server` run should route the agent's OWN room tools through
179
+ * an approval channel: on exec there is none, so every call comes back "user
180
+ * cancelled", and on mcp-server it would raise a permission card in the room
181
+ * for the agent asking to speak in that same room. Approval is scoped to this
182
+ * one random, loopback-only, turn-scoped server; hilos's own authorization and
183
+ * the allowlist below remain the authority.
184
+ *
185
+ * @param {string} url
186
+ */
187
+ export function codexChildMcpArgs(url) {
188
+ if (!url) return [];
189
+ return [
190
+ "-c", `mcp_servers.${CHILD_MCP_SERVER}.url=${JSON.stringify(url)}`,
191
+ "-c", `mcp_servers.${CHILD_MCP_SERVER}.startup_timeout_sec=15`,
192
+ "-c", `mcp_servers.${CHILD_MCP_SERVER}.default_tools_approval_mode="approve"`,
193
+ ];
194
+ }
195
+
196
+ /**
197
+ * The argv a CLAUDE child needs on the ungated lane.
198
+ *
199
+ * VERIFIED LIVE against claude 2.1.261 (0521: flags are proven against the
200
+ * binary, never remembered):
201
+ * • `-p --permission-mode acceptEdits --mcp-config <cfg>` alone answers
202
+ * "Permission required to call `mcp__hilos__ping`" and never calls the
203
+ * tool — an MCP server without an allow entry is inert in print mode;
204
+ * • adding `--allowedTools "mcp__hilos__*"` calls it (the wildcard is
205
+ * honored per server);
206
+ * • repeated `--allowedTools` flags ACCUMULATE — an operator's own
207
+ * `--allowedTools` in `codingCmd` still applies when ours is appended;
208
+ * • with `--permission-prompt-tool` also present, the allow entry wins and
209
+ * the gate is never asked, so the agent's own room tools raise no card,
210
+ * while everything else still does.
211
+ *
212
+ * @param {string} configPath
213
+ */
214
+ export function claudeChildMcpArgs(configPath) {
215
+ if (!configPath) return [];
216
+ return ["--mcp-config", configPath, "--allowedTools", CLAUDE_CHILD_MCP_TOOL_PATTERN];
217
+ }
218
+
219
+ /**
220
+ * The argv for whichever vendor this run uses, on the UNGATED lane. The gated
221
+ * lanes thread the same facts through their own transports instead.
222
+ *
223
+ * @param {string} vendor
224
+ * @param {{ url?: string, configPath?: string | null } | null} childMcp
225
+ */
226
+ export function childMcpArgs(vendor, childMcp) {
227
+ if (!childMcp) return [];
228
+ if (vendor === "claude_code") return claudeChildMcpArgs(childMcp.configPath || "");
229
+ if (vendor === "codex") return codexChildMcpArgs(childMcp.url || "");
230
+ return [];
231
+ }
232
+
233
+ /**
234
+ * Start the turn-scoped seat, or return null when this run does not get one.
235
+ * The caller MUST close it in a finally: the loopback outliving the run would
236
+ * be a live credential proxy with nobody using it.
237
+ *
238
+ * @param {{
239
+ * cfg?: any,
240
+ * vendor?: string,
241
+ * bindingClaim?: unknown,
242
+ * standaloneConfig?: boolean,
243
+ * startLoopback?: typeof startHilosMcpLoopback,
244
+ * dir?: string,
245
+ * log?: { error?: (message: string) => void },
246
+ * }} o
247
+ */
248
+ export async function startChildHilosMcp({
249
+ cfg,
250
+ vendor,
251
+ bindingClaim,
252
+ standaloneConfig = false,
253
+ startLoopback = startHilosMcpLoopback,
254
+ dir = os.tmpdir(),
255
+ log,
256
+ } = {}) {
257
+ if (!childMcpPlanned({ cfg, vendor, bindingClaim })) return null;
258
+ let loopback = null;
259
+ try {
260
+ loopback = await startLoopback({
261
+ url: cfg.url,
262
+ token: cfg.token,
263
+ bindingClaim: String(bindingClaim).trim(),
264
+ allowTools: CHILD_RUN_TOOLS,
265
+ });
266
+ } catch (error) {
267
+ log?.error?.(`hilos child MCP: ${error?.message || error}`);
268
+ return null;
269
+ }
270
+ if (!loopback) return null;
271
+ let configPath = null;
272
+ if (standaloneConfig) {
273
+ try {
274
+ configPath = writeChildMcpConfig(loopback.url, { dir });
275
+ } catch (error) {
276
+ // A config we cannot write means a claude child with no way to reach the
277
+ // seat. Tear the proxy down rather than leave one open for nobody.
278
+ log?.error?.(`hilos child MCP config: ${error?.message || error}`);
279
+ await loopback.close().catch(() => {});
280
+ return null;
281
+ }
282
+ }
283
+ return {
284
+ url: loopback.url,
285
+ configPath,
286
+ servers: childMcpServers(loopback.url),
287
+ allowedTools: CLAUDE_CHILD_MCP_TOOL_PATTERN,
288
+ async close() {
289
+ if (configPath) {
290
+ try {
291
+ fs.unlinkSync(configPath);
292
+ } catch {
293
+ /* a leftover temp config must never fail a finished run */
294
+ }
295
+ }
296
+ await loopback.close().catch(() => {});
297
+ },
298
+ };
299
+ }
300
+
301
+ /**
302
+ * The paragraph the coding prompt gains when the seat is on. Deliberately short
303
+ * and deliberately "sparingly": a run that narrates every file it opens is the
304
+ * failure mode, and the daemon still posts the report at the end either way.
305
+ *
306
+ * @param {boolean} enabled
307
+ */
308
+ export function childToolsNote(enabled) {
309
+ if (!enabled) return "";
310
+ return (
311
+ "You have hilos tools for this room: post_progress for a step worth showing, " +
312
+ "ask_agent to ask a teammate agent by handle when you are blocked, " +
313
+ "list_room_roster to see who is here, get_thread to read the discussion. " +
314
+ "Use them sparingly; the daemon posts your report when you finish."
315
+ );
316
+ }
@@ -160,9 +160,27 @@ export function shouldGateClaudePermissions({ vendor, runtimePermissions, codeAr
160
160
  return true;
161
161
  }
162
162
 
163
- /** The argv the gate appends to a claude code run. */
164
- export function claudePermissionArgs({ configPath, toolId = CLAUDE_PERMISSION_TOOL_ID }) {
165
- return ["--mcp-config", configPath, "--permission-prompt-tool", toolId];
163
+ /**
164
+ * The argv the gate appends to a claude code run.
165
+ *
166
+ * `allowedTools` (1255) is the pattern for the run's OWN hilos server when it
167
+ * rides in this same config. Verified live on 2.1.261: an allow entry wins over
168
+ * `--permission-prompt-tool`, so the agent speaking in its own room raises no
169
+ * card, while every other tool still does; and repeated `--allowedTools` flags
170
+ * accumulate, so an operator's own list in `codingCmd` survives.
171
+ */
172
+ export function claudePermissionArgs({
173
+ configPath,
174
+ toolId = CLAUDE_PERMISSION_TOOL_ID,
175
+ allowedTools = "",
176
+ }) {
177
+ return [
178
+ "--mcp-config",
179
+ configPath,
180
+ "--permission-prompt-tool",
181
+ toolId,
182
+ ...(allowedTools ? ["--allowedTools", allowedTools] : []),
183
+ ];
166
184
  }
167
185
 
168
186
  /** The flags to strip when a CLI turns out not to know them (compat retry). */
@@ -186,6 +204,8 @@ export const CLAUDE_PERMISSION_FLAGS = ["--mcp-config", "--permission-prompt-too
186
204
  * signal?: AbortSignal,
187
205
  * log?: { error?: (message: string) => void },
188
206
  * dir?: string,
207
+ * extraServers?: Record<string, object> | null,
208
+ * allowedTools?: string,
189
209
  * }} options
190
210
  * @returns {Promise<{ configPath: string, toolId: string, url: string, port: number,
191
211
  * args: string[], setSessionId: (value: string) => void, pending: () => number,
@@ -202,6 +222,11 @@ export async function startClaudePermissionServer({
202
222
  signal,
203
223
  log,
204
224
  dir = os.tmpdir(),
225
+ // 1255 — the run's turn-scoped hilos server rides in this SAME file. One
226
+ // `--mcp-config` the run owns end to end is one file to write 0600 and one
227
+ // file to delete; a second config would be a second lifetime to get wrong.
228
+ extraServers = null,
229
+ allowedTools = "",
205
230
  }) {
206
231
  if (typeof requestPermission !== "function" || typeof getPermissionDecision !== "function") {
207
232
  throw new Error("claude permission server requires requestPermission and getPermissionDecision");
@@ -408,6 +433,10 @@ export async function startClaudePermissionServer({
408
433
  configPath,
409
434
  JSON.stringify({
410
435
  mcpServers: {
436
+ // Extras go FIRST so the gate's own entry always wins the key: a
437
+ // caller's server must never be able to replace the permission server
438
+ // this file exists to serve.
439
+ ...(extraServers && typeof extraServers === "object" ? extraServers : {}),
411
440
  [CLAUDE_PERMISSION_SERVER]: { type: "http", url, headers: { Authorization: `Bearer ${token}` } },
412
441
  },
413
442
  }),
@@ -419,7 +448,7 @@ export async function startClaudePermissionServer({
419
448
  toolId: CLAUDE_PERMISSION_TOOL_ID,
420
449
  url,
421
450
  port,
422
- args: claudePermissionArgs({ configPath }),
451
+ args: claudePermissionArgs({ configPath, allowedTools }),
423
452
  /** Let the caller stamp the CLI's real session id onto later cards. */
424
453
  setSessionId(value) {
425
454
  if (typeof value === "string" && value) currentSessionId = value;
package/src/config.mjs CHANGED
@@ -107,6 +107,17 @@ const DEFAULTS = {
107
107
  // HILOS_UPLOAD_TRANSCRIPTS=1 does the same from the environment (=0 forces it
108
108
  // back off). Live-reloadable, so it can be revoked without a restart.
109
109
  uploadTranscripts: false,
110
+ // The agent's seat inside its own run (1255): a turn-scoped hilos MCP the
111
+ // daemon-launched Claude Code / Codex child can call, so a run can post a
112
+ // step, ask a teammate agent, read the roster, and read the thread from
113
+ // inside the work instead of only through the daemon's report afterwards.
114
+ // The bearer token stays in the daemon — the child gets a random loopback URL
115
+ // that dies with the turn, and only the read/speak tools on the allowlist in
116
+ // `child-mcp.mjs`; merge, delete, and every settle stay the daemon's.
117
+ //
118
+ // ON by default. `childMcp: false` here turns it off; HILOS_CHILD_MCP=off (or
119
+ // =on) does the same from the environment and wins over the file.
120
+ childMcp: true,
110
121
  // Replies in an exact thread bound by an opted-in local-session hook resume
111
122
  // that same provider session (0847). The hook install is the consent gate;
112
123
  // this switch lets an operator pause inbound pickup without uninstalling it.
@@ -215,6 +226,14 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
215
226
  : /^(0|off|false)$/i.test(process.env.HILOS_REPLY_BRIDGE || "")
216
227
  ? false
217
228
  : undefined,
229
+ // Both directions, like acpTransport: a machine whose file says yes must be
230
+ // able to say no from the environment, and the reverse.
231
+ childMcp:
232
+ /^(1|on|true|yes)$/i.test(process.env.HILOS_CHILD_MCP || "")
233
+ ? true
234
+ : /^(0|off|false|no)$/i.test(process.env.HILOS_CHILD_MCP || "")
235
+ ? false
236
+ : undefined,
218
237
  };
219
238
  const merged = { ...DEFAULTS, ...file };
220
239
  for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
@@ -252,6 +271,11 @@ const LIVE_FIELDS = [
252
271
  // special-cased below, because for this field a DELETED key has to mean no.
253
272
  "uploadTranscripts",
254
273
  "replyBridge",
274
+ // Live-reloadable: an operator who decides a run should not speak in the room
275
+ // must not have to restart the daemon to stop it. Like webMcp/webSearch, a
276
+ // DELETED key returns to the default rather than pinning a stale opt-out, so
277
+ // the special case below recomputes it.
278
+ "childMcp",
255
279
  "codingModel",
256
280
  "chatCmd",
257
281
  "codingEnv",
@@ -328,6 +352,9 @@ export function reloadConfig(prev) {
328
352
  // returns to that default on the next poll instead of pinning a stale opt-out
329
353
  // until restart; the environment override below still wins.
330
354
  if (changed("webSearch")) next.webSearch = file.webSearch !== false;
355
+ // The child's seat follows the same safe-on rule: removing an edited
356
+ // `childMcp:false` restores the default on the next poll.
357
+ if (changed("childMcp")) next.childMcp = file.childMcp !== false;
331
358
  // The environment stays the operator's override on reload, in both
332
359
  // directions — a machine that opted out with =0 must not be opted back in by
333
360
  // a file edit (0792).
@@ -335,6 +362,8 @@ export function reloadConfig(prev) {
335
362
  if (process.env.HILOS_UPLOAD_TRANSCRIPTS === "0") next.uploadTranscripts = false;
336
363
  if (/^(1|on|true)$/i.test(process.env.HILOS_REPLY_BRIDGE || "")) next.replyBridge = true;
337
364
  if (/^(0|off|false)$/i.test(process.env.HILOS_REPLY_BRIDGE || "")) next.replyBridge = false;
365
+ if (/^(1|on|true|yes)$/i.test(process.env.HILOS_CHILD_MCP || "")) next.childMcp = true;
366
+ if (/^(0|off|false|no)$/i.test(process.env.HILOS_CHILD_MCP || "")) next.childMcp = false;
338
367
  if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
339
368
  if (process.env.HILOS_CODING_MODEL) next.codingModel = process.env.HILOS_CODING_MODEL;
340
369
  if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;