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 +129 -4
- package/bin/hilos-agent.mjs +31 -6
- package/package.json +1 -1
- package/src/cli.mjs +97 -1
- package/src/config.mjs +38 -3
- package/src/daemon.mjs +49 -0
- package/src/deploy.mjs +234 -0
- package/src/handler.mjs +963 -45
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +26 -2
- package/src/progress-emitter.mjs +28 -3
- package/src/redact.mjs +54 -0
- package/src/resume.mjs +1 -1
- package/src/run.mjs +130 -14
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": "
|
|
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` (
|
|
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
|
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,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
|
|
53
|
-
|
|
54
|
-
(default: "claude -p
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
54
|
-
|
|
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 || "");
|