hilos-agent 0.11.12 → 0.11.13
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/package.json +1 -1
- package/src/acp-session.mjs +1 -29
- package/src/agent-events.mjs +14 -17
- package/src/agent-result.mjs +54 -0
- package/src/child-mcp.mjs +2 -2
- package/src/claude-permissions.mjs +21 -42
- package/src/cli.mjs +2 -2
- package/src/codex-mcp-session.mjs +1 -10
- package/src/config.mjs +5 -7
- package/src/daemon.mjs +8 -13
- package/src/deploy.mjs +1 -1
- package/src/github-artifacts.mjs +49 -35
- package/src/handler.mjs +225 -214
- package/src/hook.mjs +15 -47
- package/src/local-harness-events.mjs +0 -11
- package/src/mcp.mjs +17 -29
- package/src/opencode-permissions.mjs +2 -34
- package/src/opencode-session.mjs +1 -10
- package/src/permission-gate.mjs +1 -25
- package/src/progress-emitter.mjs +1 -6
- package/src/queue.mjs +1 -5
- package/src/reply-bridge.mjs +13 -11
- package/src/run.mjs +6 -27
- package/src/util.mjs +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.13",
|
|
4
4
|
"description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. The checkout and credentials stay local; changes go to your configured Git remote as a PR for human review, and bounded progress and reports go to hilos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/acp-session.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
20
|
import { createAcpEventMapper } from "./agent-events.mjs";
|
|
21
21
|
import { resolveHilosPermissionReply } from "./permission-gate.mjs";
|
|
22
|
+
import { abortError, isObject, raceWithAbort } from "./util.mjs";
|
|
22
23
|
|
|
23
24
|
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
24
25
|
const DEFAULT_POLL_MS = 1_000;
|
|
@@ -26,35 +27,6 @@ const SHUTDOWN_GRACE_MS = 750;
|
|
|
26
27
|
const PROTOCOL_VERSION = 1;
|
|
27
28
|
const MAX_LINE_BYTES = 4 * 1024 * 1024;
|
|
28
29
|
|
|
29
|
-
function isObject(value) {
|
|
30
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function abortError(reason = "cancelled") {
|
|
34
|
-
const error = new Error(String(reason || "cancelled"));
|
|
35
|
-
error.name = "AbortError";
|
|
36
|
-
return error;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function raceWithAbort(promise, signal) {
|
|
40
|
-
if (!signal) return promise;
|
|
41
|
-
if (signal.aborted) return Promise.reject(abortError(signal.reason));
|
|
42
|
-
return new Promise((resolve, reject) => {
|
|
43
|
-
const onAbort = () => reject(abortError(signal.reason));
|
|
44
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
45
|
-
Promise.resolve(promise).then(
|
|
46
|
-
(value) => {
|
|
47
|
-
signal.removeEventListener("abort", onAbort);
|
|
48
|
-
resolve(value);
|
|
49
|
-
},
|
|
50
|
-
(error) => {
|
|
51
|
-
signal.removeEventListener("abort", onAbort);
|
|
52
|
-
reject(error);
|
|
53
|
-
},
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
30
|
/**
|
|
59
31
|
* Choose the agent's option id for a hilos reply ("once"|"always"|"reject").
|
|
60
32
|
* Matches on the ACP option `kind` first (allow_once / allow_always /
|
package/src/agent-events.mjs
CHANGED
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
// stripped, length capped) — a chatty CLI can echo a `Bearer …` header.
|
|
17
17
|
//
|
|
18
18
|
// Claude shapes here MATCH `lib/hosted-worklog.ts` / `lib/hosted-agent.ts`
|
|
19
|
-
// (`formatClaudeEvent`, `parseStreamResult`) so
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
// (`formatClaudeEvent`, `parseStreamResult`) so the daemon and the hosted path
|
|
20
|
+
// render the same run from the same shapes (0274).
|
|
21
|
+
|
|
22
|
+
import { COMMAND_LABEL_MAX, truncate } from "./util.mjs";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* @typedef {object} AgentEvent
|
|
26
26
|
* A normalized, render-ready step. Exactly one of these per meaningful thing the
|
|
27
27
|
* coding agent did. Small on purpose — the LiveRunCard maps `t` to a label.
|
|
28
|
-
* @property {'
|
|
28
|
+
* @property {'edit'|'run'|'read'|'think'|'note'|'session'|'result'|'usage'|'websearch'|'webfetch'|'subagent'} t
|
|
29
29
|
* - 'session' → { sessionId } the CLI's resumable session id (init only)
|
|
30
30
|
* - 'edit' → { path } wrote/edited a file
|
|
31
31
|
* - 'read' → { path } read a file
|
|
@@ -44,9 +44,6 @@
|
|
|
44
44
|
* reported it (0787). Never a step — the
|
|
45
45
|
* ring and the activity fold skip it; it
|
|
46
46
|
* rides home on the report instead.
|
|
47
|
-
* - 'phase' → { name } reserved lifecycle marker (unused by v1
|
|
48
|
-
* parsers; kept so 0274 can add start/end
|
|
49
|
-
* phases without widening the type)
|
|
50
47
|
* @property {string} [sessionId]
|
|
51
48
|
* @property {string} [path]
|
|
52
49
|
* @property {string} [cmd]
|
|
@@ -95,7 +92,7 @@ export function sanitizeText(value) {
|
|
|
95
92
|
if (typeof value !== "string") return "";
|
|
96
93
|
let out = value;
|
|
97
94
|
for (const re of SECRET_PATTERNS) out = out.replace(re, REDACTED);
|
|
98
|
-
|
|
95
|
+
out = truncate(out, MAX_LEN);
|
|
99
96
|
return out;
|
|
100
97
|
}
|
|
101
98
|
|
|
@@ -269,7 +266,7 @@ function webPhrase(value) {
|
|
|
269
266
|
// would render.
|
|
270
267
|
const clean = sanitizeText(raw);
|
|
271
268
|
if (!clean) return "";
|
|
272
|
-
return clean
|
|
269
|
+
return truncate(clean, WEB_LABEL_MAX);
|
|
273
270
|
}
|
|
274
271
|
|
|
275
272
|
/**
|
|
@@ -879,7 +876,7 @@ function normalizeVendor(vendor) {
|
|
|
879
876
|
// --- Streaming parsers -----------------------------------------------------
|
|
880
877
|
|
|
881
878
|
/**
|
|
882
|
-
* The daemon pushes raw stdout chunks (
|
|
879
|
+
* The daemon pushes raw stdout chunks (handler.mjs `handleCliData`) that can
|
|
883
880
|
* split a JSON object mid-line. A structured parser buffers the partial tail and
|
|
884
881
|
* only parses COMPLETE lines; flush() drains a trailing unterminated line.
|
|
885
882
|
* @param {(line: string) => AgentEvent[]} parseLine
|
|
@@ -913,7 +910,7 @@ function makeLineBufferedParser(parseLine) {
|
|
|
913
910
|
* Fallback for any vendor with no structured stream (cursor graduated to a
|
|
914
911
|
* real parser in 0573). There's nothing to parse, so we only remember the last
|
|
915
912
|
* non-empty line seen — matching exactly what the daemon does today
|
|
916
|
-
* (handler.mjs
|
|
913
|
+
* (handler.mjs `handleCliData`) so we NEVER regress below it — and emit at most one
|
|
917
914
|
* sparse note on flush.
|
|
918
915
|
*/
|
|
919
916
|
function makeTextTailParser() {
|
|
@@ -964,13 +961,13 @@ function describeRun(cmd) {
|
|
|
964
961
|
const c = String(cmd || "").trim();
|
|
965
962
|
if (!c) return "Running a command";
|
|
966
963
|
if (/\b(test|tests)\b|vitest|jest|pytest|\bgo test\b/i.test(c)) return "Running tests";
|
|
967
|
-
const short = c
|
|
964
|
+
const short = truncate(c, COMMAND_LABEL_MAX);
|
|
968
965
|
return `Running ${short}`;
|
|
969
966
|
}
|
|
970
967
|
|
|
971
968
|
/**
|
|
972
969
|
* One AgentEvent → a human step label, or null for events that aren't shown as
|
|
973
|
-
* steps (session
|
|
970
|
+
* steps (session events are metadata). Pure.
|
|
974
971
|
* @param {AgentEvent} event
|
|
975
972
|
* @returns {string | null}
|
|
976
973
|
*/
|
|
@@ -993,7 +990,7 @@ export function stepLabel(event) {
|
|
|
993
990
|
case "result":
|
|
994
991
|
return event.ok ? "Done" : "Finished with errors";
|
|
995
992
|
default:
|
|
996
|
-
return null; // session /
|
|
993
|
+
return null; // session / unknown → not a step
|
|
997
994
|
}
|
|
998
995
|
}
|
|
999
996
|
|
|
@@ -1081,7 +1078,7 @@ function activitySentence(row) {
|
|
|
1081
1078
|
* truth a CLI stream carries, so "done" is the sequential-execution inference:
|
|
1082
1079
|
* a new action starting settles the one in flight. `settle("failed")` is for a
|
|
1083
1080
|
* dying run — leaving "Editing…" forever is the dishonesty this feed exists to
|
|
1084
|
-
* avoid. session
|
|
1081
|
+
* avoid. session events are metadata, not rows (same as stepLabel).
|
|
1085
1082
|
* @param {number} [limit]
|
|
1086
1083
|
*/
|
|
1087
1084
|
export function createActivityFold(limit = MAX_ACTIVITY) {
|
|
@@ -1151,7 +1148,7 @@ export function createActivityFold(limit = MAX_ACTIVITY) {
|
|
|
1151
1148
|
});
|
|
1152
1149
|
return;
|
|
1153
1150
|
default:
|
|
1154
|
-
return; // session /
|
|
1151
|
+
return; // session / unknown — metadata, not activity
|
|
1155
1152
|
}
|
|
1156
1153
|
}
|
|
1157
1154
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// 1298 — one writing contract and final-result envelope for hosted and local runs.
|
|
2
|
+
export const AGENT_REPLY_STYLE_RULE = [
|
|
3
|
+
"How to write the reply:",
|
|
4
|
+
"- Lead with the answer or outcome. Never restate the request.",
|
|
5
|
+
"- Default to 1-3 short sentences, or at most three short bullets for separate outcomes.",
|
|
6
|
+
"- Include useful links once. No preamble, headings, sign-off, process narration, or routine test/file lists.",
|
|
7
|
+
"- Keep blockers, material limitations, and a needed next step visible. Never claim checks, deployment, or merging without evidence.",
|
|
8
|
+
"- Give requested documents, plans, code, research, and explanations the detail they need; keep their introduction short. Put code and commands in fenced blocks.",
|
|
9
|
+
"The room's voice may shape tone, but only a person's request for detail expands this default. Clarity beats a word limit.",
|
|
10
|
+
].join("\n");
|
|
11
|
+
|
|
12
|
+
export const AGENT_CODE_RESULT_RULE = [
|
|
13
|
+
AGENT_REPLY_STYLE_RULE,
|
|
14
|
+
"When the coding turn finishes, return one fenced hilos-result JSON object with three string fields:",
|
|
15
|
+
'{"summary":"The short result for the teammate.","prTitle":"A short title describing the actual problem fixed","prDescription":"The problem, resulting behavior, and relevant verification."}',
|
|
16
|
+
"Write summary for someone who may never open the code. Do not repeat the PR title, request, branch, file counts, or tool history. Include material limitations.",
|
|
17
|
+
"Write prTitle and prDescription as the pull request itself for a reviewer opening it cold. Title: under 60 characters, no trailing period or request echo. Description: usually 1-2 short paragraphs; only actual checks. No transcript, preamble, boilerplate, or attribution footer; hilos adds credit.",
|
|
18
|
+
"If you create or update the PR yourself, use those same title and description fields on GitHub. Describe the entire final PR change, including prior work on an iteration. Preserve text a person edited.",
|
|
19
|
+
"Return empty PR fields when there is no code change. A VISUAL_PREVIEW: /route line may follow the fence.",
|
|
20
|
+
].join("\n");
|
|
21
|
+
|
|
22
|
+
/** Keep complete artifacts, never half a sentence, Markdown link, or JSON object. */
|
|
23
|
+
export function retainAgentResult(value) {
|
|
24
|
+
return typeof value === "string" && value.length <= 32_000 ? value.trim() : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @returns {{ summary: string, prTitle: string, prDescription: string } | null} */
|
|
28
|
+
export function readAgentResult(value) {
|
|
29
|
+
const text = retainAgentResult(value);
|
|
30
|
+
const fenced = /```hilos-result\s*\n([\s\S]*?)\n```/.exec(text);
|
|
31
|
+
const candidate = fenced?.[1] ?? (text.startsWith("{") ? text : null);
|
|
32
|
+
if (!candidate) return null;
|
|
33
|
+
try {
|
|
34
|
+
const result = JSON.parse(candidate);
|
|
35
|
+
if (!result || typeof result !== "object" || typeof result.summary !== "string" ||
|
|
36
|
+
typeof result.prTitle !== "string" || typeof result.prDescription !== "string") return null;
|
|
37
|
+
return {
|
|
38
|
+
summary: result.summary.trim(),
|
|
39
|
+
prTitle: result.prTitle.trim(),
|
|
40
|
+
prDescription: result.prDescription.trim(),
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Legacy prose remains readable; an invalid machine envelope never enters chat. */
|
|
48
|
+
export function agentResultText(value) {
|
|
49
|
+
const text = retainAgentResult(value);
|
|
50
|
+
const result = readAgentResult(text);
|
|
51
|
+
if (result) return result.summary;
|
|
52
|
+
if (text.includes("```hilos-result") || /^\s*\{\s*"(?:summary|prTitle|prDescription)"/.test(text)) return "";
|
|
53
|
+
return text.replace(/^\s*VISUAL_PREVIEW:.*$/gm, "").trim();
|
|
54
|
+
}
|
package/src/child-mcp.mjs
CHANGED
|
@@ -147,7 +147,7 @@ export function childMcpPlanned({ cfg, vendor, bindingClaim, env } = {}) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/** The `mcpServers` fragment for a claude `--mcp-config` JSON. */
|
|
150
|
-
|
|
150
|
+
function childMcpServers(url) {
|
|
151
151
|
return { [CHILD_MCP_SERVER]: { type: "http", url } };
|
|
152
152
|
}
|
|
153
153
|
|
|
@@ -159,7 +159,7 @@ export function childMcpServers(url) {
|
|
|
159
159
|
* config file instead — claude takes `--mcp-config` more than once, but one
|
|
160
160
|
* file the run owns end-to-end is one file to delete.
|
|
161
161
|
*/
|
|
162
|
-
|
|
162
|
+
function writeChildMcpConfig(url, { dir = os.tmpdir() } = {}) {
|
|
163
163
|
const configPath = path.join(
|
|
164
164
|
dir,
|
|
165
165
|
`hilos-child-mcp-${process.pid}-${randomBytes(6).toString("hex")}.json`,
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
DEFAULT_PERMISSION_TIMEOUT_MS,
|
|
39
39
|
resolveHilosPermissionReply,
|
|
40
40
|
} from "./permission-gate.mjs";
|
|
41
|
+
import { isObject } from "./util.mjs";
|
|
41
42
|
|
|
42
43
|
/** The MCP server key and tool name the daemon serves. Together they form the
|
|
43
44
|
* `mcp__<server>__<tool>` identifier claude's flag takes. */
|
|
@@ -54,10 +55,6 @@ const DEFAULT_SESSION_ID_WAIT_MS = 10_000;
|
|
|
54
55
|
/** Tool names whose input names a file rather than a command. */
|
|
55
56
|
const FILE_INPUT_KEYS = ["file_path", "path", "notebook_path"];
|
|
56
57
|
|
|
57
|
-
function isObject(value) {
|
|
58
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
58
|
/**
|
|
62
59
|
* Shape claude's permission-prompt payload into the SAME vendor-neutral request
|
|
63
60
|
* the opencode/ACP card flow already produces, so the card, its audit row, and
|
|
@@ -184,9 +181,6 @@ export function claudePermissionArgs({
|
|
|
184
181
|
];
|
|
185
182
|
}
|
|
186
183
|
|
|
187
|
-
/** The flags to strip when a CLI turns out not to know them (compat retry). */
|
|
188
|
-
export const CLAUDE_PERMISSION_FLAGS = ["--mcp-config", "--permission-prompt-tool"];
|
|
189
|
-
|
|
190
184
|
/**
|
|
191
185
|
* Serve the permission tool on loopback for the life of one run.
|
|
192
186
|
*
|
|
@@ -209,8 +203,7 @@ export const CLAUDE_PERMISSION_FLAGS = ["--mcp-config", "--permission-prompt-too
|
|
|
209
203
|
* allowedTools?: string,
|
|
210
204
|
* }} options
|
|
211
205
|
* @returns {Promise<{ configPath: string, toolId: string, url: string, port: number,
|
|
212
|
-
* args: string[],
|
|
213
|
-
* close: () => Promise<void> }>}
|
|
206
|
+
* args: string[], close: () => Promise<void> }>}
|
|
214
207
|
*/
|
|
215
208
|
export async function startClaudePermissionServer({
|
|
216
209
|
requestPermission,
|
|
@@ -235,7 +228,6 @@ export async function startClaudePermissionServer({
|
|
|
235
228
|
}
|
|
236
229
|
const token = randomBytes(24).toString("hex");
|
|
237
230
|
let seq = 0;
|
|
238
|
-
let inflight = 0;
|
|
239
231
|
let currentSessionId = String(sessionId || "");
|
|
240
232
|
// hilos's server REJECTS a permission request with an empty vendorSessionId,
|
|
241
233
|
// so a fresh run (no session to resume) must not ask with one — every card
|
|
@@ -373,33 +365,25 @@ export async function startClaudePermissionServer({
|
|
|
373
365
|
sessionId: await sessionIdForAsk(),
|
|
374
366
|
fallbackId: String(++seq),
|
|
375
367
|
});
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
});
|
|
396
|
-
decisionReply = resolved.reply;
|
|
397
|
-
outcome = resolved.outcome;
|
|
398
|
-
byPolicy = resolved.byPolicy === true;
|
|
399
|
-
policyReason = resolved.policyReason ?? null;
|
|
400
|
-
} finally {
|
|
401
|
-
inflight--;
|
|
402
|
-
}
|
|
368
|
+
const resolved = await resolveHilosPermissionReply({
|
|
369
|
+
request,
|
|
370
|
+
requestPermission,
|
|
371
|
+
getPermissionDecision,
|
|
372
|
+
signal,
|
|
373
|
+
timeoutMs,
|
|
374
|
+
pollIntervalMs,
|
|
375
|
+
log,
|
|
376
|
+
label: "claude permission",
|
|
377
|
+
localAllow,
|
|
378
|
+
});
|
|
379
|
+
const decisionReply = resolved.reply;
|
|
380
|
+
const outcome = resolved.outcome;
|
|
381
|
+
// byPolicy is set only when a workspace RULE refused this ask (0813); a
|
|
382
|
+
// person's rejection leaves it false and keeps the human wording.
|
|
383
|
+
// Provenance is tracked apart from the reason because a rule may carry no
|
|
384
|
+
// reason.
|
|
385
|
+
const byPolicy = resolved.byPolicy === true;
|
|
386
|
+
const policyReason = resolved.policyReason ?? null;
|
|
403
387
|
// A rejection is never an MCP error: an isError result makes claude retry
|
|
404
388
|
// or improvise around the block. A plain deny result is the refusal the
|
|
405
389
|
// CLI is built to respect.
|
|
@@ -452,11 +436,6 @@ export async function startClaudePermissionServer({
|
|
|
452
436
|
url,
|
|
453
437
|
port,
|
|
454
438
|
args: claudePermissionArgs({ configPath, allowedTools }),
|
|
455
|
-
/** Let the caller stamp the CLI's real session id onto later cards. */
|
|
456
|
-
setSessionId(value) {
|
|
457
|
-
if (typeof value === "string" && value) currentSessionId = value;
|
|
458
|
-
},
|
|
459
|
-
pending: () => inflight,
|
|
460
439
|
async close() {
|
|
461
440
|
try {
|
|
462
441
|
fs.unlinkSync(configPath);
|
package/src/cli.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// logs a periodic "still working…" heartbeat so the run visibly stays alive.
|
|
7
7
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { truncate } from "./util.mjs";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Let the daemon finish its existing abort/cleanup path before exiting.
|
|
@@ -150,8 +151,7 @@ export function fmtElapsed(ms) {
|
|
|
150
151
|
|
|
151
152
|
/** Collapse to one trimmed, length-capped line (for a heartbeat's "Latest:" tail). */
|
|
152
153
|
export function oneLine(s, max = 140) {
|
|
153
|
-
|
|
154
|
-
return t.length > max ? t.slice(0, max - 1) + "…" : t;
|
|
154
|
+
return truncate(String(s || "").replace(/\s+/g, " ").trim(), max);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
DEFAULT_PERMISSION_TIMEOUT_MS,
|
|
59
59
|
resolveHilosPermissionReply,
|
|
60
60
|
} from "./permission-gate.mjs";
|
|
61
|
+
import { isObject } from "./util.mjs";
|
|
61
62
|
|
|
62
63
|
// Codex 0.153.4 accepts on-request/never on its MCP tool. `untrusted`
|
|
63
64
|
// is rejected before a turn starts. Keep the workspace sandbox and route its
|
|
@@ -65,16 +66,6 @@ import {
|
|
|
65
66
|
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
66
67
|
const SHUTDOWN_GRACE_MS = 750;
|
|
67
68
|
|
|
68
|
-
function isObject(value) {
|
|
69
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function abortError(reason = "cancelled") {
|
|
73
|
-
const error = new Error(String(reason || "cancelled"));
|
|
74
|
-
error.name = "AbortError";
|
|
75
|
-
return error;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
69
|
/**
|
|
79
70
|
* Render codex's argv-array command as the one line a human decides about.
|
|
80
71
|
* Codex wraps shell work as ["/bin/zsh","-lc","<the real command>"]; showing
|
package/src/config.mjs
CHANGED
|
@@ -40,7 +40,7 @@ function readJson(path) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** First config file that exists: explicit path → ./hilos-agent.json → ~/.hilos/agent.json. */
|
|
43
|
-
|
|
43
|
+
function findConfigPath(explicit) {
|
|
44
44
|
if (explicit) return explicit;
|
|
45
45
|
if (existsSync(LOCAL_CONFIG)) return LOCAL_CONFIG;
|
|
46
46
|
if (existsSync(GLOBAL_CONFIG)) return GLOBAL_CONFIG;
|
|
@@ -180,10 +180,9 @@ const DEFAULTS = {
|
|
|
180
180
|
// Cap a chat reply / plan-ack so a stalled model can't dead-air the channel;
|
|
181
181
|
// on timeout we post an honest "taking longer than expected" line.
|
|
182
182
|
chatTimeoutMs: 90000,
|
|
183
|
-
// Work queue: run
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
queueConcurrency: 1,
|
|
183
|
+
// Work queue: mentions run one at a time — parallel CLI runs on one checkout
|
|
184
|
+
// would collide on git state. queueAcks posts "queued behind the current task"
|
|
185
|
+
// when a mention lands during a run; set false to keep it quiet.
|
|
187
186
|
queueAcks: true,
|
|
188
187
|
runTimeoutMs: 600000,
|
|
189
188
|
decisionTimeoutMs: 1800000,
|
|
@@ -303,8 +302,7 @@ const LIVE_FIELDS = [
|
|
|
303
302
|
"heartbeatMs",
|
|
304
303
|
"progressMs",
|
|
305
304
|
"chatTimeoutMs",
|
|
306
|
-
//
|
|
307
|
-
// live. queueAcks IS live (intake() reads it per-mention from liveCfg).
|
|
305
|
+
// Live: intake() reads it per-mention from liveCfg.
|
|
308
306
|
"queueAcks",
|
|
309
307
|
// folders is an object map; it's listed here for documentation, but the actual
|
|
310
308
|
// live merge (with DEFAULTS) happens in the special-cased block below, exactly
|
package/src/daemon.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Pure daemon helpers — no I/O.
|
|
2
|
-
|
|
1
|
+
// Pure daemon helpers — no I/O.
|
|
2
|
+
import { agentResultText } from "./agent-result.mjs";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
agentCoauthorTrailer,
|
|
@@ -133,7 +133,7 @@ export function commitMessage(task, provenance = {}) {
|
|
|
133
133
|
const title = selectGithubArtifactTitle({
|
|
134
134
|
summary: provenance.summary,
|
|
135
135
|
originalTask: task,
|
|
136
|
-
fallback: "
|
|
136
|
+
fallback: "Update project files",
|
|
137
137
|
});
|
|
138
138
|
const trailer = agentCoauthorTrailer(provenance.agentName, provenance.agentId);
|
|
139
139
|
// The agent's own account of the change becomes the commit body, so the log
|
|
@@ -152,28 +152,23 @@ export function prTitleBody(task, branch, provenance = {}) {
|
|
|
152
152
|
const title = selectGithubArtifactTitle({
|
|
153
153
|
summary: provenance.summary,
|
|
154
154
|
originalTask: task,
|
|
155
|
-
fallback:
|
|
155
|
+
fallback: "Update project files",
|
|
156
156
|
});
|
|
157
|
-
return { title, body: githubArtifactBody(provenance) };
|
|
157
|
+
return { title, body: githubArtifactBody({ ...provenance, title }) };
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/** Build the post_report payload that serves as the approval card. */
|
|
161
161
|
export function buildProposalReport(o) {
|
|
162
|
-
const firstLine = String(o.task || "").split("\n")[0].slice(0, 72) || "task";
|
|
163
|
-
const statLine = `${o.stat.files} file(s), +${o.stat.insertions}/-${o.stat.deletions} on \`${o.branch}\` in ${o.repoFullName}`;
|
|
164
162
|
const diffBlock =
|
|
165
163
|
"```diff\n" +
|
|
166
164
|
o.diffText +
|
|
167
165
|
(o.truncated ? `\n… (+${o.omittedLines} more lines)` : "") +
|
|
168
166
|
"\n```";
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
const handle = mentionHandle(o.requester);
|
|
172
|
-
const lead = handle ? `@${handle} — proposed changes for: ${firstLine}` : `Proposed changes for: ${firstLine}`;
|
|
173
|
-
const summary = `${lead}\n\n${statLine}\n\n${diffBlock}`;
|
|
167
|
+
// Before a PR exists, the diff is the reviewable deliverable.
|
|
168
|
+
const summary = `${agentResultText(o.summary) || "Changes are ready for review."}\n\n${diffBlock}`;
|
|
174
169
|
const caveats = ["Not pushed yet — approve to push + open a PR, or reject to discard."];
|
|
175
170
|
if (o.runFailed) caveats.push("The coding agent exited non-zero; review the diff carefully.");
|
|
176
|
-
return { title:
|
|
171
|
+
return { title: "Ready for review", summary, caveats, todos: [] };
|
|
177
172
|
}
|
|
178
173
|
|
|
179
174
|
/** kebab handle from a display name (matches the server's mention handle). */
|
package/src/deploy.mjs
CHANGED
|
@@ -16,7 +16,7 @@ function isProvider(value) {
|
|
|
16
16
|
return DEPLOY_PROVIDERS.includes(value);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
function findDeployCli(provider, env = process.env) {
|
|
20
20
|
if (!isProvider(provider)) return null;
|
|
21
21
|
for (const entry of String(env.PATH || "").split(delimiter)) {
|
|
22
22
|
if (!entry) continue;
|
package/src/github-artifacts.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { readAgentResult, agentResultText } from "./agent-result.mjs";
|
|
1
2
|
const TITLE_MAX = 72;
|
|
2
3
|
// A pull-request description is a reviewer's first read, not an essay: enough
|
|
3
4
|
// room for the agent's own account of the change, bounded so a runaway summary
|
|
4
5
|
// can never become the PR body. The commit log gets a shorter form still.
|
|
5
|
-
const DESCRIPTION_MAX =
|
|
6
|
+
const DESCRIPTION_MAX = 6000;
|
|
6
7
|
const COMMIT_BODY_MAX = 600;
|
|
7
8
|
|
|
8
9
|
const REJECTED_TITLE_LINES = [
|
|
9
10
|
/^(?:sure|okay|ok|yes|got it|sounds good|absolutely|certainly|done|working on it)\b/i,
|
|
10
11
|
/^(?:i(?:'ll| will| have|’ll)|let me|here(?:'s| is))\b/i,
|
|
11
12
|
/^(?:address|apply|handle|incorporate|respond to|update)\b.{0,36}\b(?:feedback|review|comments?|pr)\b/i,
|
|
13
|
+
/^(?:summary|changes|what changed|verification|testing|result|done|update|implementation)[:\s]*$/i,
|
|
14
|
+
/^(?:can|could|would|please|hey|hi)\b/i,
|
|
12
15
|
/^@\S+/,
|
|
13
16
|
/https?:\/\//i,
|
|
14
17
|
/^```/,
|
|
@@ -57,13 +60,16 @@ function truncateTitle(title) {
|
|
|
57
60
|
/**
|
|
58
61
|
* The title the agent wrote for its OWN change, or null when its summary never
|
|
59
62
|
* offered a usable one. Callers that must always end up with something use
|
|
60
|
-
* `selectGithubArtifactTitle` (which
|
|
63
|
+
* `selectGithubArtifactTitle` (which uses a neutral fallback); callers
|
|
61
64
|
* that need to know whether the agent actually authored a headline — an
|
|
62
65
|
* iteration commit deciding between the agent's words and a canned subject —
|
|
63
66
|
* use this.
|
|
64
67
|
*/
|
|
65
68
|
/** @param {unknown} summary */
|
|
66
69
|
export function agentAuthoredTitle(summary) {
|
|
70
|
+
const result = readAgentResult(summary);
|
|
71
|
+
if (result) return usableTitle(cleanTitleLine(result.prTitle)) ? truncateTitle(cleanTitleLine(result.prTitle)) : null;
|
|
72
|
+
if (!agentResultText(summary)) return null;
|
|
67
73
|
const line = candidateLines(summary).find(usableTitle);
|
|
68
74
|
return line ? truncateTitle(line) : null;
|
|
69
75
|
}
|
|
@@ -75,12 +81,11 @@ export function agentAuthoredTitle(summary) {
|
|
|
75
81
|
* callers keep their existing PR title and use this only for the new commit.
|
|
76
82
|
*/
|
|
77
83
|
/** @param {{ summary?: unknown, originalTask?: unknown, fallback?: string }} [input] */
|
|
78
|
-
export function selectGithubArtifactTitle({ summary, originalTask, fallback = "Update" } = {}) {
|
|
84
|
+
export function selectGithubArtifactTitle({ summary, originalTask, fallback = "Update project files" } = {}) {
|
|
79
85
|
const summaryTitle = agentAuthoredTitle(summary);
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return truncateTitle(summaryTitle || taskTitle || cleanTitleLine(fallback) || "Update");
|
|
86
|
+
const taskText = candidateLines(originalTask).join(" ").toLowerCase();
|
|
87
|
+
const echoed = summaryTitle && cleanTitleLine(summaryTitle).toLowerCase() === taskText;
|
|
88
|
+
return truncateTitle((!echoed && summaryTitle) || cleanTitleLine(fallback) || "Update project files");
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/**
|
|
@@ -142,11 +147,14 @@ function clampLines(lines, max) {
|
|
|
142
147
|
*/
|
|
143
148
|
/** @param {unknown} summary @param {{ max?: number }} [options] */
|
|
144
149
|
export function agentChangeDescription(summary, { max = DESCRIPTION_MAX } = {}) {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
const result = readAgentResult(summary);
|
|
151
|
+
if (result) return clampLines(tidyLines((result.prDescription || result.summary).split("\n").map(defuseMarkup)), max);
|
|
152
|
+
const text = agentResultText(summary);
|
|
153
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
148
154
|
const headline = lines.findIndex((line) => usableTitle(cleanTitleLine(line)));
|
|
149
|
-
|
|
155
|
+
// A single sentence is the complete description, not a headline to discard.
|
|
156
|
+
const hasBody = headline >= 0 && lines.slice(headline + 1).some((line) => line.trim() && !/^\s*VISUAL_PREVIEW:/i.test(line));
|
|
157
|
+
const body = (hasBody ? lines.slice(headline + 1) : lines)
|
|
150
158
|
.filter((line) => !/^\s*visual_?preview\s*:/i.test(line))
|
|
151
159
|
.map(defuseMarkup);
|
|
152
160
|
return clampLines(tidyLines(body), max);
|
|
@@ -155,6 +163,8 @@ export function agentChangeDescription(summary, { max = DESCRIPTION_MAX } = {})
|
|
|
155
163
|
/** The same account of the change, shortened and de-fenced for a commit body. */
|
|
156
164
|
/** @param {unknown} summary */
|
|
157
165
|
export function agentCommitBody(summary) {
|
|
166
|
+
const result = readAgentResult(summary);
|
|
167
|
+
if (result) return clampLines(tidyLines(stripFences(result.prDescription || result.summary).split("\n").map(defuseMarkup)), COMMIT_BODY_MAX);
|
|
158
168
|
return agentChangeDescription(stripFences(summary), { max: COMMIT_BODY_MAX });
|
|
159
169
|
}
|
|
160
170
|
|
|
@@ -185,10 +195,6 @@ function safeHilosUrl(value) {
|
|
|
185
195
|
}
|
|
186
196
|
}
|
|
187
197
|
|
|
188
|
-
function linkLine(label, url, fallback) {
|
|
189
|
-
return url ? `- ${label}: [Open in hilos](${url})` : `- ${label}: ${fallback}`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
198
|
/**
|
|
193
199
|
* A bounded PR body: the agent's own description of the change on top, then the
|
|
194
200
|
* provenance block. It still deliberately never accepts task text — the raw
|
|
@@ -199,7 +205,7 @@ function linkLine(label, url, fallback) {
|
|
|
199
205
|
/**
|
|
200
206
|
* @param {{ agentName?: unknown, personName?: unknown, roomName?: unknown,
|
|
201
207
|
* roomUrl?: unknown, messageId?: unknown, messageUrl?: unknown, runId?: unknown,
|
|
202
|
-
* reportId?: unknown, partial?: boolean, summary?: unknown }} [input]
|
|
208
|
+
* reportId?: unknown, partial?: boolean, summary?: unknown, title?: string }} [input]
|
|
203
209
|
*/
|
|
204
210
|
export function githubArtifactBody(input = {}) {
|
|
205
211
|
const agent = safeLabel(input.agentName, "hilos agent");
|
|
@@ -223,25 +229,14 @@ export function githubArtifactBody(input = {}) {
|
|
|
223
229
|
? "\nThis is a partial run. Mention the agent in the room to continue."
|
|
224
230
|
: "";
|
|
225
231
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
linkLine("Room", roomUrl, room),
|
|
235
|
-
linkLine("Request", messageUrl, machine.messageId ? `message \`${machine.messageId}\`` : "room request"),
|
|
236
|
-
`- Run: ${runId ? `\`${runId}\`` : "not recorded"}`,
|
|
237
|
-
`- Report: ${reportId ? `\`${reportId}\`` : "posted in the room after this pull request opens"}`,
|
|
238
|
-
partialNote,
|
|
239
|
-
"",
|
|
240
|
-
`<!-- hilos-provenance ${JSON.stringify(machine).replace(/--/g, "—")} -->`,
|
|
241
|
-
]
|
|
242
|
-
.filter((line, index, all) => line !== "" || index === 1 || all[index - 1] !== "")
|
|
243
|
-
.join("\n")
|
|
244
|
-
.trim();
|
|
232
|
+
const description = agentChangeDescription(input.summary) || "Changes are available in the diff. Verification was not reported.";
|
|
233
|
+
const context = messageUrl || roomUrl;
|
|
234
|
+
const credit = `Requested by **${person}** · Agent: **${agent}**${context ? ` · [Context](${context})` : ""}`;
|
|
235
|
+
const visible = [description, partialNote.trim(), credit].filter(Boolean).join("\n\n");
|
|
236
|
+
const title = input.title || selectGithubArtifactTitle({ summary: input.summary });
|
|
237
|
+
const marker = { ...machine, generatedTitle: `${partial ? "[partial] " : ""}${title}`, generatedBody: visible };
|
|
238
|
+
return `${visible}\n\n<!-- hilos-provenance ${JSON.stringify(marker).replace(/--/g, "\\u002d\\u002d")} -->`;
|
|
239
|
+
|
|
245
240
|
}
|
|
246
241
|
|
|
247
242
|
/** A stable Git co-author trailer for the named agent, without user input. */
|
|
@@ -251,3 +246,22 @@ export function agentCoauthorTrailer(agentName, agentId) {
|
|
|
251
246
|
const id = safeId(agentId)?.replace(/[^a-zA-Z0-9]/g, "").slice(0, 48) || "agent";
|
|
252
247
|
return `Co-authored-by: ${name} <agent+${id}@hilos.sh>`;
|
|
253
248
|
}
|
|
249
|
+
|
|
250
|
+
/** Update only text still identical to what hilos last wrote (1298).
|
|
251
|
+
* @returns {{title?: string, body?: string}}
|
|
252
|
+
*/
|
|
253
|
+
export function githubArtifactPatch(existing, input) {
|
|
254
|
+
const match = /<!-- hilos-provenance (\{[^\n]*\}) -->/.exec(existing.body || "");
|
|
255
|
+
if (!match) return {};
|
|
256
|
+
let previous;
|
|
257
|
+
try { previous = JSON.parse(match[1]); } catch { return {}; }
|
|
258
|
+
const patch = {};
|
|
259
|
+
const title = input.title || selectGithubArtifactTitle({ summary: input.summary });
|
|
260
|
+
if (typeof previous.generatedTitle === "string" && existing.title === previous.generatedTitle) {
|
|
261
|
+
patch.title = `${input.partial ? "[partial] " : ""}${title}`;
|
|
262
|
+
}
|
|
263
|
+
if (typeof previous.generatedBody === "string" && (existing.body || "").slice(0, match.index).trim() === previous.generatedBody) {
|
|
264
|
+
patch.body = githubArtifactBody(input) + (existing.body || "").slice(match.index + match[0].length);
|
|
265
|
+
}
|
|
266
|
+
return patch;
|
|
267
|
+
}
|