hilos-agent 0.1.15 → 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/agent-events.mjs +371 -0
- package/src/config.mjs +25 -0
- package/src/daemon.mjs +67 -0
- package/src/followup.mjs +161 -0
- package/src/handler.mjs +1351 -70
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +95 -0
- package/src/progress-emitter.mjs +270 -0
- package/src/resume.mjs +143 -0
- package/src/review.mjs +237 -0
- package/src/run.mjs +133 -12
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": {
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Normalized vendor stream parser (0272). The KEYSTONE of the "Agents feel
|
|
2
|
+
// alive" epic: coding CLIs each narrate their work in a different, unstable
|
|
3
|
+
// wire format (Claude Code emits `--output-format stream-json` NDJSON, Codex
|
|
4
|
+
// emits `--json` item events, Cursor emits unstructured text). This module turns
|
|
5
|
+
// any of them into ONE small, typed `AgentEvent` stream the UI can render as a
|
|
6
|
+
// live "what the agent is doing right now" card.
|
|
7
|
+
//
|
|
8
|
+
// Design rules that make this safe to point at an untrusted, evolving CLI:
|
|
9
|
+
// - PURE + dependency-free (node builtins only) so it stands alone and is
|
|
10
|
+
// unit-tested without a sandbox — mirror of the .mjs siblings' style.
|
|
11
|
+
// - NEVER throws. A vendor changing its format, a non-JSON log line, or an
|
|
12
|
+
// unknown event type must DEGRADE (emit nothing), never crash a run.
|
|
13
|
+
// - Every string that reaches the UI is sanitized (token-shaped secrets
|
|
14
|
+
// stripped, length capped) — a chatty CLI can echo a `Bearer …` header.
|
|
15
|
+
//
|
|
16
|
+
// Claude shapes here MATCH `lib/hosted-worklog.ts` / `lib/hosted-agent.ts`
|
|
17
|
+
// (`formatClaudeEvent`, `parseStreamResult`) so ticket 0274 can swap the daemon's
|
|
18
|
+
// hand-rolled `lastLine` capture (see handler.mjs onData ~:787) for this parser
|
|
19
|
+
// with zero behavior change. NOTHING imports this yet except the test — wiring is
|
|
20
|
+
// 0274.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} AgentEvent
|
|
24
|
+
* A normalized, render-ready step. Exactly one of these per meaningful thing the
|
|
25
|
+
* coding agent did. Small on purpose — the LiveRunCard maps `t` to a label.
|
|
26
|
+
* @property {'phase'|'edit'|'run'|'read'|'think'|'note'|'session'|'result'} t
|
|
27
|
+
* - 'session' → { sessionId } the CLI's resumable session id (init only)
|
|
28
|
+
* - 'edit' → { path } wrote/edited a file
|
|
29
|
+
* - 'read' → { path } read a file
|
|
30
|
+
* - 'run' → { cmd } ran a shell command
|
|
31
|
+
* - 'note' → { text } the agent's narration / thinking (think→note)
|
|
32
|
+
* - 'result' → { ok, summary? } the run finished (ok=false on error)
|
|
33
|
+
* - 'phase' → { name } reserved lifecycle marker (unused by v1
|
|
34
|
+
* parsers; kept so 0274 can add start/end
|
|
35
|
+
* phases without widening the type)
|
|
36
|
+
* @property {string} [sessionId]
|
|
37
|
+
* @property {string} [path]
|
|
38
|
+
* @property {string} [cmd]
|
|
39
|
+
* @property {string} [text]
|
|
40
|
+
* @property {string} [name]
|
|
41
|
+
* @property {boolean} [ok]
|
|
42
|
+
* @property {string} [summary]
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// --- Security: sanitize everything that reaches the UI ---------------------
|
|
46
|
+
|
|
47
|
+
const MAX_LEN = 300;
|
|
48
|
+
const REDACTED = "[redacted]";
|
|
49
|
+
|
|
50
|
+
// Token-shaped strings a CLI might echo (auth headers, keys it was handed, hex/
|
|
51
|
+
// base64 blobs). Order matters — specific prefixes before the generic blobs so a
|
|
52
|
+
// `ghp_…` is labelled, not swallowed by the base64 rule.
|
|
53
|
+
const SECRET_PATTERNS = [
|
|
54
|
+
/\bBearer\s+[A-Za-z0-9._\-]+/gi, // "Bearer <token>"
|
|
55
|
+
/\bhilos_[A-Za-z0-9]{8,}/g, // hilos MCP bearer tokens
|
|
56
|
+
/\bsbp_[A-Za-z0-9]{16,}/g, // Supabase access/management tokens (SUPABASE_ACCESS_TOKEN)
|
|
57
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g, // fine-grained GitHub PAT
|
|
58
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g, // ghp_/gho_/ghu_/ghs_/ghr_ GitHub tokens
|
|
59
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
|
|
60
|
+
/\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI/Anthropic-style secret keys
|
|
61
|
+
/\b[A-Fa-f0-9]{32,}\b/g, // long hex blobs
|
|
62
|
+
/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, // long base64 blobs
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strip token-shaped secrets and cap length. Exported so the server (0274+) can
|
|
67
|
+
* reuse the exact same redaction on anything it echoes. Never throws; non-string
|
|
68
|
+
* input → "".
|
|
69
|
+
* @param {unknown} value
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function sanitizeText(value) {
|
|
73
|
+
if (typeof value !== "string") return "";
|
|
74
|
+
let out = value;
|
|
75
|
+
for (const re of SECRET_PATTERNS) out = out.replace(re, REDACTED);
|
|
76
|
+
if (out.length > MAX_LEN) out = out.slice(0, MAX_LEN - 1) + "…";
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Line parsing per vendor ----------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** JSON.parse that returns null instead of throwing on a non-JSON line. */
|
|
83
|
+
function tryParse(line) {
|
|
84
|
+
const trimmed = String(line || "").trim();
|
|
85
|
+
if (!trimmed) return null;
|
|
86
|
+
try {
|
|
87
|
+
const obj = JSON.parse(trimmed);
|
|
88
|
+
return obj && typeof obj === "object" ? obj : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null; // log noise / partial line / plain text → ignore
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Map a Claude/agent tool name to a normalized event kind (or null to skip). */
|
|
95
|
+
function toolKind(name) {
|
|
96
|
+
switch (name) {
|
|
97
|
+
case "Edit":
|
|
98
|
+
case "MultiEdit":
|
|
99
|
+
case "Write":
|
|
100
|
+
case "NotebookEdit":
|
|
101
|
+
return "edit";
|
|
102
|
+
case "Read":
|
|
103
|
+
return "read";
|
|
104
|
+
case "Bash":
|
|
105
|
+
return "run";
|
|
106
|
+
default:
|
|
107
|
+
// Grep/Glob/WebFetch/Task/etc. carry less "alive" signal; ignore in v1 so
|
|
108
|
+
// an unknown tool can never crash the parser. 0274 may promote some.
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One assistant content block → an AgentEvent, or null. */
|
|
114
|
+
function blockToEvent(block) {
|
|
115
|
+
if (!block || typeof block !== "object") return null;
|
|
116
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
117
|
+
const text = sanitizeText(block.text.replace(/\s+/g, " ").trim());
|
|
118
|
+
return text ? { t: "note", text } : null;
|
|
119
|
+
}
|
|
120
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
121
|
+
const kind = toolKind(block.name);
|
|
122
|
+
if (!kind) return null;
|
|
123
|
+
const input = block.input && typeof block.input === "object" ? block.input : {};
|
|
124
|
+
if (kind === "run") {
|
|
125
|
+
const cmd = typeof input.command === "string" ? input.command.replace(/\s+/g, " ").trim() : "";
|
|
126
|
+
return { t: "run", cmd: sanitizeText(cmd) };
|
|
127
|
+
}
|
|
128
|
+
const raw = input.file_path ?? input.path ?? input.notebook_path;
|
|
129
|
+
const path = typeof raw === "string" ? sanitizeText(raw) : "";
|
|
130
|
+
return { t: kind, path };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse ONE Claude Code `stream-json` NDJSON line → AgentEvent[]. Shapes mirror
|
|
137
|
+
* `lib/hosted-agent.ts`: system init (session_id), assistant turns (text +
|
|
138
|
+
* tool_use), and the final result. Everything else (user tool_result, unknown
|
|
139
|
+
* types) → [].
|
|
140
|
+
*/
|
|
141
|
+
function parseClaudeLine(line) {
|
|
142
|
+
const obj = tryParse(line);
|
|
143
|
+
if (!obj) return [];
|
|
144
|
+
if (obj.type === "system" && typeof obj.session_id === "string") {
|
|
145
|
+
return [{ t: "session", sessionId: sanitizeText(obj.session_id) }];
|
|
146
|
+
}
|
|
147
|
+
if (obj.type === "result") {
|
|
148
|
+
const ok = obj.is_error !== true && obj.subtype !== "error";
|
|
149
|
+
const summary = typeof obj.result === "string" ? sanitizeText(obj.result) : undefined;
|
|
150
|
+
return [summary ? { t: "result", ok, summary } : { t: "result", ok }];
|
|
151
|
+
}
|
|
152
|
+
if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const block of obj.message.content) {
|
|
155
|
+
const ev = blockToEvent(block);
|
|
156
|
+
if (ev) out.push(ev);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse ONE Codex `--json` item line → AgentEvent[]. Best-effort / experimental:
|
|
165
|
+
* Codex's format is younger and less documented, so we read the kind from either
|
|
166
|
+
* `item.type` (the `{type:"item.completed", item:{…}}` envelope) or a bare
|
|
167
|
+
* top-level `type`, and pull fields from whichever object carries them. Unknown
|
|
168
|
+
* kinds → [].
|
|
169
|
+
*/
|
|
170
|
+
function parseCodexLine(line) {
|
|
171
|
+
const obj = tryParse(line);
|
|
172
|
+
if (!obj) return [];
|
|
173
|
+
const item = obj.item && typeof obj.item === "object" ? obj.item : obj;
|
|
174
|
+
const kind = item.type ?? obj.type;
|
|
175
|
+
switch (kind) {
|
|
176
|
+
case "command_execution": {
|
|
177
|
+
const raw = item.command ?? item.cmd;
|
|
178
|
+
const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
|
|
179
|
+
return cmd ? [{ t: "run", cmd: sanitizeText(cmd) }] : [];
|
|
180
|
+
}
|
|
181
|
+
case "file_change": {
|
|
182
|
+
const changes = Array.isArray(item.changes)
|
|
183
|
+
? item.changes
|
|
184
|
+
: item.path
|
|
185
|
+
? [{ path: item.path }]
|
|
186
|
+
: [];
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const c of changes) {
|
|
189
|
+
const p = c && (c.path ?? c.file_path);
|
|
190
|
+
if (typeof p === "string" && p) out.push({ t: "edit", path: sanitizeText(p) });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
case "agent_message": {
|
|
195
|
+
const raw = item.text ?? item.message;
|
|
196
|
+
const text = typeof raw === "string" ? sanitizeText(raw.replace(/\s+/g, " ").trim()) : "";
|
|
197
|
+
return text ? [{ t: "note", text }] : [];
|
|
198
|
+
}
|
|
199
|
+
case "task_complete":
|
|
200
|
+
case "turn.completed": {
|
|
201
|
+
const raw = item.text ?? item.message ?? obj.text;
|
|
202
|
+
const summary = typeof raw === "string" ? sanitizeText(raw) : undefined;
|
|
203
|
+
return [summary ? { t: "result", ok: true, summary } : { t: "result", ok: true }];
|
|
204
|
+
}
|
|
205
|
+
default:
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** claude / claude_code / claude-code all mean the Claude parser. */
|
|
211
|
+
function normalizeVendor(vendor) {
|
|
212
|
+
const v = String(vendor || "").toLowerCase();
|
|
213
|
+
if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
|
|
214
|
+
if (v === "codex") return "codex";
|
|
215
|
+
return "cursor"; // cursor + ANY unknown vendor → lastLine fallback
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- Streaming parsers -----------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The daemon pushes raw stdout chunks (see handler.mjs onData ~:787) that can
|
|
222
|
+
* split a JSON object mid-line. A structured parser buffers the partial tail and
|
|
223
|
+
* only parses COMPLETE lines; flush() drains a trailing unterminated line.
|
|
224
|
+
* @param {(line: string) => AgentEvent[]} parseLine
|
|
225
|
+
*/
|
|
226
|
+
function makeLineBufferedParser(parseLine) {
|
|
227
|
+
let buf = "";
|
|
228
|
+
return {
|
|
229
|
+
/** @param {string} chunk @returns {AgentEvent[]} */
|
|
230
|
+
push(chunk) {
|
|
231
|
+
buf += String(chunk ?? "");
|
|
232
|
+
const out = [];
|
|
233
|
+
let nl;
|
|
234
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
235
|
+
const line = buf.slice(0, nl);
|
|
236
|
+
buf = buf.slice(nl + 1);
|
|
237
|
+
for (const ev of parseLine(line)) out.push(ev);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
},
|
|
241
|
+
/** @returns {AgentEvent[]} */
|
|
242
|
+
flush() {
|
|
243
|
+
const out = [];
|
|
244
|
+
if (buf.trim()) for (const ev of parseLine(buf)) out.push(ev);
|
|
245
|
+
buf = "";
|
|
246
|
+
return out;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fallback for Cursor and any vendor with no structured stream. There's nothing
|
|
253
|
+
* to parse, so we only remember the last non-empty line seen — matching exactly
|
|
254
|
+
* what the daemon does today (handler.mjs onData) so we NEVER regress below it —
|
|
255
|
+
* and emit at most one sparse note on flush.
|
|
256
|
+
*/
|
|
257
|
+
function makeCursorParser() {
|
|
258
|
+
let lastLine = "";
|
|
259
|
+
return {
|
|
260
|
+
/** @returns {AgentEvent[]} */
|
|
261
|
+
push(chunk) {
|
|
262
|
+
const lines = String(chunk ?? "")
|
|
263
|
+
.split("\n")
|
|
264
|
+
.map((s) => s.trim())
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
if (lines.length) lastLine = lines[lines.length - 1];
|
|
267
|
+
return []; // no structured events mid-stream
|
|
268
|
+
},
|
|
269
|
+
/** @returns {AgentEvent[]} */
|
|
270
|
+
flush() {
|
|
271
|
+
if (!lastLine) return [];
|
|
272
|
+
const text = sanitizeText(lastLine);
|
|
273
|
+
lastLine = "";
|
|
274
|
+
return text ? [{ t: "note", text }] : [];
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Build a stateful stream parser for `vendor`.
|
|
281
|
+
* @param {'claude'|'claude_code'|'codex'|'cursor'|string} vendor
|
|
282
|
+
* @returns {{ push: (chunk: string) => AgentEvent[], flush: () => AgentEvent[] }}
|
|
283
|
+
*/
|
|
284
|
+
export function makeStreamParser(vendor) {
|
|
285
|
+
const v = normalizeVendor(vendor);
|
|
286
|
+
if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
|
|
287
|
+
if (v === "codex") return makeLineBufferedParser(parseCodexLine);
|
|
288
|
+
return makeCursorParser();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- Human step labels (what the LiveRunCard shows) ------------------------
|
|
292
|
+
|
|
293
|
+
/** Describe a shell command in plain words (design rule: no emoji, calm copy). */
|
|
294
|
+
function describeRun(cmd) {
|
|
295
|
+
const c = String(cmd || "").trim();
|
|
296
|
+
if (!c) return "Running a command";
|
|
297
|
+
if (/\b(test|tests)\b|vitest|jest|pytest|\bgo test\b/i.test(c)) return "Running tests";
|
|
298
|
+
const short = c.length > 60 ? c.slice(0, 59) + "…" : c;
|
|
299
|
+
return `Running ${short}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One AgentEvent → a human step label, or null for events that aren't shown as
|
|
304
|
+
* steps (session/phase are metadata). Pure.
|
|
305
|
+
* @param {AgentEvent} event
|
|
306
|
+
* @returns {string | null}
|
|
307
|
+
*/
|
|
308
|
+
export function stepLabel(event) {
|
|
309
|
+
switch (event && event.t) {
|
|
310
|
+
case "edit":
|
|
311
|
+
return event.path ? `Editing ${event.path}` : "Editing files";
|
|
312
|
+
case "read":
|
|
313
|
+
return event.path ? `Reading ${event.path}` : "Reading files";
|
|
314
|
+
case "run":
|
|
315
|
+
return describeRun(event.cmd);
|
|
316
|
+
case "note":
|
|
317
|
+
return event.text ? event.text : null;
|
|
318
|
+
case "result":
|
|
319
|
+
return event.ok ? "Done" : "Finished with errors";
|
|
320
|
+
default:
|
|
321
|
+
return null; // session / phase / unknown → not a step
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A bounded ring buffer of the most recent human step labels. Coalesces
|
|
327
|
+
* sequential edits to the SAME file into one step (a run that touches one file
|
|
328
|
+
* ten times reads as one "Editing …" line) and drops immediate duplicate
|
|
329
|
+
* labels. `limit` steps kept (default 8) — the card shows a short rolling window.
|
|
330
|
+
* @param {number} [limit]
|
|
331
|
+
*/
|
|
332
|
+
export function createStepRing(limit = 8) {
|
|
333
|
+
const steps = [];
|
|
334
|
+
let lastEditPath = null; // for collapsing consecutive same-file edits
|
|
335
|
+
|
|
336
|
+
/** @param {AgentEvent} event */
|
|
337
|
+
function push(event) {
|
|
338
|
+
const label = stepLabel(event);
|
|
339
|
+
if (!label) return;
|
|
340
|
+
if (event.t === "edit") {
|
|
341
|
+
if (lastEditPath === event.path) return; // same file again → collapse
|
|
342
|
+
lastEditPath = event.path;
|
|
343
|
+
} else {
|
|
344
|
+
lastEditPath = null; // any other step breaks an edit run
|
|
345
|
+
}
|
|
346
|
+
if (steps.length && steps[steps.length - 1] === label) return; // dup guard
|
|
347
|
+
steps.push(label);
|
|
348
|
+
if (steps.length > limit) steps.shift();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
push,
|
|
353
|
+
/** @returns {string[]} */
|
|
354
|
+
labels: () => steps.slice(),
|
|
355
|
+
/** @returns {string | null} */
|
|
356
|
+
latest: () => (steps.length ? steps[steps.length - 1] : null),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Fold a batch of events through a fresh ring → the coalesced step labels. Pure
|
|
362
|
+
* convenience over createStepRing for callers that have the whole list.
|
|
363
|
+
* @param {AgentEvent[]} events
|
|
364
|
+
* @param {number} [limit]
|
|
365
|
+
* @returns {string[]}
|
|
366
|
+
*/
|
|
367
|
+
export function summarizeSteps(events, limit = 8) {
|
|
368
|
+
const ring = createStepRing(limit);
|
|
369
|
+
for (const ev of events || []) ring.push(ev);
|
|
370
|
+
return ring.labels();
|
|
371
|
+
}
|
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.
|
|
@@ -63,6 +68,11 @@ const DEFAULTS = {
|
|
|
63
68
|
// spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
|
|
64
69
|
// UPDATEs. (Distinct from cli.mjs's 15s LOCAL terminal heartbeat.)
|
|
65
70
|
heartbeatMs: 180000,
|
|
71
|
+
// Live-progress throttle (0274): when the server supports post_progress, the
|
|
72
|
+
// code run streams a coalesced "what I'm doing right now" card instead of the
|
|
73
|
+
// 3-min heartbeat. This caps how often it pushes an UPDATE (leading+trailing).
|
|
74
|
+
// Clamped to >=750ms so a misconfig can't hammer realtime.
|
|
75
|
+
progressMs: 2000,
|
|
66
76
|
// Cap a chat reply / plan-ack so a stalled model can't dead-air the channel;
|
|
67
77
|
// on timeout we post an honest "taking longer than expected" line.
|
|
68
78
|
chatTimeoutMs: 90000,
|
|
@@ -88,6 +98,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
88
98
|
codingCmd: process.env.CODING_CMD,
|
|
89
99
|
chatCmd: process.env.HILOS_CHAT_CMD,
|
|
90
100
|
heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
|
|
101
|
+
progressMs: process.env.HILOS_PROGRESS_MS ? Number(process.env.HILOS_PROGRESS_MS) : undefined,
|
|
91
102
|
chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
|
|
92
103
|
backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
|
|
93
104
|
once: process.env.HILOS_ONCE === "1" ? true : undefined,
|
|
@@ -96,6 +107,8 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
96
107
|
for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
|
|
97
108
|
// Clamp the chat heartbeat: 0 (off) stays off, otherwise never below 15s.
|
|
98
109
|
if (merged.heartbeatMs > 0) merged.heartbeatMs = Math.max(15000, Number(merged.heartbeatMs) || 0);
|
|
110
|
+
// Clamp the live-progress throttle to a >=750ms floor (a bad value → the default).
|
|
111
|
+
merged.progressMs = Math.max(750, Number(merged.progressMs) || 2000);
|
|
99
112
|
if (joinPayload) {
|
|
100
113
|
merged.url = joinPayload.url;
|
|
101
114
|
merged.token = joinPayload.token;
|
|
@@ -103,6 +116,8 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
103
116
|
}
|
|
104
117
|
for (const [k, v] of Object.entries(flags)) if (v !== undefined) merged[k] = v;
|
|
105
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 || {}) };
|
|
106
121
|
// Remember where the file lives so reloadConfig can re-read it live.
|
|
107
122
|
merged.configPath = findConfigPath(flags.config) || null;
|
|
108
123
|
return merged;
|
|
@@ -122,10 +137,15 @@ const LIVE_FIELDS = [
|
|
|
122
137
|
"decisionTimeoutMs",
|
|
123
138
|
"decisionPollMs",
|
|
124
139
|
"heartbeatMs",
|
|
140
|
+
"progressMs",
|
|
125
141
|
"chatTimeoutMs",
|
|
126
142
|
// NOT queueConcurrency: the queue is built once at startup, so it can't change
|
|
127
143
|
// live. queueAcks IS live (intake() reads it per-mention from liveCfg).
|
|
128
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",
|
|
129
149
|
];
|
|
130
150
|
|
|
131
151
|
/**
|
|
@@ -146,9 +166,14 @@ export function reloadConfig(prev) {
|
|
|
146
166
|
if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
|
|
147
167
|
if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
|
|
148
168
|
if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
|
|
169
|
+
if (process.env.HILOS_PROGRESS_MS) next.progressMs = Number(process.env.HILOS_PROGRESS_MS);
|
|
149
170
|
if (process.env.HILOS_CHAT_TIMEOUT_MS) next.chatTimeoutMs = Number(process.env.HILOS_CHAT_TIMEOUT_MS);
|
|
150
171
|
if (next.heartbeatMs > 0) next.heartbeatMs = Math.max(15000, Number(next.heartbeatMs) || 0);
|
|
172
|
+
next.progressMs = Math.max(750, Number(next.progressMs) || 2000);
|
|
151
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 };
|
|
152
177
|
return next;
|
|
153
178
|
}
|
|
154
179
|
|