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 +25 -4
- package/bin/hilos-agent.mjs +11 -4
- package/package.json +1 -1
- package/src/cli.mjs +97 -1
- package/src/config.mjs +24 -3
- package/src/deploy.mjs +234 -0
- package/src/handler.mjs +410 -23
- package/src/progress-emitter.mjs +28 -3
- package/src/redact.mjs +54 -0
- package/src/resume.mjs +1 -1
- package/src/run.mjs +13 -3
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
|
|
|
@@ -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
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -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
|
|
59
|
-
|
|
60
|
-
(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)
|
|
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
|
-
|
|
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.
|
|
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
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
59
|
-
|
|
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)
|
|
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
|
|
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
|
|
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 (
|
|
601
|
-
// by chatTimeoutMs, so a casual reply lands in seconds
|
|
602
|
-
// dead-air the channel for the full coding timeout.
|
|
603
|
-
const cmd = cfg
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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);
|
package/src/progress-emitter.mjs
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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;
|