hilos-agent 0.4.0 → 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
 
@@ -125,6 +126,26 @@ It's your own machine, so this is the same trust as running the CLI yourself.
125
126
  startup, so a `folders` entry added while the daemon is running needs a restart
126
127
  to appear.
127
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
+
128
149
  ## Embedding the daemon
129
150
 
130
151
  `run(cfg, opts)` is the poll loop, and it's embeddable. Beyond `handler`/`log` it
@@ -55,9 +55,12 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
55
55
  Options:
56
56
  --channel <id> watch only one channel (per-channel override)
57
57
  --config <path> use a specific config file
58
- --coding-cmd <cmd> the coding agent to run (default: "claude -p")
59
- --chat-cmd <cmd> fast command for chat replies + the plan-ack
60
- (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)
61
64
  --once one poll then exit (cron-friendly)
62
65
  --backfill also act on mentions that predate startup
63
66
  --no-gate propose only; don't wait for approval / push
@@ -91,7 +94,11 @@ async function main() {
91
94
  }
92
95
 
93
96
  if (cmd === "init") {
94
- 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);
95
102
  console.log(`Wrote ${path}.`);
96
103
  console.log(joinPayload ? "Token + endpoint set from your link." : "Fill in token + repos, then run `hilos-agent`.");
97
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.4.0",
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
@@ -48,15 +48,31 @@ const DEFAULTS = {
48
48
  // folder on this machine. Changes apply directly (no branch/PR). Live-reloadable
49
49
  // like `repos`. Shape: { "<channelId>": "/abs/path" }.
50
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: {},
51
56
  // acceptEdits lets the CLI make file edits without prompting (bias to action);
52
57
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
53
58
  // want a stricter (or `--dangerously-skip-permissions`) command.
54
59
  codingCmd: "claude -p --permission-mode acceptEdits",
55
- // 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
56
70
  // reply (or "I see it, here's my plan") comes back in seconds, not minutes.
57
71
  // Bounded by chatTimeoutMs with a template fallback so it can never dead-air.
58
- // If your coding stack isn't Claude, set this to your own fast reply command.
59
- 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: "",
60
76
  defaultBranch: "main",
61
77
  // Bias to action: open a PR directly for review (approve = merge on the card).
62
78
  // Set gate:true for the older approve-before-push flow (propose a diff, wait).
@@ -118,6 +134,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
118
134
  merged.repos = { ...DEFAULTS.repos, ...(file.repos || {}) };
119
135
  // Local-folder map, merged like `repos` (an object, not a scalar overlay).
120
136
  merged.folders = { ...DEFAULTS.folders, ...(file.folders || {}) };
137
+ merged.deploy = { ...DEFAULTS.deploy, ...(file.deploy || {}) };
121
138
  // Remember where the file lives so reloadConfig can re-read it live.
122
139
  merged.configPath = findConfigPath(flags.config) || null;
123
140
  return merged;
@@ -129,6 +146,8 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
129
146
  const LIVE_FIELDS = [
130
147
  "codingCmd",
131
148
  "chatCmd",
149
+ "codingEnv",
150
+ "codingEnvAllow",
132
151
  "defaultBranch",
133
152
  "gate",
134
153
  "maxRounds",
@@ -146,6 +165,7 @@ const LIVE_FIELDS = [
146
165
  // live merge (with DEFAULTS) happens in the special-cased block below, exactly
147
166
  // like `repos`, so a partial edit doesn't drop the defaults.
148
167
  "folders",
168
+ "deploy",
149
169
  ];
150
170
 
151
171
  /**
@@ -174,6 +194,7 @@ export function reloadConfig(prev) {
174
194
  // Merge the folder map like repos (overriding the raw scalar-loop assignment
175
195
  // above with a proper DEFAULTS-merged object).
176
196
  if (file.folders) next.folders = { ...DEFAULTS.folders, ...file.folders };
197
+ if (file.deploy) next.deploy = { ...DEFAULTS.deploy, ...file.deploy };
177
198
  return next;
178
199
  }
179
200
 
package/src/deploy.mjs ADDED
@@ -0,0 +1,234 @@
1
+ // Local-folder deploys through the user's own Vercel or Netlify CLI session.
2
+ // No OAuth or credentials are stored by hilos. Every captured string returned
3
+ // to callers is redacted before it can reach a report, transcript, or log.
4
+
5
+ import { constants as fsConstants } from "node:fs";
6
+ import { accessSync, existsSync } from "node:fs";
7
+ import { delimiter, join } from "node:path";
8
+
9
+ import { runCli, scrubHilosEnv } from "./cli.mjs";
10
+ import { redactSecrets } from "./redact.mjs";
11
+
12
+ export const DEPLOY_PROVIDERS = ["vercel", "netlify"];
13
+ export const DEFAULT_DEPLOY_TIMEOUT_MS = 5 * 60_000;
14
+
15
+ function isProvider(value) {
16
+ return DEPLOY_PROVIDERS.includes(value);
17
+ }
18
+
19
+ export function findDeployCli(provider, env = process.env) {
20
+ if (!isProvider(provider)) return null;
21
+ for (const entry of String(env.PATH || "").split(delimiter)) {
22
+ if (!entry) continue;
23
+ const candidate = join(entry, provider);
24
+ try {
25
+ accessSync(candidate, fsConstants.X_OK);
26
+ return candidate;
27
+ } catch {
28
+ // Continue through PATH.
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /** Resolve explicit channel config first, then provider-owned project markers. */
35
+ export function resolveDeployTarget({ cfg, channelId, folderPath, env = process.env, pathExists = existsSync }) {
36
+ const configured = cfg?.deploy?.[channelId];
37
+ let provider = isProvider(configured?.provider) ? configured.provider : null;
38
+ let prod = configured?.prod === true;
39
+ let source = provider ? "config" : null;
40
+
41
+ if (!provider) {
42
+ if (pathExists(join(folderPath, ".vercel")) || pathExists(join(folderPath, "vercel.json"))) {
43
+ provider = "vercel";
44
+ source = "detected";
45
+ } else if (pathExists(join(folderPath, ".netlify")) || pathExists(join(folderPath, "netlify.toml"))) {
46
+ provider = "netlify";
47
+ source = "detected";
48
+ }
49
+ prod = false;
50
+ }
51
+
52
+ if (!provider) return { enabled: false, reason: "not-configured" };
53
+ const cliPath = findDeployCli(provider, env);
54
+ return {
55
+ enabled: true,
56
+ available: Boolean(cliPath),
57
+ provider,
58
+ prod,
59
+ source,
60
+ cliPath,
61
+ reason: cliPath ? null : "cli-missing",
62
+ };
63
+ }
64
+
65
+ export function deployArgs(provider, prod) {
66
+ if (provider === "vercel") return prod ? ["--prod", "--yes"] : ["--yes"];
67
+ if (provider === "netlify") {
68
+ return prod ? ["deploy", "--prod", "--json"] : ["deploy", "--json"];
69
+ }
70
+ throw new Error("Unsupported deploy provider.");
71
+ }
72
+
73
+ function stripAnsi(value) {
74
+ return String(value || "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
75
+ }
76
+
77
+ function vercelUrlCandidates(stdout, stderr) {
78
+ const lines = `${stripAnsi(stdout)}\n${stripAnsi(stderr)}`.split(/\r?\n/);
79
+ const candidates = [];
80
+ for (const line of lines) {
81
+ for (const match of line.matchAll(/https:\/\/[^\s"'<>]+/gi)) {
82
+ // Vercel's aligned output has no colon and may have a gutter glyph:
83
+ // `▲ Aliased https://example.com`. Older releases used colons.
84
+ const beforeUrl = line.slice(0, match.index ?? 0);
85
+ const label = /\b(preview|production|aliased)\b\s*:?\s*$/i
86
+ .exec(beforeUrl)?.[1]?.toLowerCase() ?? null;
87
+ const raw = match[0].replace(/[),.;\]]+$/g, "");
88
+ try {
89
+ const parsed = new URL(raw);
90
+ const host = parsed.hostname.toLowerCase();
91
+ // `Inspect: https://vercel.com/<team>/<project>/...` is a dashboard
92
+ // link, not the deployment. Never put it on a report card.
93
+ if (host === "vercel.com" || host.endsWith(".vercel.com")) continue;
94
+ candidates.push({ raw, host, label });
95
+ } catch {
96
+ // Ignore malformed URL-shaped CLI output.
97
+ }
98
+ }
99
+ }
100
+ return candidates;
101
+ }
102
+
103
+ export function parseDeployUrl(provider, stdout, stderr = "", prod = false) {
104
+ if (provider === "vercel") {
105
+ const candidates = vercelUrlCandidates(stdout, stderr);
106
+ if (prod) {
107
+ // Production deploys may finish by printing only a custom alias. Prefer
108
+ // the explicit alias, then the CLI's Production line, before falling back
109
+ // to the canonical *.vercel.app deployment URL.
110
+ return (
111
+ candidates.find((candidate) => candidate.label === "aliased")?.raw ??
112
+ candidates.find((candidate) => candidate.label === "production")?.raw ??
113
+ candidates.find((candidate) => candidate.host.endsWith(".vercel.app"))?.raw ??
114
+ null
115
+ );
116
+ }
117
+ // Preview output remains deliberately strict: an arbitrary URL printed by
118
+ // a build is not evidence that Vercel deployed there.
119
+ return candidates.find((candidate) => candidate.host.endsWith(".vercel.app"))?.raw ?? null;
120
+ }
121
+ if (provider === "netlify") {
122
+ const clean = stripAnsi(stdout).trim();
123
+ const candidates = [clean, clean.slice(clean.indexOf("{"), clean.lastIndexOf("}") + 1)];
124
+ for (const candidate of candidates) {
125
+ if (!candidate) continue;
126
+ try {
127
+ const parsed = JSON.parse(candidate);
128
+ const value = prod
129
+ ? parsed.url || parsed.deploy_url
130
+ : parsed.deploy_url || parsed.url;
131
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) return value;
132
+ } catch {
133
+ // Netlify should emit one JSON object; try the bounded object fallback.
134
+ }
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function loginCaveat(provider, output) {
141
+ const text = String(output || "").toLowerCase();
142
+ const authFailure =
143
+ /not (?:logged|signed) in|not authenticated|authentication required|unauthorized|no auth token|login required|token is not valid|invalid (?:auth )?token/.test(text);
144
+ if (!authFailure) return null;
145
+ return provider === "vercel"
146
+ ? "Vercel is not logged in on this Mac. Run `vercel login` in a terminal once, then try again."
147
+ : "Netlify is not logged in on this Mac. Run `netlify login` in a terminal once, then try again.";
148
+ }
149
+
150
+ function safeDeploymentUrl(value) {
151
+ try {
152
+ const parsed = new URL(value);
153
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
154
+ parsed.username = "";
155
+ parsed.password = "";
156
+ parsed.search = "";
157
+ parsed.hash = "";
158
+ return redactSecrets(parsed.toString());
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ export async function deployFolder({
165
+ folderPath,
166
+ target,
167
+ timeoutMs = DEFAULT_DEPLOY_TIMEOUT_MS,
168
+ env = process.env,
169
+ signal = undefined,
170
+ run = runCli,
171
+ }) {
172
+ const provider = target?.provider;
173
+ const prod = target?.prod === true;
174
+ if (!isProvider(provider)) {
175
+ return { ok: false, provider: null, prod, caveat: "Choose Vercel or Netlify before deploying." };
176
+ }
177
+ if (!target.available || !target.cliPath) {
178
+ const label = provider === "vercel" ? "Vercel" : "Netlify";
179
+ return {
180
+ ok: false,
181
+ provider,
182
+ prod,
183
+ caveat: `Install the ${label} CLI, sign in, then try again.`,
184
+ };
185
+ }
186
+
187
+ const result = await run({
188
+ cmd: target.cliPath,
189
+ args: deployArgs(provider, prod),
190
+ cwd: folderPath,
191
+ timeoutMs,
192
+ heartbeatMs: 0,
193
+ label: "deploying",
194
+ signal,
195
+ // Defense in depth: runCli strips HILOS_* too, but an injected runner sees
196
+ // the same scrubbed environment the real child receives.
197
+ env: scrubHilosEnv(env),
198
+ });
199
+ const stdout = redactSecrets(String(result?.stdout || ""));
200
+ const stderr = redactSecrets(String(result?.stderr || ""));
201
+ const error = redactSecrets(String(result?.error?.message || ""));
202
+ const combined = [stdout, stderr, error].filter(Boolean).join("\n");
203
+
204
+ if (result?.aborted) {
205
+ return { ok: false, provider, prod, aborted: true, stdout, stderr, caveat: "Deployment was stopped." };
206
+ }
207
+ if (result?.status !== 0) {
208
+ return {
209
+ ok: false,
210
+ provider,
211
+ prod,
212
+ stdout,
213
+ stderr,
214
+ caveat:
215
+ loginCaveat(provider, combined) ||
216
+ (error.includes("timed out")
217
+ ? `The ${provider === "vercel" ? "Vercel" : "Netlify"} deployment timed out after ${Math.round(timeoutMs / 1000)} seconds.`
218
+ : `${provider === "vercel" ? "Vercel" : "Netlify"} deployment failed: ${(stderr || error || "unknown error").slice(0, 240)}`),
219
+ };
220
+ }
221
+
222
+ const url = safeDeploymentUrl(parseDeployUrl(provider, result.stdout, result.stderr, prod));
223
+ if (!url) {
224
+ return {
225
+ ok: false,
226
+ provider,
227
+ prod,
228
+ stdout,
229
+ stderr,
230
+ caveat: `${provider === "vercel" ? "Vercel" : "Netlify"} finished without returning a deployment URL.`,
231
+ };
232
+ }
233
+ return { ok: true, provider, prod, url, stdout, stderr };
234
+ }
package/src/handler.mjs CHANGED
@@ -26,9 +26,9 @@ import {
26
26
  mentionHandle,
27
27
  detectPrContinuation,
28
28
  } from "./daemon.mjs";
29
- import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed } from "./cli.mjs";
29
+ import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed, minimalEnv } from "./cli.mjs";
30
30
  import { makeStreamParser } from "./agent-events.mjs";
31
- import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
31
+ import { detectVendor, codeStreamArgs, createProgressEmitter, fastChatCmd } from "./progress-emitter.mjs";
32
32
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
33
33
  import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
34
34
  import {
@@ -38,6 +38,30 @@ import {
38
38
  shouldReview,
39
39
  } from "./review.mjs";
40
40
  import { buildMemoryBlock } from "./memory.mjs";
41
+ import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
42
+
43
+ /**
44
+ * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
45
+ * of whatever this returns; here we additionally honor `codingEnv: "minimal"`
46
+ * (strict isolation — only what the tool needs to reach its own model provider).
47
+ * Returns undefined for the default "inherit" mode so runCli falls back to the
48
+ * scrubbed process.env.
49
+ */
50
+ function codingChildEnv(cfg) {
51
+ return cfg?.codingEnv === "minimal"
52
+ ? minimalEnv(process.env, cfg.codingEnvAllow)
53
+ : undefined;
54
+ }
55
+
56
+ /**
57
+ * The fast chat command for this config: an explicit chatCmd wins, else the
58
+ * coding vendor's verified non-interactive print mode (fastChatCmd), else the
59
+ * coding command itself. Keeps chat/plan-ack/review on the USER'S tool — a
60
+ * Codex or Cursor daemon must never require Claude Code on PATH (0521).
61
+ */
62
+ function chatCmdFor(cfg) {
63
+ return cfg.chatCmd || fastChatCmd(detectVendor(cfg.codingCmd)) || cfg.codingCmd;
64
+ }
41
65
 
42
66
  /** PR number from a github pull-request URL, or null. */
43
67
  function prNumberFromUrl(url) {
@@ -102,6 +126,8 @@ function defaultDeps() {
102
126
  // Does a path exist on disk? Injectable so folder mode's "missing folder"
103
127
  // guard is unit-testable without touching the real filesystem.
104
128
  pathExists: (p) => existsSync(p),
129
+ resolveDeployTarget: (args) => resolveDeployTarget(args),
130
+ deployFolder: (args) => deployFolder(args),
105
131
  openPR: (cwd, { title, body, branch, base }) => {
106
132
  const r = spawnSync(
107
133
  "gh",
@@ -167,7 +193,15 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, pare
167
193
  report = m && m.report ? m.report : null;
168
194
  }
169
195
  const kind = report ? decisionKind(report) : null;
170
- if (kind) return { kind, note: report.decision?.note || null };
196
+ if (kind === "deploy") {
197
+ const provider = report.decision?.provider;
198
+ const prod = report.decision?.prod;
199
+ if ((provider === "vercel" || provider === "netlify") && typeof prod === "boolean") {
200
+ return { kind, provider, prod };
201
+ }
202
+ } else if (kind) {
203
+ return { kind, note: report.decision?.note || null };
204
+ }
171
205
  await deps.sleep(cfg.decisionPollMs);
172
206
  }
173
207
  return { kind: "timeout" };
@@ -371,7 +405,7 @@ const CODE_SIGNAL = "__CODE__";
371
405
  */
372
406
  async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn }) {
373
407
  const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
374
- const cmd = cfg.chatCmd || cfg.codingCmd;
408
+ const cmd = chatCmdFor(cfg);
375
409
  const parts = cmd.split(" ").filter(Boolean);
376
410
  // When this thread already owns an OPEN pull request (a follow-up reply), the
377
411
  // router ALSO reads whether the latest message continues that PR, wants a
@@ -558,7 +592,7 @@ export function planAckPrompt(o) {
558
592
  * empty/timeout/error so the run keeps the instant template and never stalls.
559
593
  */
560
594
  async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
561
- const cmd = cfg.chatCmd;
595
+ const cmd = chatCmdFor(cfg);
562
596
  if (!cmd) return null;
563
597
  const parts = cmd.split(" ").filter(Boolean);
564
598
  const prompt = planAckPrompt({ task, transcript, repoFullName });
@@ -568,6 +602,7 @@ async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
568
602
  timeoutMs: Math.min(cfg.chatTimeoutMs || 90000, 45000),
569
603
  label: "thinking",
570
604
  signal,
605
+ env: codingChildEnv(cfg),
571
606
  });
572
607
  if (run.aborted || signal?.aborted) return null;
573
608
  const text = (run.stdout || "").trim();
@@ -597,10 +632,10 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
597
632
  `${memoryPreamble(workspaceMemory)}` +
598
633
  `Conversation so far:\n${transcript}`;
599
634
 
600
- // Chat uses the FAST one-shot command (fall back to codingCmd if unset) bounded
601
- // by chatTimeoutMs, so a casual reply lands in seconds and a stalled model can't
602
- // dead-air the channel for the full coding timeout.
603
- const cmd = cfg.chatCmd || cfg.codingCmd;
635
+ // Chat uses the FAST one-shot command (the coding vendor's own print mode when
636
+ // chatCmd is unset) bounded by chatTimeoutMs, so a casual reply lands in seconds
637
+ // and a stalled model can't dead-air the channel for the full coding timeout.
638
+ const cmd = chatCmdFor(cfg);
604
639
  const parts = cmd.split(" ").filter(Boolean);
605
640
  console.log(` chat → running \`${cmd}\` (output appears when it finishes)…`);
606
641
 
@@ -636,6 +671,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
636
671
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
637
672
  label: "thinking",
638
673
  signal,
674
+ env: codingChildEnv(cfg),
639
675
  });
640
676
  } finally {
641
677
  beatStopped = true;
@@ -807,7 +843,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
807
843
  // Read-only run: a throwaway cwd contains any stray write; the daemon reads ONLY
808
844
  // stdout and never stages/commits anything. Review uses the fast chat command
809
845
  // when set (no acceptEdits → can't write), falling back to the coding command.
810
- const cmd = cfg.reviewCmd || cfg.chatCmd || cfg.codingCmd;
846
+ const cmd = cfg.reviewCmd || chatCmdFor(cfg);
811
847
  const parts = String(cmd).split(" ").filter(Boolean);
812
848
  let sandboxDir = null;
813
849
  try {
@@ -823,6 +859,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
823
859
  timeoutMs: cfg.chatTimeoutMs ? Math.max(cfg.chatTimeoutMs, 120000) : cfg.runTimeoutMs,
824
860
  label: "reviewing",
825
861
  signal,
862
+ env: codingChildEnv(cfg),
826
863
  });
827
864
  if (sandboxDir) {
828
865
  try {
@@ -890,6 +927,207 @@ function renderChangedList({ created, modified, deleted }) {
890
927
  return lines.join("\n");
891
928
  }
892
929
 
930
+ function deployLabel(target) {
931
+ const provider = target.provider === "vercel" ? "Vercel" : "Netlify";
932
+ return `${provider} (${target.prod ? "production" : "preview"})`;
933
+ }
934
+
935
+ function folderDeployState(folderPath, git) {
936
+ const inside = git(folderPath, ["rev-parse", "--is-inside-work-tree"]);
937
+ const isGit = inside.status === 0 && String(inside.stdout || "").trim() === "true";
938
+ if (!isGit) return { commit: null, stat: "", changed: null };
939
+ const commitResult = git(folderPath, ["rev-parse", "--short", "HEAD"]);
940
+ const statResult = git(folderPath, ["diff", "--stat", "HEAD"]);
941
+ const statusResult = git(folderPath, ["status", "--porcelain"]);
942
+ const changed = diffStatusSets("", String(statusResult.stdout || ""));
943
+ return {
944
+ commit: commitResult.status === 0 ? String(commitResult.stdout || "").trim() : null,
945
+ stat: truncateDiff(String(statResult.stdout || "")).text.trim(),
946
+ changed,
947
+ };
948
+ }
949
+
950
+ // A report-card Deploy click is stored durably on the report as a pending
951
+ // decision, and list_mentions re-projects it on EVERY poll until a post_report
952
+ // settle clears it. So every terminal exit of handleFolderDeploy — including
953
+ // "stopped" and "deploy is off" — must settle the source report, or the
954
+ // decision re-triggers the deploy on the next poll / daemon restart (an
955
+ // explicit human cancel of a production deploy would not stick) and the card
956
+ // stays wedged on "deployment requested" with its action row hidden. Settling
957
+ // re-posts the prior report content untouched plus one honest caveat; the
958
+ // server replaces metadata.report wholesale, which clears the decision by
959
+ // construction and brings the Deploy button back for a retry.
960
+ async function settleDeploySource({ tool, channelId, sourceReportId, caveat }) {
961
+ if (!sourceReportId) return;
962
+ const prior = await tool("get_report", { messageId: sourceReportId }).catch(() => null);
963
+ const p = prior?.found && prior.report ? prior.report : null;
964
+ const caveats = [
965
+ ...(Array.isArray(p?.caveats) ? p.caveats.filter((v) => typeof v === "string") : []),
966
+ caveat,
967
+ ];
968
+ await tool("post_report", {
969
+ channelId,
970
+ messageId: sourceReportId,
971
+ broadcast: true,
972
+ ...(typeof p?.title === "string" ? { title: p.title } : {}),
973
+ summary: typeof p?.summary === "string" && p.summary ? p.summary : caveat,
974
+ caveats: [...new Set(caveats)],
975
+ todos: Array.isArray(p?.todos) ? p.todos.filter((v) => typeof v === "string") : [],
976
+ ...(Array.isArray(p?.verified) ? { verified: p.verified.filter((v) => typeof v === "string") } : {}),
977
+ ...(p?.deployment ? { deployment: p.deployment } : {}),
978
+ ...(typeof p?.previewUrl === "string" ? { previewUrl: p.previewUrl } : {}),
979
+ ...(typeof p?.pr?.url === "string" ? { prUrl: p.pr.url } : {}),
980
+ ...(typeof p?.pr?.number === "number" ? { prNumber: p.pr.number } : {}),
981
+ }).catch(() => {});
982
+ }
983
+
984
+ async function handleFolderDeploy({
985
+ message,
986
+ channelId,
987
+ tool,
988
+ cfg,
989
+ deps,
990
+ signal,
991
+ parentId,
992
+ folderPath,
993
+ sourceReportId,
994
+ }) {
995
+ if (!deps.pathExists(folderPath)) {
996
+ await settleDeploySource({
997
+ tool,
998
+ channelId,
999
+ sourceReportId,
1000
+ caveat: "A requested deployment didn't start: the folder wasn't found on this machine.",
1001
+ });
1002
+ await tool("post_message", {
1003
+ channelId,
1004
+ parentId,
1005
+ body: `I couldn't find the folder \`${folderPath}\` on this machine, so nothing was deployed.`,
1006
+ });
1007
+ return { status: "no-path" };
1008
+ }
1009
+ const target = deps.resolveDeployTarget({
1010
+ cfg,
1011
+ channelId,
1012
+ folderPath,
1013
+ pathExists: deps.pathExists,
1014
+ });
1015
+ if (!target.enabled) {
1016
+ await settleDeploySource({
1017
+ tool,
1018
+ channelId,
1019
+ sourceReportId,
1020
+ caveat: "A requested deployment didn't start: deployment is off for this folder.",
1021
+ });
1022
+ await tool("post_message", {
1023
+ channelId,
1024
+ parentId,
1025
+ body:
1026
+ "Deployment is off for this folder. Add a `deploy` entry for this channel in `hilos-agent.json`, " +
1027
+ "or link the folder once with Vercel/Netlify so its local project marker can be detected.",
1028
+ });
1029
+ return { status: "deploy-off" };
1030
+ }
1031
+
1032
+ const requested = message?.deployRequest;
1033
+ if (
1034
+ requested &&
1035
+ (requested.provider !== target.provider || requested.prod !== target.prod)
1036
+ ) {
1037
+ await settleDeploySource({
1038
+ tool,
1039
+ channelId,
1040
+ sourceReportId,
1041
+ caveat: "A requested deployment didn't start: the folder's deploy target changed after this report.",
1042
+ });
1043
+ await tool("post_message", {
1044
+ channelId,
1045
+ parentId,
1046
+ body: "The folder's deploy target changed after that report. Open the latest report and try again.",
1047
+ });
1048
+ return { status: "deploy-target-changed" };
1049
+ }
1050
+
1051
+ const prior = sourceReportId
1052
+ ? await tool("get_report", { messageId: sourceReportId }).catch(() => null)
1053
+ : null;
1054
+ const priorReport = prior?.found && prior.report ? prior.report : null;
1055
+ const state = folderDeployState(folderPath, deps.git);
1056
+ await tool("post_message", {
1057
+ channelId,
1058
+ parentId,
1059
+ body:
1060
+ `Deploying ${target.prod ? "to production" : "a preview"} on ` +
1061
+ `${target.provider === "vercel" ? "Vercel" : "Netlify"} from \`${folderPath}\`. ` +
1062
+ `${sourceReportId ? "I'll update the report when it finishes." : "I'll post the live link when it finishes."}`,
1063
+ });
1064
+ const result = await deps.deployFolder({ folderPath, target, signal });
1065
+ if (result.aborted || signal?.aborted) {
1066
+ // A stop must stick: settle the report so the pending decision can't
1067
+ // re-trigger this deploy on the next poll or daemon restart.
1068
+ await settleDeploySource({
1069
+ tool,
1070
+ channelId,
1071
+ sourceReportId,
1072
+ caveat: "A deployment was stopped before it finished. Use Deploy on this report to try again.",
1073
+ });
1074
+ await tool("post_message", { channelId, parentId, body: "Deployment was stopped." });
1075
+ return { status: "cancelled" };
1076
+ }
1077
+
1078
+ const folderName = folderPath.split("/").filter(Boolean).pop() || folderPath;
1079
+ const stateLines = [];
1080
+ if (state.commit) stateLines.push(`Deployed from commit \`${state.commit}\`.`);
1081
+ if (state.changed && renderChangedList(state.changed)) {
1082
+ stateLines.push(`Local changes included:\n${renderChangedList(state.changed)}`);
1083
+ }
1084
+ if (state.stat) stateLines.push("```\n" + state.stat + "\n```");
1085
+ const priorSummary = typeof priorReport?.summary === "string" ? priorReport.summary : "";
1086
+ const summary = result.ok
1087
+ ? [
1088
+ priorSummary,
1089
+ `Deployed to ${deployLabel(target)}: ${result.url}`,
1090
+ ...stateLines,
1091
+ ].filter(Boolean).join("\n\n")
1092
+ : [priorSummary, `Deployment to ${deployLabel(target)} did not finish.`, ...stateLines]
1093
+ .filter(Boolean)
1094
+ .join("\n\n");
1095
+ const priorCaveats = Array.isArray(priorReport?.caveats)
1096
+ ? priorReport.caveats.filter((value) => typeof value === "string")
1097
+ : [];
1098
+ const caveats = [...priorCaveats];
1099
+ if (result.ok && !target.prod) {
1100
+ caveats.push("Preview deployment — this is not your production domain.");
1101
+ } else if (!result.ok && result.caveat) {
1102
+ caveats.push(result.caveat);
1103
+ }
1104
+ const priorPreviewUrl =
1105
+ typeof priorReport?.previewUrl === "string" ? priorReport.previewUrl : undefined;
1106
+ if (!result.ok && priorPreviewUrl) {
1107
+ caveats.push("The live link is from the previous successful deployment; this new deploy did not replace it.");
1108
+ }
1109
+ const report = {
1110
+ channelId,
1111
+ ...(sourceReportId ? { messageId: sourceReportId } : { parentId }),
1112
+ broadcast: true,
1113
+ title: result.ok ? `Deployed: ${folderName}` : `Deployment failed: ${folderName}`,
1114
+ summary,
1115
+ caveats: [...new Set(caveats)],
1116
+ todos: Array.isArray(priorReport?.todos) ? priorReport.todos : [],
1117
+ ...(target.available
1118
+ ? { deployment: { provider: target.provider, prod: target.prod } }
1119
+ : {}),
1120
+ ...((result.ok && result.url) || priorPreviewUrl
1121
+ ? { previewUrl: result.ok ? result.url : priorPreviewUrl }
1122
+ : {}),
1123
+ };
1124
+ await tool("post_report", report);
1125
+ return {
1126
+ status: result.ok ? "folder-deployed" : "deploy-failed",
1127
+ previewUrl: result.ok ? result.url : null,
1128
+ };
1129
+ }
1130
+
893
1131
  /**
894
1132
  * FOLDER MODE (0322). A channel with NO linked GitHub repo but a `folders`
895
1133
  * mapping runs the coding CLI directly in that folder and applies changes in
@@ -919,6 +1157,12 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
919
1157
  });
920
1158
  return { status: "no-path" };
921
1159
  }
1160
+ const deployTarget = deps.resolveDeployTarget({
1161
+ cfg,
1162
+ channelId,
1163
+ folderPath,
1164
+ pathExists: deps.pathExists,
1165
+ });
922
1166
 
923
1167
  // 2. Is it a git repo? If so, snapshot the pre-run status so we can (a) report
924
1168
  // exactly what the run changed and (b) revert precisely on reject. We NEVER touch
@@ -1036,6 +1280,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1036
1280
  timeoutMs: cfg.runTimeoutMs,
1037
1281
  label: "coding",
1038
1282
  signal,
1283
+ env: codingChildEnv(cfg),
1039
1284
  onData: (c) => {
1040
1285
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
1041
1286
  if (lines.length) lastLine = lines[lines.length - 1];
@@ -1129,7 +1374,15 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1129
1374
  caveats.push(`The coding agent didn't exit cleanly${why} — review the changes carefully.`);
1130
1375
  }
1131
1376
  const folderName = folderPath.split("/").filter(Boolean).pop() || folderPath;
1132
- return { title: `Folder run: ${folderName}`, summary: parts.join("\n\n"), caveats, todos: [] };
1377
+ return {
1378
+ title: `Folder run: ${folderName}`,
1379
+ summary: parts.join("\n\n"),
1380
+ caveats,
1381
+ todos: [],
1382
+ ...(deployTarget.enabled && deployTarget.available
1383
+ ? { deployment: { provider: deployTarget.provider, prod: deployTarget.prod } }
1384
+ : {}),
1385
+ };
1133
1386
  };
1134
1387
 
1135
1388
  // --- Run ---
@@ -1206,6 +1459,27 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1206
1459
  round += 1;
1207
1460
  }
1208
1461
 
1462
+ if (decision.kind === "deploy") {
1463
+ return await handleFolderDeploy({
1464
+ message: {
1465
+ ...message,
1466
+ deployRequest: {
1467
+ reportMessageId,
1468
+ provider: decision.provider,
1469
+ prod: decision.prod,
1470
+ },
1471
+ },
1472
+ channelId,
1473
+ tool,
1474
+ cfg,
1475
+ deps,
1476
+ signal,
1477
+ parentId: threadRoot,
1478
+ folderPath,
1479
+ sourceReportId: reportMessageId,
1480
+ });
1481
+ }
1482
+
1209
1483
  // --- Terminal ---
1210
1484
  if (decision.kind === "approved") {
1211
1485
  await tool("post_message", {
@@ -1268,7 +1542,10 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1268
1542
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
1269
1543
  * cancels an in-flight run — the queue fires it when a human says "stop". */
1270
1544
  export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
1271
- const deps = depsOverride || defaultDeps();
1545
+ if (message.dispatch?.briefMarkdown) {
1546
+ message = { ...message, body: `${message.body}\n\n${message.dispatch.briefMarkdown}` };
1547
+ }
1548
+ const deps = depsOverride ? { ...defaultDeps(), ...depsOverride } : defaultDeps();
1272
1549
  const git = deps.git;
1273
1550
  const signal = opts.signal;
1274
1551
  // When the mention was a thread reply, keep the whole exchange in that thread.
@@ -1324,15 +1601,102 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1324
1601
  if (!repoLink) {
1325
1602
  const folderPath = resolveFolderPath(cfg, channelId);
1326
1603
  if (folderPath) {
1604
+ // Durable report-card fallback: list_mentions projects a still-pending
1605
+ // in-place decision into the queue. Deploy only while that decision
1606
+ // remains pending; a settled report is a silent no-op, which prevents a
1607
+ // race with the active folder run from deploying twice.
1608
+ if (message.deployRequest && typeof message.deployRequest.reportMessageId === "string") {
1609
+ if (message.authorRole === "guest") {
1610
+ await tool("post_message", {
1611
+ channelId,
1612
+ parentId,
1613
+ body: "A workspace member needs to request that deployment.",
1614
+ });
1615
+ return { status: "chat" };
1616
+ }
1617
+ const sourceReportId = message.deployRequest.reportMessageId;
1618
+ const current = await tool("get_report", { messageId: sourceReportId }).catch(() => null);
1619
+ if (current?.found && current.report?.decision?.kind !== "deploy") {
1620
+ return { status: "deploy-already-handled" };
1621
+ }
1622
+ return await handleFolderDeploy({
1623
+ message,
1624
+ channelId,
1625
+ tool,
1626
+ cfg,
1627
+ deps,
1628
+ signal,
1629
+ parentId,
1630
+ folderPath,
1631
+ sourceReportId,
1632
+ });
1633
+ }
1634
+
1327
1635
  // Same chat-vs-code decision the repo path uses: mode 'ask'/'ship' short-
1328
1636
  // circuit exactly as in the repo flow, otherwise the LLM router judges intent
1329
1637
  // (its CLI call goes through deps.runCli so folder mode is unit-testable).
1330
- const routed =
1331
- mode === "ask"
1332
- ? { code: false, reply: null }
1333
- : mode === "ship"
1334
- ? { code: true, task: message.body }
1335
- : await routeIntent({
1638
+ let routed;
1639
+ if (mode === "ask") {
1640
+ routed = { code: false, reply: null };
1641
+ } else if (mode === "ship") {
1642
+ routed = { code: true, task: message.body };
1643
+ } else if (caps.agentIntent) {
1644
+ const classifierDeployTarget = deps.resolveDeployTarget({
1645
+ cfg,
1646
+ channelId,
1647
+ folderPath,
1648
+ pathExists: deps.pathExists,
1649
+ });
1650
+ const semantic = await tool("classify_agent_intent", {
1651
+ latestMessage: message.body,
1652
+ transcript: context.transcript,
1653
+ project: folderPath,
1654
+ channelId,
1655
+ allowDeploy: classifierDeployTarget.enabled === true,
1656
+ }).catch(() => null);
1657
+ if (semantic?.reason === "execution-disabled") {
1658
+ await tool("post_message", {
1659
+ channelId,
1660
+ parentId,
1661
+ body: "I can chat here, but agent execution is disabled in this channel.",
1662
+ });
1663
+ return { status: "chat" };
1664
+ }
1665
+ if (
1666
+ semantic?.ok &&
1667
+ message.authorRole === "guest" &&
1668
+ (semantic.mode === "ship" || semantic.mode === "deploy")
1669
+ ) {
1670
+ await tool("post_message", {
1671
+ channelId,
1672
+ parentId,
1673
+ body: "A workspace member needs to confirm that request before I can act on it.",
1674
+ });
1675
+ return { status: "chat" };
1676
+ }
1677
+ if (semantic?.ok && semantic.mode === "deploy") {
1678
+ return await handleFolderDeploy({
1679
+ message,
1680
+ channelId,
1681
+ tool,
1682
+ cfg,
1683
+ deps,
1684
+ signal,
1685
+ parentId,
1686
+ folderPath,
1687
+ sourceReportId: null,
1688
+ });
1689
+ }
1690
+ if (semantic?.ok && semantic.mode === "ship") {
1691
+ routed = { code: true, task: semantic.brief || message.body };
1692
+ } else if (semantic?.ok && semantic.mode === "ask") {
1693
+ routed = { code: false, reply: null };
1694
+ }
1695
+ }
1696
+ // Capability or classifier failure: preserve the previous semantic local
1697
+ // CLI router. There is still no keyword/regex intent list.
1698
+ if (!routed) {
1699
+ routed = await routeIntent({
1336
1700
  name: me?.agentName || "an assistant",
1337
1701
  repoFullName: folderPath,
1338
1702
  transcript: context.transcript,
@@ -1341,6 +1705,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1341
1705
  signal,
1342
1706
  runCliFn: deps.runCli,
1343
1707
  });
1708
+ }
1344
1709
  if (routed.aborted || signal?.aborted) {
1345
1710
  await tool("post_message", { channelId, parentId, body: "Stopped." });
1346
1711
  return { status: "chat" };
@@ -1426,7 +1791,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1426
1791
  if (!body) {
1427
1792
  body =
1428
1793
  routed.error?.code === "ENOENT"
1429
- ? `(my chat command \`${cfg.chatCmd || cfg.codingCmd}\` isn't installed or on PATH.)`
1794
+ ? `(my chat command \`${chatCmdFor(cfg)}\` isn't installed or on PATH.)`
1430
1795
  : `Still thinking on this — it's taking longer than usual. I'll follow up shortly.`;
1431
1796
  }
1432
1797
  await tool("post_message", { channelId, parentId, body });
@@ -1632,8 +1997,28 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1632
1997
  // new → record a fresh run, exactly as before.
1633
1998
  // Best-effort throughout — a bookkeeping failure (or an older server without the
1634
1999
  // runs tools) must NEVER break the run, so it proceeds without a runId.
1635
- let runId = effectiveMode === "iterate" ? activeRun?.runId ?? null : null;
1636
- if (caps.runs && threadRoot && effectiveMode !== "iterate") {
2000
+ let runId = message.dispatch?.runId ?? (effectiveMode === "iterate" ? activeRun?.runId ?? null : null);
2001
+ if (message.dispatch?.runId && caps.runs) {
2002
+ // Adopt the dispatch's canonical run. If the server already SETTLED it (the
2003
+ // reaper/outbox closed a dispatch we were slow to pick up), leave it alone
2004
+ // and skip the coding run — resurrecting it would ship work no one is
2005
+ // waiting on. Any OTHER failure (network, an older server without the guard)
2006
+ // keeps today's best-effort behavior: proceed with the run.
2007
+ try {
2008
+ await tool("update_run", { runId: message.dispatch.runId, status: "running" });
2009
+ } catch (e) {
2010
+ if (String(e?.message || "").includes("already settled")) {
2011
+ await tool("post_message", {
2012
+ channelId,
2013
+ parentId,
2014
+ body: "This dispatch was already closed on the server, so I left it alone. Approve it again if it's still wanted.",
2015
+ }).catch(() => {});
2016
+ return { status: "dispatch-settled" };
2017
+ }
2018
+ /* older server or a transient error — proceed best-effort */
2019
+ }
2020
+ }
2021
+ if (caps.runs && threadRoot && effectiveMode !== "iterate" && !message.dispatch?.runId) {
1637
2022
  try {
1638
2023
  if (effectiveMode === "redirect" && activeRun?.runId) {
1639
2024
  await tool("update_run", { runId: activeRun.runId, status: "superseded" }).catch(() => {});
@@ -1644,7 +2029,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1644
2029
  taskText: message.body,
1645
2030
  branch,
1646
2031
  // Map an unrecognized command to null rather than the off-vocabulary
1647
- // "unknown" — provider is documented as claude_code|codex|cursor|hilos.
2032
+ // "unknown" — provider is documented as
2033
+ // claude_code|codex|cursor|antigravity|hermes|hilos.
1648
2034
  provider: (() => {
1649
2035
  const v = detectVendor(cfg.codingCmd);
1650
2036
  return v === "unknown" ? null : v;
@@ -1657,7 +2043,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1657
2043
  }
1658
2044
  // Keep the inspectable continuation/redirect statement (don't overwrite it with
1659
2045
  // an LLM plan) so a human can correct the routing before code lands.
1660
- if (ackId && caps.editMessage && cfg.chatCmd && !continuingPrUrl && effectiveMode !== "redirect") {
2046
+ if (ackId && caps.editMessage && chatCmdFor(cfg) && !continuingPrUrl && effectiveMode !== "redirect") {
1661
2047
  // Feed the ack the router's distilled brief AND the conversation — not the raw
1662
2048
  // mention — so it states a real plan instead of "what's the task?".
1663
2049
  const plan = await proposePlanAck({
@@ -1835,6 +2221,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1835
2221
  timeoutMs: cfg.runTimeoutMs,
1836
2222
  label: "coding",
1837
2223
  signal,
2224
+ env: codingChildEnv(cfg),
1838
2225
  onData: (c) => {
1839
2226
  // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
1840
2227
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
@@ -21,10 +21,11 @@ import { createStepRing, sanitizeText } from "./agent-events.mjs";
21
21
  /**
22
22
  * Which parser/stream-flags a coding command wants, from its FIRST token.
23
23
  * `claude`/`claude-code` → claude_code, `codex` → codex,
24
- * `cursor`/`cursor-agent` → cursor, everything else unknown. Handles an
24
+ * `cursor`/`cursor-agent` → cursor, `agy`/`antigravity`antigravity,
25
+ * `hermes` → hermes, everything else → unknown. Handles an
25
26
  * absolute path (`/usr/local/bin/claude`) by taking the basename.
26
27
  * @param {string} codingCmd
27
- * @returns {'claude_code'|'codex'|'cursor'|'unknown'}
28
+ * @returns {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'}
28
29
  */
29
30
  export function detectVendor(codingCmd) {
30
31
  const first = String(codingCmd || "").trim().split(/\s+/)[0] || "";
@@ -32,16 +33,40 @@ export function detectVendor(codingCmd) {
32
33
  if (base === "claude" || base === "claude-code" || base === "claude_code") return "claude_code";
33
34
  if (base === "codex") return "codex";
34
35
  if (base === "cursor" || base === "cursor-agent") return "cursor";
36
+ if (base === "agy" || base === "antigravity") return "antigravity";
37
+ if (base === "hermes") return "hermes";
35
38
  return "unknown";
36
39
  }
37
40
 
41
+ /**
42
+ * The FAST one-shot chat command for a coding vendor — used when the user didn't
43
+ * set chatCmd explicitly, so a Codex/Cursor/Antigravity daemon never needs Claude
44
+ * Code installed just to answer chat (0521). Every command is the vendor's
45
+ * verified non-interactive print mode; the daemon appends the prompt as the last
46
+ * arg. codex carries --skip-git-repo-check because chat (and the read-only
47
+ * review sandbox) can run outside a git checkout. cursor carries
48
+ * --output-format text because its -p default is stream-json, which would post
49
+ * raw JSONL into the channel. Returns "" for unknown (caller falls back to
50
+ * codingCmd).
51
+ * @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
52
+ * @returns {string}
53
+ */
54
+ export function fastChatCmd(vendor) {
55
+ if (vendor === "claude_code") return "claude -p --model claude-haiku-4-5";
56
+ if (vendor === "codex") return "codex exec --skip-git-repo-check";
57
+ if (vendor === "cursor") return "cursor-agent -p --output-format text";
58
+ if (vendor === "antigravity") return "agy -p";
59
+ if (vendor === "hermes") return "hermes -z";
60
+ return "";
61
+ }
62
+
38
63
  /**
39
64
  * Extra args to make the code run EMIT a structured stream, appended to the code
40
65
  * run's argv (NOT the display string) and ONLY for the code run. Only claude_code
41
66
  * has a proven flag (`--output-format stream-json --verbose`, per lib/agent-cli.ts
42
67
  * + scripts/verify-sandbox-mcp.mjs). codex/cursor return [] — their stream flags
43
68
  * are deferred to 0278 rather than guessed (an unproven flag could break the run).
44
- * @param {'claude_code'|'codex'|'cursor'|'unknown'} vendor
69
+ * @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
45
70
  * @returns {string[]}
46
71
  */
47
72
  export function codeStreamArgs(vendor) {
package/src/redact.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // Credential redaction shared by hosted transcript persistence and local deploy
2
+ // output. Keep this dependency-free so the published hilos-agent package can
3
+ // redact CLI output before it is returned, logged, or attached to a report.
4
+
5
+ const PEM_BLOCK = /-----BEGIN [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----[\s\S]*?-----END [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----/g;
6
+ const JWT = /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}(?:\.[A-Za-z0-9_-]{6,})?\b/g;
7
+ const URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)([^\/\s:@"']+):([^\/\s@"']+)@/gi;
8
+ const PROVIDER_TOKENS = new RegExp(
9
+ [
10
+ "sk-ant-[A-Za-z0-9_-]{8,}",
11
+ "sk-proj-[A-Za-z0-9_-]{8,}",
12
+ "sk-ws-[A-Za-z0-9._-]{16,}",
13
+ "sk-[A-Za-z0-9]{20,}",
14
+ "gh[pousr]_[A-Za-z0-9]{20,}",
15
+ "github_pat_[A-Za-z0-9_]{20,}",
16
+ "xox[baprs]-[A-Za-z0-9-]{10,}",
17
+ "sbp_[A-Za-z0-9]{20,}",
18
+ "sb_secret_[A-Za-z0-9_-]{10,}",
19
+ "AKIA[0-9A-Z]{16}",
20
+ "ASIA[0-9A-Z]{16}",
21
+ "AIza[A-Za-z0-9_-]{30,}",
22
+ "hilos_live_[A-Za-z0-9_-]{8,}",
23
+ "vercel_[A-Za-z0-9]{20,}",
24
+ "re_[A-Za-z0-9]{20,}",
25
+ ].join("|"),
26
+ "g",
27
+ );
28
+ const BEARER = /(\b[Bb]earer["']?[\s:=]+["']?)[A-Za-z0-9._~+/=-]{12,}/g;
29
+ const ASSIGNMENT =
30
+ /((?:api[_-]?key|access[_-]?key|secret[_-]?access[_-]?key|client[_-]?secret|private[_-]?key|service[_-]?role[_-]?key|auth[_-]?token|refresh[_-]?token|session[_-]?token|apikey|token|secret|password|passwd|credential)["']?\s*[:=]+\s*["']?)(?!\[redacted:)([A-Za-z0-9_~.+/-]{8,})/gi;
31
+
32
+ /** Replace credential-shaped content with typed markers. Idempotent. */
33
+ export function redactSecrets(text) {
34
+ if (!text) return text;
35
+ return text
36
+ .replace(PEM_BLOCK, "[redacted:pem]")
37
+ .replace(JWT, "[redacted:jwt]")
38
+ .replace(URL_CREDENTIALS, "$1[redacted:url-credentials]@")
39
+ .replace(PROVIDER_TOKENS, "[redacted:token]")
40
+ .replace(BEARER, "$1[redacted:token]")
41
+ .replace(ASSIGNMENT, (match, prefix, _value, offset, source) => {
42
+ if (source[offset + match.length] === "(") return match;
43
+ return `${prefix}[redacted:value]`;
44
+ });
45
+ }
46
+
47
+ /** Keep the transcript tail at a whole-line boundary. */
48
+ export function truncateTranscriptTail(text, maxChars) {
49
+ if (text.length <= maxChars) return text;
50
+ const tail = text.slice(text.length - maxChars);
51
+ const newline = tail.indexOf("\n");
52
+ const clean = newline >= 0 ? tail.slice(newline + 1) : tail;
53
+ return `{"type":"hilos_truncated","note":"earlier transcript trimmed to fit the storage cap"}\n${clean}`;
54
+ }
package/src/resume.mjs CHANGED
@@ -41,7 +41,7 @@ export const STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
41
41
  * degrade to today's branch+feedback iterate (deferred to 0278/later). A falsy or
42
42
  * non-string sessionId also returns [] (nothing to resume).
43
43
  *
44
- * @param {'claude_code'|'codex'|'cursor'|'unknown'} vendor
44
+ * @param {'claude_code'|'codex'|'cursor'|'hermes'|'unknown'} vendor
45
45
  * @param {string|null|undefined} sessionId
46
46
  * @returns {string[]}
47
47
  */
package/src/run.mjs CHANGED
@@ -78,7 +78,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
78
78
 
79
79
  const since = cfg.backfill ? 0 : Date.now();
80
80
  const toolNames = await listToolNames();
81
- const useMentions = !cfg.channelId && toolNames.includes("list_mentions");
81
+ const useMentions = toolNames.includes("list_mentions");
82
82
  // Capabilities of THIS server, so the handler degrades gracefully on older
83
83
  // deploys (e.g. no edit_message → no live heartbeat, rather than erroring).
84
84
  const caps = {
@@ -96,6 +96,9 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
96
96
  // coding agent has the workspace's conventions and gotchas from the start.
97
97
  // Absent on older servers → silently skipped, no change in behavior.
98
98
  recall: toolNames.includes("recall"),
99
+ // Semantic local-folder intent (0515): the server guarantees a forced-tool
100
+ // ask/ship/deploy decision. Older servers fall back to the local router.
101
+ agentIntent: toolNames.includes("classify_agent_intent"),
99
102
  };
100
103
 
101
104
  // Register local folders (0324/0325): a folder-mode daemon announces each
@@ -194,7 +197,10 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
194
197
  log.log(" (deduped a repeat of an in-flight/queued ask)");
195
198
  return;
196
199
  }
197
- if (liveCfg.queueAcks && !r.startedImmediately) {
200
+ // A report-card deploy decision is already visible in place. list_mentions
201
+ // projects the pending decision into this structured event, so don't add a
202
+ // misleading "queued" chat line while the active run settles it.
203
+ if (liveCfg.queueAcks && !r.startedImmediately && !m.deployRequest) {
198
204
  await tool("post_message", {
199
205
  channelId,
200
206
  parentId: m.parentId ?? null,
@@ -204,7 +210,11 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
204
210
  }
205
211
 
206
212
  async function passViaMentions() {
207
- const out = await tool("list_mentions", cursor.value ? { since: cursor.value } : {});
213
+ const mentionArgs = {
214
+ ...(cursor.value ? { since: cursor.value } : {}),
215
+ ...(cfg.channelId ? { channelId: cfg.channelId } : {}),
216
+ };
217
+ const out = await tool("list_mentions", mentionArgs);
208
218
  const mentions = (out?.mentions ?? []).slice().reverse();
209
219
  for (const m of mentions) {
210
220
  if (seen.has(m.id)) continue;