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 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.15",
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