hilos-agent 0.1.16 → 0.5.1

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
@@ -1,6 +1,6 @@
1
1
  # hilos-agent
2
2
 
3
- Run **your own** coding agent — Claude Code, Codex, Cursor, or any command — as
3
+ Run **your own** coding agent — Claude Code, Codex, Cursor, Hermes, or any command — as
4
4
  an autonomous teammate inside a [hilos](https://hilos.sh) channel.
5
5
 
6
6
  It connects to hilos over MCP, watches for `@mentions` of your agent in a
@@ -42,8 +42,8 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
42
42
  "url": "https://hilos.sh/api/mcp",
43
43
  "token": "mgo_…",
44
44
  "repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
45
- "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent", any command
46
- "chatCmd": "claude -p --model claude-haiku-4-5", // FAST command for chat replies + the plan-ack (set if your stack isn't Claude)
45
+ "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text", "agy -p", any command
46
+ "chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
47
47
  "defaultBranch": "main",
48
48
  "gate": false, // default: open a PR directly. true = approve-before-push
49
49
  "heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
@@ -60,7 +60,8 @@ then edits it in place with elapsed time + the CLI's latest line — so the chan
60
60
  shows it's alive without thread spam. When the run ends, that message is retired
61
61
  to a short "done" line. A run that **times out or errors** says so honestly (with
62
62
  a stderr tail) instead of claiming "no changes". Chat replies use the faster
63
- `chatCmd` (default Haiku; falls back to `codingCmd` if unset) bounded by
63
+ `chatCmd` (when unset, derived from `codingCmd`'s tool a Claude daemon chats
64
+ with Haiku, a Codex daemon with `codex exec`, and so on) bounded by
64
65
  `chatTimeoutMs`. The responsive surface needs a hilos server new enough to expose
65
66
  `edit_message`; older servers just skip the live edits.
66
67
 
@@ -87,6 +88,89 @@ hilos-agent --channel <id> # scope to one channel
87
88
  card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
88
89
  discards the branch, **Request changes** re-runs with your note (bounded rounds).
89
90
 
91
+ ## Local folders (no repo required)
92
+
93
+ A channel doesn't need a linked GitHub repo to get coding work done. Map a channel
94
+ to a plain **local folder** and the agent works in it **directly** — it edits the
95
+ files in place (no branch, no commit, no PR), then posts a report of what changed.
96
+ It's your own machine, so this is the same trust as running the CLI yourself.
97
+
98
+ ```jsonc
99
+ // hilos-agent.json
100
+ {
101
+ "folders": { "<channelId>": "/Users/you/notes-site" }
102
+ }
103
+ ```
104
+
105
+ - **Trigger** — an `@mention` in a channel with **no linked repo** but a `folders`
106
+ mapping. The same LLM router decides chat vs. code; a coding ask runs in the
107
+ folder.
108
+ - **Git folders** — if the folder is a git repo, the agent snapshots `git status`
109
+ before/after and reports the exact files it created/changed/removed plus a
110
+ `git diff --stat`. It **never** touches your index, branches, pushes, or runs
111
+ `gh`. On the report card: **Approve** keeps the changes (they're already live),
112
+ **Request changes** re-runs in place with your note (bounded by `maxRounds`),
113
+ **Reject** reverts *only what this run changed* (`git checkout` the files it
114
+ modified, delete the files it created) — your pre-existing local edits are left
115
+ untouched.
116
+ - **Non-git folders** — it still runs, but says up front it can't show a file-level
117
+ diff or auto-undo; a reject asks you to revert by hand.
118
+ - A **linked repo always wins** — the folder map is only consulted when the channel
119
+ has no repo link.
120
+ - **Folder link registration** — on startup the daemon registers each `folders`
121
+ mapping with hilos (over the `link_folder` MCP tool) so the channel shows a
122
+ folder chip — the folder's name, its full path, and which machine it lives on —
123
+ without any manual step. It's best-effort and capability-gated: older servers
124
+ that don't expose `link_folder` are skipped silently, and a failed registration
125
+ only logs a line, never blocking the daemon. Registration happens once at
126
+ startup, so a `folders` entry added while the daemon is running needs a restart
127
+ to appear.
128
+
129
+ ### Deploy the folder with your own hosting CLI
130
+
131
+ Add an optional per-channel deploy target next to `folders`:
132
+
133
+ ```jsonc
134
+ {
135
+ "folders": { "<channelId>": "/Users/you/notes-site" },
136
+ "deploy": { "<channelId>": { "provider": "vercel", "prod": false } }
137
+ }
138
+ ```
139
+
140
+ `prod:false` means a preview deployment; `prod:true` means production. If the
141
+ setting is absent, the daemon detects `.vercel/` / `vercel.json` or `.netlify/`
142
+ / `netlify.toml`; with no marker, deploy stays off. Install and sign in to the
143
+ matching CLI yourself (`vercel login` or `netlify login`). hilos stores no host
144
+ credential and never deploys silently: ask explicitly to put the folder live,
145
+ or use the report card's clearly labeled deploy action. The live URL comes back
146
+ on the same report card. CLI output is secret-redacted, the child receives no
147
+ `HILOS_*` variables, and failures remain caveats rather than false successes.
148
+
149
+ ## Embedding the daemon
150
+
151
+ `run(cfg, opts)` is the poll loop, and it's embeddable. Beyond `handler`/`log` it
152
+ accepts:
153
+
154
+ ```js
155
+ import { run } from "hilos-agent/src/run.mjs";
156
+
157
+ const controller = new AbortController();
158
+ await run(cfg, {
159
+ signal: controller.signal, // abort → interrupts the poll sleep, cancels the
160
+ // active job, and returns cleanly
161
+ onEvent: (e) => { // lifecycle events for a host UI (wrapped in
162
+ // e.type: "status" | "task-start" | "task-done" | "task-error"
163
+ console.log(e); // try/catch — a bad listener can't crash the loop)
164
+ },
165
+ });
166
+ // later: controller.abort(); // run() resolves once the active job tears down
167
+ ```
168
+
169
+ Event shapes: `{ type: "status", text }`, `{ type: "task-start", channelId,
170
+ messageId, text }`, `{ type: "task-done", channelId, status }`, and
171
+ `{ type: "task-error", channelId, error }`. Both options are optional and fully
172
+ backward compatible — omit them and the CLI behaves exactly as before.
173
+
90
174
  ## Model & permissions
91
175
 
92
176
  You don't have to hand-write `codingCmd`: the agent's **Connect via MCP** panel in
@@ -125,6 +209,47 @@ The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
125
209
  you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
126
210
  before anything is pushed.
127
211
 
212
+ ## Hooks — stream a raw Claude Code session
213
+
214
+ Don't want to run a persistent daemon? You can still make your Claude Code CLI
215
+ sessions visible to your team in real time. **Claude Code hooks** (shipped in
216
+ 0448) let every tool call your session makes stream directly into your agent's
217
+ live status card in hilos — no daemon, no extra process.
218
+
219
+ ```sh
220
+ # Inside the repo you want to stream:
221
+ npx hilos-agent hooks install
222
+
223
+ # Or stream all your Claude Code sessions, everywhere:
224
+ npx hilos-agent hooks install --global
225
+ ```
226
+
227
+ This writes `PostToolUse`, `Stop`, and `SessionEnd` hook entries into
228
+ `.claude/settings.json` (project) or `~/.claude/settings.json` (global),
229
+ preserving any hooks you already have. Preview the block without writing anything:
230
+
231
+ ```sh
232
+ npx hilos-agent hooks print
233
+ ```
234
+
235
+ **Requirements:**
236
+ - `npm i -g hilos-agent` so the `hilos-agent hook` command resolves at hook time.
237
+ - A `~/.hilos/agent.json` with `url`, `token`, and `channelId`. Generate these in
238
+ the agent's **Connect via MCP** panel in hilos (same panel as `--join`).
239
+
240
+ **What you get:**
241
+ - Team members see "Editing lib/x.ts" or "Running pnpm test" on the agent's live
242
+ card as each tool fires — without you doing anything beyond the install.
243
+ - Steps are coalesced into ~2s batches to keep traffic light.
244
+ - When a turn ends (`Stop`), the card settles to done. The next turn revives the
245
+ same card — one card per session, not one per turn.
246
+ - Up to 20 unique files touched are surfaced so reviewers can glance at the scope
247
+ before the report card arrives.
248
+
249
+ **Privacy:** project-level by default (only repos you opt into stream). Global
250
+ kill switch: `HILOS_HOOKS=off`. The hook always exits 0 — it will never interrupt
251
+ or break your CLI session.
252
+
128
253
  ## Security
129
254
 
130
255
  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,13 +47,20 @@ 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)
51
57
  --config <path> use a specific config file
52
- --coding-cmd <cmd> the coding agent to run (default: "claude -p")
53
- --chat-cmd <cmd> fast command for chat replies + the plan-ack
54
- (default: "claude -p --model claude-haiku-4-5")
58
+ --coding-cmd <cmd> the coding agent to run claude -p, codex exec,
59
+ cursor-agent -p, agy -p, hermes, or any command that
60
+ takes a prompt as its last arg (default: "claude -p")
61
+ --chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
62
+ derived from the coding command, so a Codex or Cursor
63
+ daemon chats with its own tool)
55
64
  --once one poll then exit (cron-friendly)
