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 +104 -0
- package/bin/hilos-agent.mjs +20 -2
- package/package.json +1 -1
- package/src/config.mjs +14 -0
- package/src/daemon.mjs +49 -0
- package/src/handler.mjs +561 -30
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +26 -2
- package/src/run.mjs +117 -11
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
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -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.
|
|
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 || "");
|