hilos-agent 0.9.0 → 0.9.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/README.md +80 -16
- package/bin/hilos-agent.mjs +16 -6
- package/package.json +1 -1
- package/src/acp-session.mjs +69 -54
- package/src/agent-events.mjs +645 -45
- package/src/argv.mjs +61 -0
- package/src/attachments.mjs +310 -0
- package/src/claude-permissions.mjs +445 -0
- package/src/cli.mjs +56 -0
- package/src/codex-mcp-session.mjs +619 -0
- package/src/config.mjs +83 -7
- package/src/handler.mjs +914 -77
- package/src/hook.mjs +793 -108
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +3 -2
- package/src/model-resolve.mjs +180 -11
- package/src/permission-gate.mjs +269 -0
- package/src/progress-emitter.mjs +100 -5
- package/src/queue.mjs +21 -5
- package/src/redact.mjs +11 -1
- package/src/reply-bridge.mjs +847 -0
- package/src/resume.mjs +48 -11
- package/src/run.mjs +130 -4
- package/src/transcript.mjs +153 -0
package/README.md
CHANGED
|
@@ -236,46 +236,87 @@ bridge; Claude Code, Codex, Cursor, and other adapters still follow their own
|
|
|
236
236
|
CLI permission modes until their native approval hooks join the same
|
|
237
237
|
vendor-neutral hilos substrate.
|
|
238
238
|
|
|
239
|
-
## Hooks —
|
|
239
|
+
## Hooks — keep a raw Codex, Claude Code, or Cursor session in the room
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
241
|
+
Keep working in the interactive coding tool you already use. Project hooks make
|
|
242
|
+
Codex, Claude Code, or Cursor visible on the agent's live card and bind a successful
|
|
243
|
+
hilos `post_message` / `post_report` to that exact provider session. With the
|
|
244
|
+
normal `hilos-agent` process running, a person's reply in the bound hilos thread
|
|
245
|
+
becomes the next turn in the same local session and checkout.
|
|
245
246
|
|
|
246
247
|
```sh
|
|
247
248
|
# Inside the repo you want to stream:
|
|
248
249
|
npx hilos-agent hooks install
|
|
249
250
|
|
|
250
|
-
#
|
|
251
|
+
# Install only one provider, if you prefer:
|
|
252
|
+
npx hilos-agent hooks install --codex
|
|
253
|
+
npx hilos-agent hooks install --claude
|
|
254
|
+
npx hilos-agent hooks install --cursor
|
|
255
|
+
|
|
256
|
+
# Or opt in every repo on this machine:
|
|
251
257
|
npx hilos-agent hooks install --global
|
|
252
258
|
```
|
|
253
259
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
260
|
+
Claude Code and Cursor write lifecycle entries into this repo. Codex needs one
|
|
261
|
+
entry in `~/.codex/hooks.json` because `codex exec` does not run repository
|
|
262
|
+
hooks. hilos keeps that entry project-scoped with a 0600 allowlist at
|
|
263
|
+
`~/.hilos/codex-hook-scope.json`, so other repos stay untouched unless you use
|
|
264
|
+
`--global`. Launching Codex below the opted-in repository root still works, but
|
|
265
|
+
a nested repository needs its own opt-in. If the allowlist is lost or malformed,
|
|
266
|
+
new installs fail closed instead of reporting from every repo.
|
|
267
|
+
|
|
268
|
+
Any hooks you already have are preserved. A pre-existing project-level hilos
|
|
269
|
+
Codex entry is moved out, with a `.bak` beside the changed file. Codex asks you
|
|
270
|
+
to review the home-level hilos hook once; open `/hooks` and trust the entry.
|
|
271
|
+
The installer copies the dependency-free hook code into a private, versioned
|
|
272
|
+
`~/.hilos/hook-runtime` and records its absolute entrypoint. A one-off npx run
|
|
273
|
+
therefore remains usable offline; no global install or PATH change is required.
|
|
274
|
+
Preview all three blocks without writing anything:
|
|
257
275
|
|
|
258
276
|
```sh
|
|
259
277
|
npx hilos-agent hooks print
|
|
260
278
|
```
|
|
261
279
|
|
|
262
280
|
**Requirements:**
|
|
263
|
-
-
|
|
264
|
-
|
|
265
|
-
|
|
281
|
+
- For live progress from every tool event, a `~/.hilos/agent.json` with `url`
|
|
282
|
+
and the owned agent's `token`; `channelId` is needed only for the fallback
|
|
283
|
+
live card. The normal one-line `--join` flow does not need that file for
|
|
284
|
+
replies: its token stays in daemon memory, and Hilos signs each session
|
|
285
|
+
binding so the daemon can verify it before resuming local code.
|
|
286
|
+
- Keep `hilos-agent` running to pick replies up. Hooks alone still stream live
|
|
287
|
+
steps, but MCP has no server-push channel that can wake an idle local client.
|
|
266
288
|
|
|
267
289
|
**What you get:**
|
|
268
290
|
- Team members see "Editing lib/x.ts" or "Running pnpm test" on the agent's live
|
|
269
291
|
card as each tool fires — without you doing anything beyond the install.
|
|
292
|
+
- A successful hilos post binds its returned message to this provider session.
|
|
293
|
+
A person's later reply to that exact thread resumes the same session while
|
|
294
|
+
`hilos-agent` is running. Ambient messages, older thread history, and agent
|
|
295
|
+
replies do not wake it. Current workspace roles are checked at pickup: guests,
|
|
296
|
+
removed people, and unknown authors remain advisory and cannot start code.
|
|
297
|
+
- The resumed turn receives bounded thread, room, and member context. Codex also
|
|
298
|
+
gets a random loopback-only Hilos MCP URL for that turn, so it can search any
|
|
299
|
+
room its linked owner can access when that owner minted the current key,
|
|
300
|
+
without receiving the bearer token. Regenerate an older key from Connect to
|
|
301
|
+
enable that inheritance. Admin-issued keys and other people's private rooms
|
|
302
|
+
stay outside the context.
|
|
303
|
+
- The room's normal execution gate still applies; a chat-only guest room cannot
|
|
304
|
+
resume local code.
|
|
270
305
|
- Steps are coalesced into ~2s batches to keep traffic light.
|
|
271
306
|
- When a turn ends (`Stop`), the card settles to done. The next turn revives the
|
|
272
307
|
same card — one card per session, not one per turn.
|
|
273
308
|
- Up to 20 unique files touched are surfaced so reviewers can glance at the scope
|
|
274
309
|
before the report card arrives.
|
|
275
310
|
|
|
276
|
-
**Privacy:** project-level by default (only repos you opt into stream
|
|
277
|
-
|
|
278
|
-
|
|
311
|
+
**Privacy:** project-level by default (only repos you opt into stream, including
|
|
312
|
+
Codex through its local allowlist). Hook state stays in
|
|
313
|
+
`~/.hilos/hook-state` and contains session ids, checkout paths,
|
|
314
|
+
hilos message ids, an opaque server-signed binding proof, a one-way credential
|
|
315
|
+
scope after verification, progress steps, and any completed result waiting for
|
|
316
|
+
a delivery retry — never the bearer token or coding transcript. Kill
|
|
317
|
+
switches: `HILOS_HOOKS=off` pauses streaming and new bindings;
|
|
318
|
+
`HILOS_REPLY_BRIDGE=off` pauses inbound replies. The hook always exits 0 — it
|
|
319
|
+
will never interrupt or break your CLI session.
|
|
279
320
|
|
|
280
321
|
## Security
|
|
281
322
|
|
|
@@ -286,10 +327,33 @@ hilos's GitHub App only for workspace owners/admins). Want a human checkpoint
|
|
|
286
327
|
before anything is pushed? Set `"gate": true`. Keep your token in the config file
|
|
287
328
|
or `HILOS_TOKEN`, never in shared shell history.
|
|
288
329
|
|
|
330
|
+
### Run transcripts are opt-in
|
|
331
|
+
|
|
332
|
+
The room gets what a teammate needs to see: a plan, live progress, a report, a
|
|
333
|
+
PR. The coding tool's own stream is **not** part of that by default — the live
|
|
334
|
+
card is an eight-step window and nothing keeps it after the run.
|
|
335
|
+
|
|
336
|
+
Set `"uploadTranscripts": true` and the daemon, after posting its report, sends
|
|
337
|
+
the tail of that run's stream to hilos, where it becomes the same transcript a
|
|
338
|
+
hosted run gets: openable from the report card, readable only by workspace
|
|
339
|
+
members, never by guests.
|
|
340
|
+
|
|
341
|
+
It is off until you turn it on, because every other option here changes how work
|
|
342
|
+
happens on your machine and this one moves bytes off it. When on: the last ~1 MB
|
|
343
|
+
of the run, cut at a whole record; credentials redacted here before sending and
|
|
344
|
+
again on arrival; a permission-gated run records its tool calls too, not just
|
|
345
|
+
the assistant's prose; each round of an iterate appends to the previous round
|
|
346
|
+
rather than replacing it; and the upload is never able to fail your run,
|
|
347
|
+
whatever goes wrong with it.
|
|
348
|
+
|
|
349
|
+
`HILOS_UPLOAD_TRANSCRIPTS=1` turns it on, `=0` forces it off, and either
|
|
350
|
+
overrides the config file. Turning it off takes effect on the next run, with no
|
|
351
|
+
restart — and **deleting** the key counts as off, not as "leave it as it was".
|
|
352
|
+
|
|
289
353
|
## Flags
|
|
290
354
|
|
|
291
355
|
`--join <blob>` · `--channel <id>` · `--config <path>` · `--coding-cmd <cmd>` ·
|
|
292
356
|
`--chat-cmd <cmd>` · `--once` · `--backfill` · `--no-gate` · `--help`
|
|
293
357
|
|
|
294
358
|
Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`, `HILOS_ONCE=1`,
|
|
295
|
-
`HILOS_BACKFILL=1`.
|
|
359
|
+
`HILOS_BACKFILL=1`, `HILOS_UPLOAD_TRANSCRIPTS=1|0`.
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -44,7 +44,14 @@ function parseArgs(argv) {
|
|
|
44
44
|
else if (a === "--once") flags.once = true;
|
|
45
45
|
else if (a === "--backfill") flags.backfill = true;
|
|
46
46
|
else if (a === "--no-gate") flags.gate = false;
|
|
47
|
+
else if (a === "--no-reply-bridge") flags.replyBridge = false;
|
|
47
48
|
else if (a === "--global") flags.global = true;
|
|
49
|
+
else if (a === "--claude") flags.hookClient = "claude";
|
|
50
|
+
else if (a === "--codex") flags.hookClient = "codex";
|
|
51
|
+
else if (a === "--cursor") flags.hookClient = "cursor";
|
|
52
|
+
else if (a === "--vendor") flags.vendor = argv[++i];
|
|
53
|
+
else if (a === "--scope-managed") flags.scopeManaged = true;
|
|
54
|
+
else if (a === "--managed-runtime") flags.managedRuntime = true;
|
|
48
55
|
else if (a === "-h" || a === "--help") flags.help = true;
|
|
49
56
|
else if (a === "-v" || a === "--version") flags.version = true;
|
|
50
57
|
else positional.push(a);
|
|
@@ -57,10 +64,12 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
|
|
|
57
64
|
hilos-agent --join <blob> connect using a link copied from hilos
|
|
58
65
|
hilos-agent init write a starter config to ~/.hilos/agent.json
|
|
59
66
|
hilos-agent run the daemon (watch @mentions, propose diffs)
|
|
60
|
-
hilos-agent hooks install stream this repo's Claude
|
|
61
|
-
hilos
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
hilos-agent hooks install stream this repo's Codex, Claude, and Cursor
|
|
68
|
+
sessions to hilos and continue replies in the same
|
|
69
|
+
local session. Installs all three hook formats;
|
|
70
|
+
use --codex, --claude, or --cursor to choose, and
|
|
71
|
+
--global for every repo. HILOS_HOOKS=off pauses
|
|
72
|
+
streaming; HILOS_REPLY_BRIDGE=off pauses pickup.
|
|
64
73
|
|
|
65
74
|
Options:
|
|
66
75
|
--channel <id> watch only one channel (per-channel override)
|
|
@@ -78,6 +87,7 @@ Options:
|
|
|
78
87
|
--once one poll then exit (cron-friendly)
|
|
79
88
|
--backfill also act on mentions that predate startup
|
|
80
89
|
--no-gate propose only; don't wait for approval / push
|
|
90
|
+
--no-reply-bridge don't resume local sessions from replies in bound threads
|
|
81
91
|
-v, --version print the installed version
|
|
82
92
|
-h, --help this help
|
|
83
93
|
|
|
@@ -98,11 +108,11 @@ async function main() {
|
|
|
98
108
|
// fast, silent, and always exit 0, so it short-circuits before any daemon
|
|
99
109
|
// machinery.
|
|
100
110
|
if (cmd === "hook") {
|
|
101
|
-
await hookMain();
|
|
111
|
+
await hookMain({ vendor: flags.vendor || "unknown", scopeManaged: Boolean(flags.scopeManaged) });
|
|
102
112
|
return;
|
|
103
113
|
}
|
|
104
114
|
if (cmd === "hooks") {
|
|
105
|
-
hooksMain(positional[1], { global: Boolean(flags.global) });
|
|
115
|
+
hooksMain(positional[1], { global: Boolean(flags.global), client: flags.hookClient || "all" });
|
|
106
116
|
return;
|
|
107
117
|
}
|
|
108
118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
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.)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/acp-session.mjs
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
// { status, stdout, stderr, error?, sessionId?, aborted? }
|
|
18
18
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import {
|
|
20
|
+
import { createAcpEventMapper } from "./agent-events.mjs";
|
|
21
|
+
import { resolveHilosPermissionReply } from "./permission-gate.mjs";
|
|
21
22
|
|
|
22
23
|
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
23
24
|
const DEFAULT_POLL_MS = 1_000;
|
|
@@ -186,6 +187,7 @@ export function createNdjsonParser() {
|
|
|
186
187
|
* pollIntervalMs?: number,
|
|
187
188
|
* signal?: AbortSignal,
|
|
188
189
|
* onData?: (chunk: string) => void,
|
|
190
|
+
* onEvent?: (event: object) => void,
|
|
189
191
|
* requestPermission?: (request: object, context: object) => Promise<unknown>,
|
|
190
192
|
* getPermissionDecision?: (handle: unknown, context: object) => Promise<unknown>,
|
|
191
193
|
* mcpServers?: object[],
|
|
@@ -209,8 +211,11 @@ export async function runAcpSession({
|
|
|
209
211
|
pollIntervalMs = DEFAULT_POLL_MS,
|
|
210
212
|
signal,
|
|
211
213
|
onData,
|
|
214
|
+
onEvent,
|
|
212
215
|
requestPermission,
|
|
213
216
|
getPermissionDecision,
|
|
217
|
+
/** Session to continue (0778); null starts a fresh one. */
|
|
218
|
+
resumeSessionId = null,
|
|
214
219
|
mcpServers = [],
|
|
215
220
|
spawnImpl = spawn,
|
|
216
221
|
setTimer = setTimeout,
|
|
@@ -258,6 +263,7 @@ export async function runAcpSession({
|
|
|
258
263
|
const permissionTasks = new Set();
|
|
259
264
|
let currentMessageId = null;
|
|
260
265
|
let messageBuffer = "";
|
|
266
|
+
const eventMapper = createAcpEventMapper();
|
|
261
267
|
|
|
262
268
|
const emitOutput = (text) => {
|
|
263
269
|
try {
|
|
@@ -310,51 +316,21 @@ export async function runAcpSession({
|
|
|
310
316
|
const request = normalizeAcpPermissionRequest(msg.params, msg.id, vendor);
|
|
311
317
|
const receivedAt = now();
|
|
312
318
|
const deadlineAt = receivedAt + Math.max(1, timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
signal: controller.signal,
|
|
329
|
-
deadlineAt,
|
|
330
|
-
}),
|
|
331
|
-
controller.signal,
|
|
332
|
-
);
|
|
333
|
-
mapped = mapOpenCodePermissionDecision(decision);
|
|
334
|
-
if (mapped) break;
|
|
335
|
-
const waitMs = Math.min(Math.max(1, pollIntervalMs), Math.max(1, deadlineAt - now()));
|
|
336
|
-
await raceWithAbort(sleep(waitMs), controller.signal);
|
|
337
|
-
}
|
|
338
|
-
reply = mapped;
|
|
339
|
-
} catch (error) {
|
|
340
|
-
reply = "reject";
|
|
341
|
-
log?.error?.(`acp permission decision: ${error?.message ?? error}`);
|
|
342
|
-
// The agent is about to be failed closed. Settle the durable hilos card
|
|
343
|
-
// too, on a bounded one-shot call — the run signal may already be gone.
|
|
344
|
-
if (handle) {
|
|
345
|
-
try {
|
|
346
|
-
await getPermissionDecision(handle, {
|
|
347
|
-
request,
|
|
348
|
-
deadlineAt,
|
|
349
|
-
failClosed: true,
|
|
350
|
-
});
|
|
351
|
-
} catch (settlementError) {
|
|
352
|
-
log?.error?.(
|
|
353
|
-
`acp permission settlement: ${settlementError?.message ?? settlementError}`,
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
319
|
+
// The shared gate (0777): the identical raise → poll → fail-closed loop
|
|
320
|
+
// claude_code and codex now run, so no transport can drift into being the
|
|
321
|
+
// lenient one.
|
|
322
|
+
const { reply } = await resolveHilosPermissionReply({
|
|
323
|
+
request,
|
|
324
|
+
requestPermission,
|
|
325
|
+
getPermissionDecision,
|
|
326
|
+
signal: controller.signal,
|
|
327
|
+
deadlineAt,
|
|
328
|
+
pollIntervalMs,
|
|
329
|
+
sleep,
|
|
330
|
+
now,
|
|
331
|
+
log,
|
|
332
|
+
label: "acp permission",
|
|
333
|
+
});
|
|
358
334
|
const option = pickAcpPermissionOption(msg.params?.options, reply);
|
|
359
335
|
if (option && (reply !== "reject" || option.kind?.startsWith("reject"))) {
|
|
360
336
|
respond(msg.id, { outcome: { outcome: "selected", optionId: option.optionId } });
|
|
@@ -383,8 +359,18 @@ export async function runAcpSession({
|
|
|
383
359
|
messageBuffer += text;
|
|
384
360
|
return;
|
|
385
361
|
}
|
|
386
|
-
//
|
|
387
|
-
//
|
|
362
|
+
// Tool calls narrate the activity feed (0759 slice 3): the mapper's
|
|
363
|
+
// emit-once discipline turns the raw update stream into AgentEvents.
|
|
364
|
+
if (onEvent && (kind === "tool_call" || kind === "tool_call_update")) {
|
|
365
|
+
try {
|
|
366
|
+
const event = eventMapper.push(update);
|
|
367
|
+
if (event) onEvent(event);
|
|
368
|
+
} catch {
|
|
369
|
+
// Narration must never break the run.
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// Thoughts, usage, command lists: structurally received, not activity.
|
|
388
374
|
}
|
|
389
375
|
|
|
390
376
|
function handleMessage(msg) {
|
|
@@ -463,12 +449,41 @@ export async function runAcpSession({
|
|
|
463
449
|
throw new Error(`ACP agent speaks protocol ${init.protocolVersion}, expected ${PROTOCOL_VERSION}`);
|
|
464
450
|
}
|
|
465
451
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
)
|
|
470
|
-
|
|
471
|
-
|
|
452
|
+
// Resume over ACP (0778). The 0759 slice-1 exclusion — ACP runs could never
|
|
453
|
+
// be resumed runs — was deferred work, not an incompatibility: BOTH live
|
|
454
|
+
// vendors advertise `loadSession: true` in this very handshake (verified
|
|
455
|
+
// against opencode 1.18.5 and cursor-agent). So a resumed run now loads its
|
|
456
|
+
// session and keeps raising cards, instead of trading approvals for
|
|
457
|
+
// continuity. A load that fails (an id from another machine, vendor, or
|
|
458
|
+
// transport) falls back to a fresh session — never-worse, the same rule the
|
|
459
|
+
// argv `--resume` retry follows — and says so.
|
|
460
|
+
const canLoad = isObject(init) && init.agentCapabilities?.loadSession === true;
|
|
461
|
+
if (resumeSessionId && canLoad) {
|
|
462
|
+
try {
|
|
463
|
+
await raceWithAbort(
|
|
464
|
+
rpc("session/load", { sessionId: resumeSessionId, cwd, mcpServers }),
|
|
465
|
+
controller.signal,
|
|
466
|
+
);
|
|
467
|
+
sessionId = resumeSessionId;
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (controller.signal.aborted) throw error;
|
|
470
|
+
log?.error?.(
|
|
471
|
+
`acp session/load failed for ${resumeSessionId}; starting a fresh session: ${error?.message ?? error}`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
} else if (resumeSessionId) {
|
|
475
|
+
log?.error?.(
|
|
476
|
+
`acp agent does not advertise loadSession; starting a fresh session instead of resuming ${resumeSessionId}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
if (!sessionId) {
|
|
480
|
+
const session = await raceWithAbort(
|
|
481
|
+
rpc("session/new", { cwd, mcpServers }),
|
|
482
|
+
controller.signal,
|
|
483
|
+
);
|
|
484
|
+
sessionId = isObject(session) && typeof session.sessionId === "string" ? session.sessionId : null;
|
|
485
|
+
if (!sessionId) throw new Error("ACP agent created a session without an id");
|
|
486
|
+
}
|
|
472
487
|
|
|
473
488
|
const turn = await raceWithAbort(
|
|
474
489
|
rpc("session/prompt", {
|