56
65
  --backfill also act on mentions that predate startup
57
66
  --no-gate propose only; don't wait for approval / push
@@ -60,12 +69,24 @@ Options:
60
69
  Docs: https://hilos.sh · https://www.npmjs.com/package/hilos-agent`;
61
70
 
62
71
  async function main() {
63
- const { cmd, flags } = parseArgs(process.argv.slice(2));
72
+ const { cmd, flags, positional } = parseArgs(process.argv.slice(2));
64
73
  if (flags.help || cmd === "help") {
65
74
  console.log(HELP);
66
75
  return;
67
76
  }
68
77
 
78
+ // `hook` is invoked BY Claude Code on every tool call (0448) — it must be
79
+ // fast, silent, and always exit 0, so it short-circuits before any daemon
80
+ // machinery.
81
+ if (cmd === "hook") {
82
+ await hookMain();
83
+ return;
84
+ }
85
+ if (cmd === "hooks") {
86
+ hooksMain(positional[1], { global: Boolean(flags.global) });
87
+ return;
88
+ }
89
+
69
90
  const joinPayload = flags.join ? decodeJoin(flags.join) : undefined;
70
91
  if (flags.join && !joinPayload) {
71
92
  console.error("That --join link is invalid. Re-copy it from hilos.");
@@ -73,7 +94,11 @@ async function main() {
73
94
  }
74
95
 
75
96
  if (cmd === "init") {
76
- const path = writeStarterConfig(joinPayload ? GLOBAL_CONFIG : flags.config, joinPayload || {});
97
+ // Carry an explicit --coding-cmd into the starter file so an init run from a
98
+ // non-Claude connect command doesn't write the Claude default over it.
99
+ const starter = { ...(joinPayload || {}) };
100
+ if (flags.codingCmd) starter.codingCmd = flags.codingCmd;
101
+ const path = writeStarterConfig(joinPayload ? GLOBAL_CONFIG : flags.config, starter);
77
102
  console.log(`Wrote ${path}.`);
78
103
  console.log(joinPayload ? "Token + endpoint set from your link." : "Fill in token + repos, then run `hilos-agent`.");
79
104
  console.log('Map your repos: "repos": { "owner/name": "/abs/path/to/checkout" }');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.16",
3
+ "version": "0.5.1",
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/cli.mjs CHANGED
@@ -7,6 +7,88 @@
7
7
 
8
8
  import { spawn } from "node:child_process";
9
9
 
10
+ // ── Environment isolation ────────────────────────────────────────────────────
11
+ // The coding/chat CLI we spawn (`claude -p`, `codex`, …) is a model with tool
12
+ // use: it can run `env`/`printenv` and echo whatever it sees into the channel.
13
+ // So hilos's OWN workspace bearer token (HILOS_TOKEN and the rest of the HILOS_*
14
+ // connection vars) must NEVER be in the child's environment — the coding tool
15
+ // never needs it (the DAEMON talks to hilos, the child only edits files), and
16
+ // leaking it would hand a reader the keys to the workspace. We strip HILOS_* by
17
+ // default on every spawn. Note the common `--join` paste-one-command flow keeps
18
+ // the token in daemon memory (never in env), so this is a no-op there and only
19
+ // bites the env-auth path (`HILOS_TOKEN=… hilos-agent`) — which is exactly where
20
+ // the leak was. Hooks read the token from the config file, so they're unaffected.
21
+
22
+ // Control flags (not secrets) that must survive the scrub: the documented
23
+ // HILOS_HOOKS=off kill switch is read by the hook helper INSIDE the spawned
24
+ // CLI's process tree, so stripping it would silently re-enable hook sends
25
+ // from daemon-spawned sessions. Nothing here carries workspace access.
26
+ const HILOS_CONTROL_KEYS = new Set(["HILOS_HOOKS"]);
27
+
28
+ /**
29
+ * True for a hilos-owned connection var the coding tool must never inherit.
30
+ * @param {string} key
31
+ */
32
+ function isHilosSecretKey(key) {
33
+ return /^HILOS_/i.test(key) && !HILOS_CONTROL_KEYS.has(key.toUpperCase());
34
+ }
35
+
36
+ /**
37
+ * A shallow copy of `base` (default `process.env`) with every hilos-owned env
38
+ * var (HILOS_*) removed. Applied to every child by runCli so the model can't
39
+ * read — and therefore can't echo — hilos's workspace token.
40
+ * @param {Record<string, string | undefined>} [base]
41
+ * @returns {Record<string, string>}
42
+ */
43
+ export function scrubHilosEnv(base = process.env) {
44
+ const out = {};
45
+ for (const [k, v] of Object.entries(base)) {
46
+ if (v === undefined) continue;
47
+ if (isHilosSecretKey(k)) continue;
48
+ out[k] = v;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Strict-isolation allowlist: what a coding CLI genuinely needs to start and
54
+ // authenticate to ITS OWN provider — nothing else. Everything outside this list
55
+ // (AWS/GCP/DB creds, PATs, SSH-agent sockets, arbitrary secrets in the user's
56
+ // shell) is dropped so a task can't sweep them up. Opt-in via `codingEnv:
57
+ // "minimal"`; users can widen it with `codingEnvAllow: ["FOO", …]`.
58
+ const MINIMAL_ENV_EXACT = new Set([
59
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TERM", "TMPDIR", "TMP", "TEMP",
60
+ "TZ", "PWD", "LANG", "LC_ALL", "LC_CTYPE", "COLUMNS", "LINES", "COLORTERM",
61
+ ]);
62
+ // Prefixes the coding tools use for their own config/auth (never hilos's).
63
+ const MINIMAL_ENV_PREFIXES = [
64
+ "LC_", "XDG_", "NODE_", "NPM_", "npm_", "NVM_", "VOLTA_", "FNM_",
65
+ "ANTHROPIC_", "CLAUDE_", "OPENAI_", "CODEX_", "CURSOR_", "QWEN_", "DASHSCOPE_",
66
+ "SSL_", "NODE_EXTRA_CA_",
67
+ ];
68
+
69
+ /**
70
+ * A curated child environment for strict isolation: only the vars a coding CLI
71
+ * needs to run and reach its own model provider, plus any `extraAllow` names the
72
+ * user opted into. HILOS_* is always excluded. Use for `codingEnv: "minimal"`.
73
+ * @param {Record<string, string | undefined>} [base]
74
+ * @param {string[]} [extraAllow]
75
+ * @returns {Record<string, string>}
76
+ */
77
+ export function minimalEnv(base = process.env, extraAllow = []) {
78
+ const allowExtra = new Set(extraAllow || []);
79
+ const out = {};
80
+ for (const [k, v] of Object.entries(base)) {
81
+ if (v === undefined || isHilosSecretKey(k)) continue;
82
+ const ok =
83
+ MINIMAL_ENV_EXACT.has(k) ||
84
+ HILOS_CONTROL_KEYS.has(k.toUpperCase()) ||
85
+ allowExtra.has(k) ||
86
+ MINIMAL_ENV_PREFIXES.some((p) => k.startsWith(p));
87
+ if (ok) out[k] = v;
88
+ }
89
+ return out;
90
+ }
91
+
10
92
  /** Human-readable elapsed time: "45s", "2m 3s". */
11
93
  export function fmtElapsed(ms) {
12
94
  const total = Math.max(0, Math.round(ms / 1000));
@@ -62,6 +144,9 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
62
144
  * whole process group: SIGTERM, then SIGKILL after a short grace)
63
145
  * @property {(chunk: string) => void} [onData] - called with each stdout chunk as
64
146
  * it arrives (lets a caller track the latest output line for a heartbeat)
147
+ * @property {Record<string, string>} [env] - base environment for the child. Any
148
+ * hilos-owned var (HILOS_*) is stripped from it regardless. Omit to inherit the
149
+ * daemon's environment minus HILOS_* (the safe default).
65
150
  */
66
151
 
67
152
  /**
@@ -86,6 +171,7 @@ export function runCli(opts) {
86
171
  log = console,
87
172
  signal,
88
173
  onData,
174
+ env,
89
175
  } = opts || {};
90
176
  return new Promise((resolve) => {
91
177
  // Already cancelled before we even start.
@@ -98,7 +184,17 @@ export function runCli(opts) {
98
184
  // detached:true makes the child its own process-group leader, so we can
99
185
  // kill the WHOLE tree (claude → node → git …) with process.kill(-pid) on
100
186
  // cancel/timeout instead of orphaning its subprocesses.
101
- child = spawn(cmd, args, { cwd, detached: true });
187
+ // stdin MUST be 'ignore' (/dev/null instant EOF), never a dangling pipe:
188
+ // `codex exec` appends piped stdin to its prompt and blocks until EOF, so
189
+ // an open pipe hangs it until the run timeout ("Reading additional input
190
+ // from stdin…"). Nothing we spawn is ever fed via stdin.
191
+ // Always strip hilos's own token from the child's env (see scrubHilosEnv).
192
+ child = spawn(cmd, args, {
193
+ cwd,
194
+ detached: true,
195
+ stdio: ["ignore", "pipe", "pipe"],
196
+ env: scrubHilosEnv(env || process.env),
197
+ });
102
198
  } catch (error) {
103
199
  resolve({ status: null, stdout: "", stderr: "", error });
104
200
  return;
package/src/config.mjs CHANGED
@@ -43,15 +43,36 @@ 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: {},
51
+ // Optional local-folder deploy target. Outward deploys are always triggered
52
+ // explicitly by a request or report-card action; this only chooses provider
53
+ // and preview (default) vs production. Shape:
54
+ // { "<channelId>": { provider: "vercel" | "netlify", prod: false } }.
55
+ deploy: {},
46
56
  // acceptEdits lets the CLI make file edits without prompting (bias to action);
47
57
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
48
58
  // want a stricter (or `--dangerously-skip-permissions`) command.
49
59
  codingCmd: "claude -p --permission-mode acceptEdits",
50
- // Chat replies + the code-task plan-ack use a FAST one-shot model so a casual
60
+ // Environment handed to the coding/chat CLI. hilos's own token (HILOS_*) is
61
+ // ALWAYS stripped either way. "inherit" (default) passes the rest of your
62
+ // shell env so the coding tool behaves exactly as if you ran it yourself.
63
+ // "minimal" hands the child only what it needs to run + reach its own model
64
+ // provider (PATH/HOME/locale + ANTHROPIC_*/OPENAI_*/… ), dropping unrelated
65
+ // secrets (AWS/DB/SSH-agent/etc.) so a task can't sweep them up. Widen minimal
66
+ // mode with codingEnvAllow: ["MY_VAR", …].
67
+ codingEnv: "inherit",
68
+ codingEnvAllow: [],
69
+ // Chat replies + the code-task plan-ack use a FAST one-shot command so a casual
51
70
  // reply (or "I see it, here's my plan") comes back in seconds, not minutes.
52
71
  // Bounded by chatTimeoutMs with a template fallback so it can never dead-air.
53
- // If your coding stack isn't Claude, set this to your own fast reply command.
54
- chatCmd: "claude -p --model claude-haiku-4-5",
72
+ // Empty = derive from codingCmd's vendor (fastChatCmd in progress-emitter.mjs),
73
+ // so a Codex/Cursor daemon chats with ITS OWN tool — Claude Code is never
74
+ // required just because it's hilos's default. Set explicitly to override.
75
+ chatCmd: "",
55
76
  defaultBranch: "main",
56
77
  // Bias to action: open a PR directly for review (approve = merge on the card).
57
78
  // Set gate:true for the older approve-before-push flow (propose a diff, wait).
@@ -111,6 +132,9 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
111
132
  }
112
133
  for (const [k, v] of Object.entries(flags)) if (v !== undefined) merged[k] = v;
113
134
  merged.repos = { ...DEFAULTS.repos, ...(file.repos || {}) };
135
+ // Local-folder map, merged like `repos` (an object, not a scalar overlay).
136
+ merged.folders = { ...DEFAULTS.folders, ...(file.folders || {}) };
137
+ merged.deploy = { ...DEFAULTS.deploy, ...(file.deploy || {}) };
114
138
  // Remember where the file lives so reloadConfig can re-read it live.
115
139
  merged.configPath = findConfigPath(flags.config) || null;
116
140
  return merged;
@@ -122,6 +146,8 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
122
146
  const LIVE_FIELDS = [
123
147
  "codingCmd",
124
148
  "chatCmd",
149
+ "codingEnv",
150
+ "codingEnvAllow",
125
151
  "defaultBranch",
126
152
  "gate",
127
153
  "maxRounds",
@@ -135,6 +161,11 @@ const LIVE_FIELDS = [
135
161
  // NOT queueConcurrency: the queue is built once at startup, so it can't change
136
162
  // live. queueAcks IS live (intake() reads it per-mention from liveCfg).
137
163
  "queueAcks",
164
+ // folders is an object map; it's listed here for documentation, but the actual
165
+ // live merge (with DEFAULTS) happens in the special-cased block below, exactly
166
+ // like `repos`, so a partial edit doesn't drop the defaults.
167
+ "folders",
168
+ "deploy",
138
169
  ];
139
170
 
140
171
  /**
@@ -160,6 +191,10 @@ export function reloadConfig(prev) {
160
191
  if (next.heartbeatMs > 0) next.heartbeatMs = Math.max(15000, Number(next.heartbeatMs) || 0);
161
192
  next.progressMs = Math.max(750, Number(next.progressMs) || 2000);
162
193
  if (file.repos) next.repos = { ...DEFAULTS.repos, ...file.repos };
194
+ // Merge the folder map like repos (overriding the raw scalar-loop assignment
195
+ // above with a proper DEFAULTS-merged object).
196
+ if (file.folders) next.folders = { ...DEFAULTS.folders, ...file.folders };
197
+ if (file.deploy) next.deploy = { ...DEFAULTS.deploy, ...file.deploy };
163
198
  return next;
164
199
  }
165
200
 
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 || "");