hilos-agent 0.1.16 → 0.4.0

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
@@ -87,6 +87,69 @@ hilos-agent --channel <id> # scope to one channel
87
87
  card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
88
88
  discards the branch, **Request changes** re-runs with your note (bounded rounds).
89
89
 
90
+ ## Local folders (no repo required)
91
+
92
+ A channel doesn't need a linked GitHub repo to get coding work done. Map a channel
93
+ to a plain **local folder** and the agent works in it **directly** — it edits the
94
+ files in place (no branch, no commit, no PR), then posts a report of what changed.
95
+ It's your own machine, so this is the same trust as running the CLI yourself.
96
+
97
+ ```jsonc
98
+ // hilos-agent.json
99
+ {
100
+ "folders": { "<channelId>": "/Users/you/notes-site" }
101
+ }
102
+ ```
103
+
104
+ - **Trigger** — an `@mention` in a channel with **no linked repo** but a `folders`
105
+ mapping. The same LLM router decides chat vs. code; a coding ask runs in the
106
+ folder.
107
+ - **Git folders** — if the folder is a git repo, the agent snapshots `git status`
108
+ before/after and reports the exact files it created/changed/removed plus a
109
+ `git diff --stat`. It **never** touches your index, branches, pushes, or runs
110
+ `gh`. On the report card: **Approve** keeps the changes (they're already live),
111
+ **Request changes** re-runs in place with your note (bounded by `maxRounds`),
112
+ **Reject** reverts *only what this run changed* (`git checkout` the files it
113
+ modified, delete the files it created) — your pre-existing local edits are left
114
+ untouched.
115
+ - **Non-git folders** — it still runs, but says up front it can't show a file-level
116
+ diff or auto-undo; a reject asks you to revert by hand.
117
+ - A **linked repo always wins** — the folder map is only consulted when the channel
118
+ has no repo link.
119
+ - **Folder link registration** — on startup the daemon registers each `folders`
120
+ mapping with hilos (over the `link_folder` MCP tool) so the channel shows a
121
+ folder chip — the folder's name, its full path, and which machine it lives on —
122
+ without any manual step. It's best-effort and capability-gated: older servers
123
+ that don't expose `link_folder` are skipped silently, and a failed registration
124
+ only logs a line, never blocking the daemon. Registration happens once at
125
+ startup, so a `folders` entry added while the daemon is running needs a restart
126
+ to appear.
127
+
128
+ ## Embedding the daemon
129
+
130
+ `run(cfg, opts)` is the poll loop, and it's embeddable. Beyond `handler`/`log` it
131
+ accepts:
132
+
133
+ ```js
134
+ import { run } from "hilos-agent/src/run.mjs";
135
+
136
+ const controller = new AbortController();
137
+ await run(cfg, {
138
+ signal: controller.signal, // abort → interrupts the poll sleep, cancels the
139
+ // active job, and returns cleanly
140
+ onEvent: (e) => { // lifecycle events for a host UI (wrapped in
141
+ // e.type: "status" | "task-start" | "task-done" | "task-error"
142
+ console.log(e); // try/catch — a bad listener can't crash the loop)
143
+ },
144
+ });
145
+ // later: controller.abort(); // run() resolves once the active job tears down
146
+ ```
147
+
148
+ Event shapes: `{ type: "status", text }`, `{ type: "task-start", channelId,
149
+ messageId, text }`, `{ type: "task-done", channelId, status }`, and
150
+ `{ type: "task-error", channelId, error }`. Both options are optional and fully
151
+ backward compatible — omit them and the CLI behaves exactly as before.
152
+
90
153
  ## Model & permissions
91
154
 
92
155
  You don't have to hand-write `codingCmd`: the agent's **Connect via MCP** panel in
@@ -125,6 +188,47 @@ The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
125
188
  you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
126
189
  before anything is pushed.
127
190
 
191
+ ## Hooks — stream a raw Claude Code session
192
+
193
+ Don't want to run a persistent daemon? You can still make your Claude Code CLI
194
+ sessions visible to your team in real time. **Claude Code hooks** (shipped in
195
+ 0448) let every tool call your session makes stream directly into your agent's
196
+ live status card in hilos — no daemon, no extra process.
197
+
198
+ ```sh
199
+ # Inside the repo you want to stream:
200
+ npx hilos-agent hooks install
201
+
202
+ # Or stream all your Claude Code sessions, everywhere:
203
+ npx hilos-agent hooks install --global
204
+ ```
205
+
206
+ This writes `PostToolUse`, `Stop`, and `SessionEnd` hook entries into
207
+ `.claude/settings.json` (project) or `~/.claude/settings.json` (global),
208
+ preserving any hooks you already have. Preview the block without writing anything:
209
+
210
+ ```sh
211
+ npx hilos-agent hooks print
212
+ ```
213
+
214
+ **Requirements:**
215
+ - `npm i -g hilos-agent` so the `hilos-agent hook` command resolves at hook time.
216
+ - A `~/.hilos/agent.json` with `url`, `token`, and `channelId`. Generate these in
217
+ the agent's **Connect via MCP** panel in hilos (same panel as `--join`).
218
+
219
+ **What you get:**
220
+ - Team members see "Editing lib/x.ts" or "Running pnpm test" on the agent's live
221
+ card as each tool fires — without you doing anything beyond the install.
222
+ - Steps are coalesced into ~2s batches to keep traffic light.
223
+ - When a turn ends (`Stop`), the card settles to done. The next turn revives the
224
+ same card — one card per session, not one per turn.
225
+ - Up to 20 unique files touched are surfaced so reviewers can glance at the scope
226
+ before the report card arrives.
227
+
228
+ **Privacy:** project-level by default (only repos you opt into stream). Global
229
+ kill switch: `HILOS_HOOKS=off`. The hook always exits 0 — it will never interrupt
230
+ or break your CLI session.
231
+
128
232
  ## Security
129
233
 
130
234
  The daemon runs a coding agent that can execute code in your repo — exactly as if
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "../src/config.mjs";
20
20
  import { run } from "../src/run.mjs";
21
+ import { hookMain, hooksMain } from "../src/hook.mjs";
21
22
 
22
23
  function parseArgs(argv) {
23
24
  const flags = {};
@@ -34,10 +35,11 @@ function parseArgs(argv) {
34
35
  else if (a === "--once") flags.once = true;
35
36
  else if (a === "--backfill") flags.backfill = true;
36
37
  else if (a === "--no-gate") flags.gate = false;
38
+ else if (a === "--global") flags.global = true;
37
39
  else if (a === "-h" || a === "--help") flags.help = true;
38
40
  else positional.push(a);
39
41
  }
40
- return { cmd: positional[0] || "run", flags };
42
+ return { cmd: positional[0] || "run", flags, positional };
41
43
  }
42
44
 
43
45
  const HELP = `hilos-agent — your coding agent as a teammate in hilos
@@ -45,6 +47,10 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
45
47
  hilos-agent --join <blob> connect using a link copied from hilos
46
48
  hilos-agent init write a starter config to ~/.hilos/agent.json
47
49
  hilos-agent run the daemon (watch @mentions, propose diffs)
50
+ hilos-agent hooks install stream this repo's Claude Code sessions to your
51
+ hilos channel (writes .claude/settings.json;
52
+ --global for every repo). "hooks print" shows
53
+ the snippet; HILOS_HOOKS=off pauses streaming.
48
54
 
49
55
  Options:
50
56
  --channel <id> watch only one channel (per-channel override)
@@ -60,12 +66,24 @@ Options:
60
66
  Docs: https://hilos.sh · https://www.npmjs.com/package/hilos-agent`;
61
67
 
62
68
  async function main() {
63
- const { cmd, flags } = parseArgs(process.argv.slice(2));
69
+ const { cmd, flags, positional } = parseArgs(process.argv.slice(2));
64
70
  if (flags.help || cmd === "help") {
65
71
  console.log(HELP);
66
72
  return;
67
73
  }
68
74
 
75
+ // `hook` is invoked BY Claude Code on every tool call (0448) — it must be
76
+ // fast, silent, and always exit 0, so it short-circuits before any daemon
77
+ // machinery.
78
+ if (cmd === "hook") {
79
+ await hookMain();
80
+ return;
81
+ }
82
+ if (cmd === "hooks") {
83
+ hooksMain(positional[1], { global: Boolean(flags.global) });
84
+ return;
85
+ }
86
+
69
87
  const joinPayload = flags.join ? decodeJoin(flags.join) : undefined;
70
88
  if (flags.join && !joinPayload) {
71
89
  console.error("That --join link is invalid. Re-copy it from hilos.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.16",
3
+ "version": "0.4.0",
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/config.mjs CHANGED
@@ -43,6 +43,11 @@ const DEFAULTS = {
43
43
  token: "",
44
44
  channelId: "", // when set, watch only this channel (the per-channel override)
45
45
  repos: {},
46
+ // Local-folder mode (0322): map a channelId → an absolute folder path so a
47
+ // channel with NO linked GitHub repo can still get coding work done in a plain
48
+ // folder on this machine. Changes apply directly (no branch/PR). Live-reloadable
49
+ // like `repos`. Shape: { "<channelId>": "/abs/path" }.
50
+ folders: {},
46
51
  // acceptEdits lets the CLI make file edits without prompting (bias to action);
47
52
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
48
53
  // want a stricter (or `--dangerously-skip-permissions`) command.
@@ -111,6 +116,8 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
111
116
  }
112
117
  for (const [k, v] of Object.entries(flags)) if (v !== undefined) merged[k] = v;
113
118
  merged.repos = { ...DEFAULTS.repos, ...(file.repos || {}) };
119
+ // Local-folder map, merged like `repos` (an object, not a scalar overlay).
120
+ merged.folders = { ...DEFAULTS.folders, ...(file.folders || {}) };
114
121
  // Remember where the file lives so reloadConfig can re-read it live.
115
122
  merged.configPath = findConfigPath(flags.config) || null;
116
123
  return merged;
@@ -135,6 +142,10 @@ const LIVE_FIELDS = [
135
142
  // NOT queueConcurrency: the queue is built once at startup, so it can't change
136
143
  // live. queueAcks IS live (intake() reads it per-mention from liveCfg).
137
144
  "queueAcks",
145
+ // folders is an object map; it's listed here for documentation, but the actual
146
+ // live merge (with DEFAULTS) happens in the special-cased block below, exactly
147
+ // like `repos`, so a partial edit doesn't drop the defaults.
148
+ "folders",
138
149
  ];
139
150
 
140
151
  /**
@@ -160,6 +171,9 @@ export function reloadConfig(prev) {
160
171
  if (next.heartbeatMs > 0) next.heartbeatMs = Math.max(15000, Number(next.heartbeatMs) || 0);
161
172
  next.progressMs = Math.max(750, Number(next.progressMs) || 2000);
162
173
  if (file.repos) next.repos = { ...DEFAULTS.repos, ...file.repos };
174
+ // Merge the folder map like repos (overriding the raw scalar-loop assignment
175
+ // above with a proper DEFAULTS-merged object).
176
+ if (file.folders) next.folders = { ...DEFAULTS.folders, ...file.folders };
163
177
  return next;
164
178
  }
165
179
 
package/src/daemon.mjs CHANGED
@@ -31,6 +31,55 @@ export function resolveRepoPath(config, repoFullName) {
31
31
  return (config && config.repos && config.repos[repoFullName]) || null;
32
32
  }
33
33
 
34
+ /** Local folder path for a channel from config (folder mode, 0322), or null.
35
+ * Mirrors resolveRepoPath — a channel with no linked repo can still map to a
36
+ * plain folder on this machine where the agent works directly. */
37
+ export function resolveFolderPath(config, channelId) {
38
+ return (config && config.folders && config.folders[channelId]) || null;
39
+ }
40
+
41
+ /** Parse `git status --porcelain` (v1) into a Map(path → "XY" status code).
42
+ * Handles renames ("old -> new" → keeps the new name) and quoted paths. */
43
+ function parsePorcelain(text) {
44
+ const map = new Map();
45
+ for (const raw of String(text || "").split("\n")) {
46
+ if (!raw.trim()) continue;
47
+ const code = raw.slice(0, 2); // two status columns
48
+ let path = raw.slice(3); // skip the single separating space
49
+ const arrow = path.indexOf(" -> ");
50
+ if (arrow >= 0) path = path.slice(arrow + 4); // a rename reports old -> new
51
+ path = path.replace(/^"(.*)"$/, "$1"); // unquote paths with special chars
52
+ if (path) map.set(path, code);
53
+ }
54
+ return map;
55
+ }
56
+
57
+ /**
58
+ * What changed between two `git status --porcelain` snapshots taken before and
59
+ * after a run: files the run created (new/untracked/added), modified, or deleted.
60
+ * Pure + unit-testable. A path whose status is identical in both snapshots is a
61
+ * PRE-EXISTING local change (dirty before the run) and is excluded. Returns
62
+ * `{ modified, created, deleted }`, each a sorted string[].
63
+ */
64
+ export function diffStatusSets(before, after) {
65
+ const b = parsePorcelain(before);
66
+ const a = parsePorcelain(after);
67
+ const created = [];
68
+ const modified = [];
69
+ const deleted = [];
70
+ for (const [path, code] of a) {
71
+ if (b.get(path) === code) continue; // unchanged since before the run
72
+ const c = code.trim();
73
+ if (code.includes("?") || c === "A" || c === "AM") created.push(path);
74
+ else if (c.startsWith("D")) deleted.push(path);
75
+ else modified.push(path);
76
+ }
77
+ created.sort();
78
+ modified.sort();
79
+ deleted.sort();
80
+ return { modified, created, deleted };
81
+ }
82
+
34
83
  /** Parse `git diff --shortstat` output into counts. */
35
84
  export function parseShortstat(s) {
36
85
  const files = /(\d+) files? changed/.exec(s || "");