hilos-agent 0.11.3 → 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 +20 -4
- package/bin/hilos-agent.mjs +25 -2
- package/package.json +2 -1
- package/skills/hilos-handoff/SKILL.md +27 -0
- package/skills/hilos-room/SKILL.md +31 -0
- package/skills/hilos-share/SKILL.md +22 -0
- package/src/child-mcp.mjs +316 -0
- package/src/claude-permissions.mjs +33 -4
- package/src/config.mjs +29 -0
- package/src/handler.mjs +206 -13
- package/src/hook.mjs +403 -12
- package/src/mcp-loopback.mjs +134 -9
- package/src/mcp.mjs +78 -3
- package/src/reply-bridge.mjs +277 -22
- package/src/run.mjs +85 -6
package/README.md
CHANGED
|
@@ -392,12 +392,23 @@ npx hilos-agent@latest hooks print
|
|
|
392
392
|
`hilos-agent` is running. Ambient messages, older thread history, and agent
|
|
393
393
|
replies do not wake it. Current workspace roles are checked at pickup: guests,
|
|
394
394
|
removed people, and unknown authors remain advisory and cannot start code.
|
|
395
|
+
- Every initial mention job is also signed for its exact agent-token row and
|
|
396
|
+
answer room after final server revalidation. The daemon keeps that proof on a
|
|
397
|
+
job-local MCP client, so concurrent rooms cannot exchange project context. A
|
|
398
|
+
guest-visible room may be explicitly allowed to run code, but its task still
|
|
399
|
+
cannot pull workspace memory, Docs, Tasks, sibling conversations, or external
|
|
400
|
+
context into the answer.
|
|
395
401
|
- The resumed turn receives bounded thread, room, and member context. Codex also
|
|
396
402
|
gets a random loopback-only Hilos MCP URL for that turn, so it can search any
|
|
397
403
|
room its linked owner can access when that owner minted the current key,
|
|
398
404
|
without receiving the bearer token. Regenerate an older key from Connect to
|
|
399
405
|
enable that inheritance. Admin-issued keys and other people's private rooms
|
|
400
|
-
stay outside the context.
|
|
406
|
+
stay outside the context. Hilos signs the turn's room into a short-lived
|
|
407
|
+
claim tied to that exact agent-token row; the loopback fixes the claim in an
|
|
408
|
+
upstream header that the coding child cannot replace. A missing, malformed,
|
|
409
|
+
cross-token, or conflicting room claim is refused, and a guest-visible turn
|
|
410
|
+
cannot use workspace-wide tools to pull private sibling context into its
|
|
411
|
+
answer.
|
|
401
412
|
- The room's normal execution gate still applies; a chat-only guest room cannot
|
|
402
413
|
resume local code.
|
|
403
414
|
- Steps are coalesced into ~2s batches to keep traffic light.
|
|
@@ -490,6 +501,11 @@ Bump `version` in `package.json`, merge, wait for main CI, then tag that commit
|
|
|
490
501
|
that exact version, a commit contained in `main`, and a successful `verify`
|
|
491
502
|
check. It packs and clean-installs the artifact on Node.js 20, 22, and 24 before
|
|
492
503
|
publishing, then installs the registry copy and checks its version and help.
|
|
493
|
-
A manual workflow dispatch is always a dry run and cannot publish. The
|
|
494
|
-
|
|
495
|
-
|
|
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.
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -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({
|
|
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], {
|
|
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.
|
|
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
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
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;
|