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 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 — stream a raw Claude Code session
239
+ ## Hooks — keep a raw Codex, Claude Code, or Cursor session in the room
240
240
 
241
- Don't want to run a persistent daemon? You can still make your Claude Code CLI
242
- sessions visible to your team in real time. **Claude Code hooks** (shipped in
243
- 0448) let every tool call your session makes stream directly into your agent's
244
- live status card in hilos no daemon, no extra process.
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
- # Or stream all your Claude Code sessions, everywhere:
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
- This writes `PostToolUse`, `Stop`, and `SessionEnd` hook entries into
255
- `.claude/settings.json` (project) or `~/.claude/settings.json` (global),
256
- preserving any hooks you already have. Preview the block without writing anything:
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
- - `npm i -g hilos-agent` so the `hilos-agent hook` command resolves at hook time.
264
- - A `~/.hilos/agent.json` with `url`, `token`, and `channelId`. Generate these in
265
- the agent's **Connect via MCP** panel in hilos (same panel as `--join`).
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). Global
277
- kill switch: `HILOS_HOOKS=off`. The hook always exits 0 — it will never interrupt
278
- or break your CLI session.
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`.
@@ -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 Code sessions to your
61
- hilos channel (writes .claude/settings.json;
62
- --global for every repo). "hooks print" shows
63
- the snippet; HILOS_HOOKS=off pauses streaming.
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.0",
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": {
@@ -17,7 +17,8 @@
17
17
  // { status, stdout, stderr, error?, sessionId?, aborted? }
18
18
 
19
19
  import { spawn } from "node:child_process";
20
- import { mapOpenCodePermissionDecision } from "./opencode-permissions.mjs";
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
- let reply = "reject";
314
- let handle = null;
315
- try {
316
- if (controller.signal.aborted) throw abortError(controller.signal.reason);
317
- handle = await raceWithAbort(
318
- requestPermission(request, { signal: controller.signal, deadlineAt }),
319
- controller.signal,
320
- );
321
- let decision = handle;
322
- let mapped = mapOpenCodePermissionDecision(decision);
323
- while (!mapped) {
324
- if (now() >= deadlineAt) throw abortError("timeout");
325
- decision = await raceWithAbort(
326
- getPermissionDecision(handle, {
327
- request,
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
- // Thoughts, tool calls, usage, command lists: structurally received, not
387
- // part of slice 1's transcript (parity with the HTTP runner's text mode).
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
- const session = await raceWithAbort(
467
- rpc("session/new", { cwd, mcpServers }),
468
- controller.signal,
469
- );
470
- sessionId = isObject(session) && typeof session.sessionId === "string" ? session.sessionId : null;
471
- if (!sessionId) throw new Error("ACP agent created a session without an id");
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", {