hilos-agent 0.9.4 → 0.9.5
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 +8 -2
- package/package.json +1 -1
- package/src/codex-mcp-session.mjs +4 -0
- package/src/config.mjs +13 -0
- package/src/cursor-store.mjs +76 -0
- package/src/daemon.mjs +32 -10
- package/src/github-artifacts.mjs +253 -0
- package/src/handler.mjs +125 -35
- package/src/mcp.mjs +10 -3
- package/src/progress-emitter.mjs +30 -2
- package/src/run.mjs +62 -8
package/README.md
CHANGED
|
@@ -53,7 +53,9 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
|
53
53
|
"defaultBranch": "main",
|
|
54
54
|
"gate": false, // default: open a PR directly. true = approve-before-push
|
|
55
55
|
"heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
|
|
56
|
-
"chatTimeoutMs": 90000
|
|
56
|
+
"chatTimeoutMs": 90000, // cap a chat reply / plan-ack so a stalled model can't go silent
|
|
57
|
+
"longPollMs": 20000, // ask the server to HOLD the mention poll and answer the moment work lands (0 = plain 5s polling; older servers fall back automatically)
|
|
58
|
+
"catchupMs": 86400000 // how far back a restart replays mentions from the persisted cursor (default 24h; 0 = restart at "now", the pre-0866 behavior)
|
|
57
59
|
}
|
|
58
60
|
```
|
|
59
61
|
|
|
@@ -68,7 +70,11 @@ to a short "done" line. A run that **times out or errors** says so honestly (wit
|
|
|
68
70
|
a stderr tail) instead of claiming "no changes". Chat replies use the faster
|
|
69
71
|
`chatCmd` (when unset, derived from `codingCmd`'s tool — a Claude daemon chats
|
|
70
72
|
with Haiku, a Codex daemon with `codex exec`, and so on) bounded by
|
|
71
|
-
`chatTimeoutMs`. The
|
|
73
|
+
`chatTimeoutMs`. The chat-vs-code pass only classifies; the separate chat
|
|
74
|
+
responder keeps the CLI's normal tools. Generated Codex chat and code commands
|
|
75
|
+
explicitly enable its built-in web search (an explicit operator override still
|
|
76
|
+
wins), while other vendors keep their own tool configuration. The responsive
|
|
77
|
+
surface needs a hilos server new enough to expose
|
|
72
78
|
`edit_message`; older servers just skip the live edits.
|
|
73
79
|
|
|
74
80
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
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": {
|
|
@@ -461,6 +461,10 @@ export async function runCodexMcpSession({
|
|
|
461
461
|
cwd,
|
|
462
462
|
"approval-policy": approvalPolicy,
|
|
463
463
|
sandbox,
|
|
464
|
+
// The gated transport cannot inherit `codex exec` argv. Carry the
|
|
465
|
+
// same public web-search capability through the MCP tool's config;
|
|
466
|
+
// permission policy and sandboxing remain unchanged.
|
|
467
|
+
config: { tools: { web_search: true } },
|
|
464
468
|
...(model ? { model } : {}),
|
|
465
469
|
},
|
|
466
470
|
};
|
package/src/config.mjs
CHANGED
|
@@ -121,6 +121,17 @@ const DEFAULTS = {
|
|
|
121
121
|
gate: false,
|
|
122
122
|
maxRounds: 3,
|
|
123
123
|
pollMs: 5000,
|
|
124
|
+
// Long-poll (0866): when the server supports it, list_mentions is asked to
|
|
125
|
+
// HOLD this many ms and answer the moment a mention lands — pickup latency
|
|
126
|
+
// stops being pollMs. 0 disables and falls back to plain pollMs polling.
|
|
127
|
+
// The server caps a hold at 25s regardless of what is asked for here.
|
|
128
|
+
longPollMs: 20000,
|
|
129
|
+
// How far back a restart may replay missed mentions from the persisted
|
|
130
|
+
// cursor (0866). A daemon that was off for a week should not open a week of
|
|
131
|
+
// stale branches; a day covers the overnight-laptop case the cursor exists
|
|
132
|
+
// for. 0 disables catch-up entirely (restart starts at "now", the old
|
|
133
|
+
// behavior).
|
|
134
|
+
catchupMs: 24 * 60 * 60 * 1000,
|
|
124
135
|
// On a long code run, post ONE thread progress reply at the first beat then
|
|
125
136
|
// edit it on later beats, so a human sees the agent is alive without thread
|
|
126
137
|
// spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
|
|
@@ -224,6 +235,8 @@ const LIVE_FIELDS = [
|
|
|
224
235
|
"gate",
|
|
225
236
|
"maxRounds",
|
|
226
237
|
"pollMs",
|
|
238
|
+
"longPollMs",
|
|
239
|
+
"catchupMs",
|
|
227
240
|
"runTimeoutMs",
|
|
228
241
|
"decisionTimeoutMs",
|
|
229
242
|
"decisionPollMs",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Durable mention cursor (0866).
|
|
2
|
+
//
|
|
3
|
+
// The poll loop's cursor and dedupe set lived only in memory, so a daemon
|
|
4
|
+
// restart began at "now" and every mention that arrived while it was down was
|
|
5
|
+
// silently skipped (backfill:false, the default). This file is the fix's whole
|
|
6
|
+
// mechanism: the cursor is persisted per agent after each delivery, and a
|
|
7
|
+
// restart resumes from it — bounded by `catchupMs`, because replaying a week of
|
|
8
|
+
// stale asks after a vacation would be worse than skipping them.
|
|
9
|
+
//
|
|
10
|
+
// Telegram semantics, deliberately: persisting the cursor acknowledges
|
|
11
|
+
// DELIVERY into the daemon's queue, not completion of the work. A crash after
|
|
12
|
+
// enqueue can still lose an in-flight task (exactly as today); what can no
|
|
13
|
+
// longer happen is a mention arriving into a dead daemon and never being seen.
|
|
14
|
+
//
|
|
15
|
+
// Dependency-free and injectable-dir like hook.mjs's state store, so tests run
|
|
16
|
+
// against a temp dir and never touch a real ~/.hilos.
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** Same parent as ~/.hilos/agent.json (config.mjs) and state.json (resume.mjs). */
|
|
23
|
+
export const CURSOR_STATE_DIR = join(homedir(), ".hilos", "mention-cursors");
|
|
24
|
+
|
|
25
|
+
/** One file per agent — two agents on one machine must never share a cursor. */
|
|
26
|
+
export function cursorPath(agentId, dir = CURSOR_STATE_DIR) {
|
|
27
|
+
return join(dir, `${String(agentId).replace(/[^a-zA-Z0-9-]/g, "")}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The cursor a restart should resume from, or null when there is nothing
|
|
32
|
+
* usable — no file, unreadable JSON, a malformed timestamp, or a cursor older
|
|
33
|
+
* than `catchupMs` allows (clamped to the window's edge rather than dropped,
|
|
34
|
+
* so a long-dead daemon still catches the most recent day, not nothing).
|
|
35
|
+
*
|
|
36
|
+
* @param {string} agentId
|
|
37
|
+
* @param {{ catchupMs?: number, dir?: string, now?: () => number }} [opts]
|
|
38
|
+
* @returns {string|null} ISO timestamp
|
|
39
|
+
*/
|
|
40
|
+
export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_DIR, now = Date.now } = {}) {
|
|
41
|
+
if (!agentId || !(catchupMs > 0)) return null;
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(readFileSync(cursorPath(agentId, dir), "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const value = parsed && typeof parsed.mentionCursor === "string" ? parsed.mentionCursor : null;
|
|
49
|
+
if (!value) return null;
|
|
50
|
+
const at = new Date(value).getTime();
|
|
51
|
+
if (Number.isNaN(at)) return null;
|
|
52
|
+
const floor = now() - catchupMs;
|
|
53
|
+
return at < floor ? new Date(floor).toISOString() : value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Persist the cursor after a delivery. Best-effort by design: a full disk or
|
|
58
|
+
* read-only home must never take the poll loop down — the daemon just degrades
|
|
59
|
+
* to today's restart-at-now behavior.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} agentId
|
|
62
|
+
* @param {string} isoTimestamp
|
|
63
|
+
* @param {{ dir?: string, log?: { error?: (m: string) => void } }} [opts]
|
|
64
|
+
* @returns {boolean} whether the write landed
|
|
65
|
+
*/
|
|
66
|
+
export function saveMentionCursor(agentId, isoTimestamp, { dir = CURSOR_STATE_DIR, log } = {}) {
|
|
67
|
+
if (!agentId || typeof isoTimestamp !== "string" || !isoTimestamp) return false;
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
writeFileSync(cursorPath(agentId, dir), JSON.stringify({ mentionCursor: isoTimestamp }));
|
|
71
|
+
return true;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
log?.error?.(`mention cursor not persisted: ${e?.message || e}`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/daemon.mjs
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// Pure daemon helpers — no I/O. (Mirror of the app's scripts/lib/daemon.mjs so
|
|
2
2
|
// the package stands alone; keep them equivalent.)
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
agentCoauthorTrailer,
|
|
6
|
+
agentCommitBody,
|
|
7
|
+
githubArtifactBody,
|
|
8
|
+
selectGithubArtifactTitle,
|
|
9
|
+
} from "./github-artifacts.mjs";
|
|
10
|
+
|
|
4
11
|
/** Branch name from a task: `hilos/<kebab-first-words>-<suffix>`. */
|
|
5
12
|
export function branchSlug(text, suffix) {
|
|
6
13
|
const base =
|
|
@@ -121,18 +128,33 @@ export function decisionKind(report) {
|
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
/** Commit message for an approved proposal. */
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
/** @param {unknown} task @param {Record<string, any>} [provenance] */
|
|
132
|
+
export function commitMessage(task, provenance = {}) {
|
|
133
|
+
const title = selectGithubArtifactTitle({
|
|
134
|
+
summary: provenance.summary,
|
|
135
|
+
originalTask: task,
|
|
136
|
+
fallback: "hilos change",
|
|
137
|
+
});
|
|
138
|
+
const trailer = agentCoauthorTrailer(provenance.agentName, provenance.agentId);
|
|
139
|
+
// The agent's own account of the change becomes the commit body, so the log
|
|
140
|
+
// says what shipped and not only that a person approved it.
|
|
141
|
+
const body = agentCommitBody(provenance.summary);
|
|
142
|
+
return [title, body, "Proposed via hilos and approved by a person.", trailer]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join("\n\n");
|
|
127
145
|
}
|
|
128
146
|
|
|
129
|
-
/** PR title + body for an approved proposal.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
/** PR title + body for an approved proposal. The body carries the agent's own
|
|
148
|
+
* description of the change (from `provenance.summary`) above the provenance
|
|
149
|
+
* block, so a reviewer on GitHub reads what shipped before who proposed it. */
|
|
150
|
+
/** @param {unknown} task @param {string} branch @param {Record<string, any>} [provenance] */
|
|
151
|
+
export function prTitleBody(task, branch, provenance = {}) {
|
|
152
|
+
const title = selectGithubArtifactTitle({
|
|
153
|
+
summary: provenance.summary,
|
|
154
|
+
originalTask: task,
|
|
155
|
+
fallback: branch,
|
|
156
|
+
});
|
|
157
|
+
return { title, body: githubArtifactBody(provenance) };
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
/** Build the post_report payload that serves as the approval card. */
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
const TITLE_MAX = 72;
|
|
2
|
+
// A pull-request description is a reviewer's first read, not an essay: enough
|
|
3
|
+
// room for the agent's own account of the change, bounded so a runaway summary
|
|
4
|
+
// can never become the PR body. The commit log gets a shorter form still.
|
|
5
|
+
const DESCRIPTION_MAX = 1400;
|
|
6
|
+
const COMMIT_BODY_MAX = 600;
|
|
7
|
+
|
|
8
|
+
const REJECTED_TITLE_LINES = [
|
|
9
|
+
/^(?:sure|okay|ok|yes|got it|sounds good|absolutely|certainly|done|working on it)\b/i,
|
|
10
|
+
/^(?:i(?:'ll| will| have|’ll)|let me|here(?:'s| is))\b/i,
|
|
11
|
+
/^(?:address|apply|handle|incorporate|respond to|update)\b.{0,36}\b(?:feedback|review|comments?|pr)\b/i,
|
|
12
|
+
/^@\S+/,
|
|
13
|
+
/https?:\/\//i,
|
|
14
|
+
/^```/,
|
|
15
|
+
/^visual_?preview\s*:/i,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function oneLine(value, max = 160) {
|
|
19
|
+
return String(value ?? "")
|
|
20
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
21
|
+
.replace(/\s+/g, " ")
|
|
22
|
+
.trim()
|
|
23
|
+
.slice(0, max);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cleanTitleLine(value) {
|
|
27
|
+
return oneLine(value)
|
|
28
|
+
.replace(/^#{1,6}\s+/, "")
|
|
29
|
+
.replace(/^[-*+]\s+/, "")
|
|
30
|
+
.replace(/^\d+[.)]\s+/, "")
|
|
31
|
+
.replace(/^(?:(?:pr|pull request|commit)\s+)?title\s*[:\-–—]\s*/i, "")
|
|
32
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
33
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
34
|
+
.replace(/[*_~]/g, "")
|
|
35
|
+
.replace(/^@[a-z0-9][a-z0-9-]*\s*[:,–—-]?\s*/i, "")
|
|
36
|
+
.replace(/[.\s]+$/, "")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function candidateLines(value) {
|
|
41
|
+
return String(value ?? "")
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map(cleanTitleLine)
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function usableTitle(line) {
|
|
48
|
+
if (line.length < 4) return false;
|
|
49
|
+
return !REJECTED_TITLE_LINES.some((pattern) => pattern.test(line));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function truncateTitle(title) {
|
|
53
|
+
if (title.length <= TITLE_MAX) return title;
|
|
54
|
+
return `${title.slice(0, TITLE_MAX - 1).trimEnd()}…`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The title the agent wrote for its OWN change, or null when its summary never
|
|
59
|
+
* offered a usable one. Callers that must always end up with something use
|
|
60
|
+
* `selectGithubArtifactTitle` (which falls back to the request text); callers
|
|
61
|
+
* that need to know whether the agent actually authored a headline — an
|
|
62
|
+
* iteration commit deciding between the agent's words and a canned subject —
|
|
63
|
+
* use this.
|
|
64
|
+
*/
|
|
65
|
+
/** @param {unknown} summary */
|
|
66
|
+
export function agentAuthoredTitle(summary) {
|
|
67
|
+
const line = candidateLines(summary).find(usableTitle);
|
|
68
|
+
return line ? truncateTitle(line) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One title selector for hosted and local-daemon GitHub output. Agent summaries
|
|
73
|
+
* win only when they actually describe a change; acknowledgements, mentions,
|
|
74
|
+
* Markdown wrappers, links, and review-process chatter are rejected. Iteration
|
|
75
|
+
* callers keep their existing PR title and use this only for the new commit.
|
|
76
|
+
*/
|
|
77
|
+
/** @param {{ summary?: unknown, originalTask?: unknown, fallback?: string }} [input] */
|
|
78
|
+
export function selectGithubArtifactTitle({ summary, originalTask, fallback = "Update" } = {}) {
|
|
79
|
+
const summaryTitle = agentAuthoredTitle(summary);
|
|
80
|
+
const taskTitle = candidateLines(originalTask)
|
|
81
|
+
.map((line) => line.replace(/@[a-z0-9][a-z0-9-]*/gi, "").trim())
|
|
82
|
+
.find(usableTitle);
|
|
83
|
+
return truncateTitle(summaryTitle || taskTitle || cleanTitleLine(fallback) || "Update");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Neutralize the two things in agent prose that would ACT on GitHub instead of
|
|
88
|
+
* describing the change: HTML comment delimiters (which could otherwise forge
|
|
89
|
+
* or break the provenance marker this body ends with) and @handles (which ping
|
|
90
|
+
* whatever unrelated GitHub account happens to own that name). Everything else
|
|
91
|
+
* — including code fences — is the agent's writing and survives intact.
|
|
92
|
+
*/
|
|
93
|
+
function defuseMarkup(line) {
|
|
94
|
+
return line
|
|
95
|
+
.replace(/<!--+/g, "")
|
|
96
|
+
.replace(/--+>/g, "")
|
|
97
|
+
.replace(/(^|[\s(\[])@([a-z0-9][a-z0-9-]{0,38})/gi, "$1$2");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop fenced code blocks — a commit log is not the place for a snippet. */
|
|
101
|
+
function stripFences(text) {
|
|
102
|
+
return String(text ?? "").replace(/^\s*```[\s\S]*?^\s*```\s*$/gm, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Trim leading/trailing blank lines and collapse blank runs to one. */
|
|
106
|
+
function tidyLines(lines) {
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const value = line.replace(/\s+$/, "");
|
|
110
|
+
if (!value.trim() && (!out.length || !out[out.length - 1].trim())) continue;
|
|
111
|
+
out.push(value);
|
|
112
|
+
}
|
|
113
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Keep whole lines up to `max` characters, marking a cut rather than hiding it. */
|
|
118
|
+
function clampLines(lines, max) {
|
|
119
|
+
const kept = [];
|
|
120
|
+
let used = 0;
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
const next = used + line.length + 1;
|
|
123
|
+
if (next > max) {
|
|
124
|
+
kept.push("…");
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
kept.push(line);
|
|
128
|
+
used = next;
|
|
129
|
+
}
|
|
130
|
+
return tidyLines(kept).join("\n").trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The agent's own account of the change, ready to be a pull-request
|
|
135
|
+
* description: its summary MINUS the headline it leads with (that becomes the
|
|
136
|
+
* title, and a PR should not open by repeating its own title) and minus the
|
|
137
|
+
* machine directives it ends with (`VISUAL_PREVIEW:`), sanitized and bounded on
|
|
138
|
+
* whole lines.
|
|
139
|
+
*
|
|
140
|
+
* Returns "" when the agent wrote nothing past a title — the caller then ships
|
|
141
|
+
* provenance alone rather than inventing prose it cannot stand behind.
|
|
142
|
+
*/
|
|
143
|
+
/** @param {unknown} summary @param {{ max?: number }} [options] */
|
|
144
|
+
export function agentChangeDescription(summary, { max = DESCRIPTION_MAX } = {}) {
|
|
145
|
+
const lines = String(summary ?? "")
|
|
146
|
+
.replace(/\r\n?/g, "\n")
|
|
147
|
+
.split("\n");
|
|
148
|
+
const headline = lines.findIndex((line) => usableTitle(cleanTitleLine(line)));
|
|
149
|
+
const body = (headline === -1 ? lines : lines.slice(headline + 1))
|
|
150
|
+
.filter((line) => !/^\s*visual_?preview\s*:/i.test(line))
|
|
151
|
+
.map(defuseMarkup);
|
|
152
|
+
return clampLines(tidyLines(body), max);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The same account of the change, shortened and de-fenced for a commit body. */
|
|
156
|
+
/** @param {unknown} summary */
|
|
157
|
+
export function agentCommitBody(summary) {
|
|
158
|
+
return agentChangeDescription(stripFences(summary), { max: COMMIT_BODY_MAX });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeLabel(value, fallback) {
|
|
162
|
+
const label = oneLine(value, 80)
|
|
163
|
+
.replace(/[<>`[\]{}*_]/g, "")
|
|
164
|
+
.replace(/https?:\/\/\S+/gi, "")
|
|
165
|
+
.trim();
|
|
166
|
+
return label || fallback;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function safeId(value) {
|
|
170
|
+
const id = oneLine(value, 100);
|
|
171
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/.test(id) ? id : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function safeHilosUrl(value) {
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(String(value ?? ""));
|
|
177
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
178
|
+
url.username = "";
|
|
179
|
+
url.password = "";
|
|
180
|
+
// Room and message links use their query/hash to identify the exact place
|
|
181
|
+
// a request came from. Keep that stable context after removing credentials.
|
|
182
|
+
return url.toString();
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function linkLine(label, url, fallback) {
|
|
189
|
+
return url ? `- ${label}: [Open in hilos](${url})` : `- ${label}: ${fallback}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A bounded PR body: the agent's own description of the change on top, then the
|
|
194
|
+
* provenance block. It still deliberately never accepts task text — the raw
|
|
195
|
+
* chat message is not a description of what shipped — but it does accept the
|
|
196
|
+
* agent's `summary`, which is the one piece of writing on this path that was
|
|
197
|
+
* actually authored about the diff.
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* @param {{ agentName?: unknown, personName?: unknown, roomName?: unknown,
|
|
201
|
+
* roomUrl?: unknown, messageId?: unknown, messageUrl?: unknown, runId?: unknown,
|
|
202
|
+
* reportId?: unknown, partial?: boolean, summary?: unknown }} [input]
|
|
203
|
+
*/
|
|
204
|
+
export function githubArtifactBody(input = {}) {
|
|
205
|
+
const agent = safeLabel(input.agentName, "hilos agent");
|
|
206
|
+
const person = safeLabel(input.personName, "a team member");
|
|
207
|
+
const room = safeLabel(input.roomName, "project room");
|
|
208
|
+
const roomUrl = safeHilosUrl(input.roomUrl);
|
|
209
|
+
const messageUrl = safeHilosUrl(input.messageUrl);
|
|
210
|
+
const runId = safeId(input.runId);
|
|
211
|
+
const reportId = safeId(input.reportId);
|
|
212
|
+
const partial = input.partial === true;
|
|
213
|
+
const machine = {
|
|
214
|
+
version: 1,
|
|
215
|
+
agent,
|
|
216
|
+
person,
|
|
217
|
+
room,
|
|
218
|
+
messageId: safeId(input.messageId),
|
|
219
|
+
runId,
|
|
220
|
+
reportId,
|
|
221
|
+
};
|
|
222
|
+
const partialNote = partial
|
|
223
|
+
? "\nThis is a partial run. Mention the agent in the room to continue."
|
|
224
|
+
: "";
|
|
225
|
+
|
|
226
|
+
// The agent's account of the change leads, because that is what a reviewer
|
|
227
|
+
// opens the pull request to read. Provenance follows it under a rule.
|
|
228
|
+
const description = agentChangeDescription(input.summary);
|
|
229
|
+
|
|
230
|
+
return [
|
|
231
|
+
...(description ? [description, "", "---", ""] : []),
|
|
232
|
+
`Proposed by **${agent}** for **${person}** in **${room}**. A person remains the creator of record and decides whether to merge.`,
|
|
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();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A stable Git co-author trailer for the named agent, without user input. */
|
|
248
|
+
/** @param {unknown} agentName @param {unknown} agentId */
|
|
249
|
+
export function agentCoauthorTrailer(agentName, agentId) {
|
|
250
|
+
const name = safeLabel(agentName, "hilos agent");
|
|
251
|
+
const id = safeId(agentId)?.replace(/[^a-zA-Z0-9]/g, "").slice(0, 48) || "agent";
|
|
252
|
+
return `Co-authored-by: ${name} <agent+${id}@hilos.sh>`;
|
|
253
|
+
}
|
package/src/handler.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { makeStreamParser, createUsageFold } from "./agent-events.mjs";
|
|
|
41
41
|
import {
|
|
42
42
|
detectVendor,
|
|
43
43
|
codeStreamArgs,
|
|
44
|
+
codeWebArgs,
|
|
44
45
|
codeDirArgs,
|
|
45
46
|
codeImageArgs,
|
|
46
47
|
codeProjectKey,
|
|
@@ -306,6 +307,11 @@ function openCodePermissionCallbacks({
|
|
|
306
307
|
threadRoot,
|
|
307
308
|
runId = null,
|
|
308
309
|
provider = "opencode",
|
|
310
|
+
// 0866 — when the server's get_permission_decision supports a long-poll
|
|
311
|
+
// hold, each read blocks up to this many ms and answers the moment a human
|
|
312
|
+
// decides, instead of the gate discovering the decision on its next 1s poll.
|
|
313
|
+
// 0 (older servers) keeps the plain immediate read.
|
|
314
|
+
decisionWaitMs = 0,
|
|
309
315
|
}) {
|
|
310
316
|
return {
|
|
311
317
|
requestPermission: async (request) => {
|
|
@@ -352,6 +358,8 @@ function openCodePermissionCallbacks({
|
|
|
352
358
|
vendorSessionId: request.sessionId,
|
|
353
359
|
vendorRequestId: request.vendorRequestId,
|
|
354
360
|
...(failClosed ? { failClosed: true } : {}),
|
|
361
|
+
// Never on a failClosed settlement — that call exists to act NOW.
|
|
362
|
+
...(decisionWaitMs > 0 && !failClosed ? { waitMs: decisionWaitMs } : {}),
|
|
355
363
|
});
|
|
356
364
|
},
|
|
357
365
|
};
|
|
@@ -768,7 +776,37 @@ async function linkPrFromUrl({ tool, channelId, url }) {
|
|
|
768
776
|
await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
|
|
769
777
|
}
|
|
770
778
|
|
|
771
|
-
|
|
779
|
+
function githubProvenance({ cfg, me, message, channelId, runId, summary }) {
|
|
780
|
+
let siteUrl = "https://hilos.sh";
|
|
781
|
+
try {
|
|
782
|
+
const parsed = new URL(cfg.url);
|
|
783
|
+
parsed.pathname = parsed.pathname.replace(/\/api\/mcp\/?$/, "");
|
|
784
|
+
parsed.search = "";
|
|
785
|
+
parsed.hash = "";
|
|
786
|
+
siteUrl = parsed.toString().replace(/\/$/, "");
|
|
787
|
+
} catch {
|
|
788
|
+
/* the body helper will keep links absent if configuration is malformed */
|
|
789
|
+
}
|
|
790
|
+
const roomUrl = `${siteUrl}/w/${me?.workspaceId}/c/${channelId}`;
|
|
791
|
+
const messageUrl = message?.id
|
|
792
|
+
? message.parentId
|
|
793
|
+
? `${roomUrl}?thread=${message.parentId}&reply=${message.id}`
|
|
794
|
+
: `${roomUrl}#m-${message.id}`
|
|
795
|
+
: undefined;
|
|
796
|
+
return {
|
|
797
|
+
agentName: me?.agentName,
|
|
798
|
+
agentId: me?.agentId,
|
|
799
|
+
personName: message?.author,
|
|
800
|
+
roomName: message?.channel || "project room",
|
|
801
|
+
roomUrl,
|
|
802
|
+
messageId: message?.id,
|
|
803
|
+
messageUrl,
|
|
804
|
+
runId,
|
|
805
|
+
summary,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId, provenance = {}, usageArgs = {} }) {
|
|
772
810
|
const tag = requesterTag(requester);
|
|
773
811
|
const lead = tag ? `${tag} — ` : "";
|
|
774
812
|
// `parentId` here is the run's thread root. Terminal outcomes ask the server
|
|
@@ -785,7 +823,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
785
823
|
});
|
|
786
824
|
return { status: "nothing-staged", branch };
|
|
787
825
|
}
|
|
788
|
-
const commit = deps.git(repoPath, ["commit", "-m", commitMessage(task)]);
|
|
826
|
+
const commit = deps.git(repoPath, ["commit", "-m", commitMessage(task, provenance)]);
|
|
789
827
|
if (commit.status !== 0) {
|
|
790
828
|
await tool("post_message", {
|
|
791
829
|
channelId,
|
|
@@ -805,7 +843,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
805
843
|
});
|
|
806
844
|
return { status: "push-failed", branch };
|
|
807
845
|
}
|
|
808
|
-
const { title, body } = prTitleBody(task, branch);
|
|
846
|
+
const { title, body } = prTitleBody(task, branch, provenance);
|
|
809
847
|
// Continuing an existing PR (request-changes rework): the push already
|
|
810
848
|
// updated it — reuse its URL instead of opening a duplicate. Otherwise open
|
|
811
849
|
// a fresh PR.
|
|
@@ -897,7 +935,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
897
935
|
* switched to `main` would otherwise make `gh` try to open main → main. Used
|
|
898
936
|
* only when NOT gated (bias-to-action).
|
|
899
937
|
*/
|
|
900
|
-
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId, usageArgs = {} }) {
|
|
938
|
+
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId, provenance = {}, usageArgs = {} }) {
|
|
901
939
|
const tag = requesterTag(requester);
|
|
902
940
|
const lead = tag ? `${tag} — ` : "";
|
|
903
941
|
const currentBranch =
|
|
@@ -907,7 +945,7 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
907
945
|
let prUrl = deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
|
|
908
946
|
let prAttempt = null;
|
|
909
947
|
if (!prUrl && push.status === 0) {
|
|
910
|
-
const { title, body } = prTitleBody(task, ship.headBranch);
|
|
948
|
+
const { title, body } = prTitleBody(task, ship.headBranch, provenance);
|
|
911
949
|
prAttempt = deps.openPR(repoPath, {
|
|
912
950
|
title,
|
|
913
951
|
body,
|
|
@@ -916,7 +954,7 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
916
954
|
});
|
|
917
955
|
prUrl = prAttempt.ok && prAttempt.url ? prAttempt.url : null;
|
|
918
956
|
}
|
|
919
|
-
const { title } = prTitleBody(task, ship.headBranch);
|
|
957
|
+
const { title } = prTitleBody(task, ship.headBranch, provenance);
|
|
920
958
|
const recoveredNote = ship.recoveredFrom
|
|
921
959
|
? ` The child had switched to \`${ship.recoveredFrom}\`; hilos recovered its HEAD onto \`${ship.headBranch}\`.`
|
|
922
960
|
: "";
|
|
@@ -991,6 +1029,7 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
991
1029
|
// Sentinel the router model emits when the latest message is a request to change
|
|
992
1030
|
// code. Unusual on purpose so it can't be confused with a real chat reply.
|
|
993
1031
|
const CODE_SIGNAL = "__CODE__";
|
|
1032
|
+
const CHAT_SIGNAL = "__CHAT__";
|
|
994
1033
|
|
|
995
1034
|
// 0860 — the quiet option, offered ONLY on untagged DM wakes (message.implicit
|
|
996
1035
|
// === "dm"). In a DM every message reaches the agent without a tag, so some of
|
|
@@ -1046,8 +1085,8 @@ export function dmJudgmentBlock(implicitDm) {
|
|
|
1046
1085
|
|
|
1047
1086
|
/**
|
|
1048
1087
|
* Decide — with the LLM, not a word list — whether the latest message wants a
|
|
1049
|
-
* code change or a conversational reply
|
|
1050
|
-
*
|
|
1088
|
+
* code change or a conversational reply. The model reads the whole
|
|
1089
|
+
* conversation, so it judges by intent and
|
|
1051
1090
|
* context, in any language: "just code it", "dale, hazlo", "yeah go for it" after
|
|
1052
1091
|
* a request → code; "how does this work?", "thoughts?", "thanks" → chat.
|
|
1053
1092
|
*
|
|
@@ -1059,12 +1098,12 @@ export function dmJudgmentBlock(implicitDm) {
|
|
|
1059
1098
|
* Returns one of:
|
|
1060
1099
|
* { aborted: true }
|
|
1061
1100
|
* { code: true, task } → run the coding flow with `task` as the spec
|
|
1062
|
-
* { code: false,
|
|
1101
|
+
* { code: false, error } → run the separate conversational responder
|
|
1063
1102
|
*
|
|
1064
1103
|
* `error` is set when the model produced nothing (so the caller can be honest
|
|
1065
1104
|
* about a timeout vs a missing binary instead of inventing a reply).
|
|
1066
1105
|
*/
|
|
1067
|
-
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn, implicitDm = false }) {
|
|
1106
|
+
export async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn, implicitDm = false }) {
|
|
1068
1107
|
const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
|
|
1069
1108
|
const cmd = chatCmdFor(cfg);
|
|
1070
1109
|
const parts = cmd.split(" ").filter(Boolean);
|
|
@@ -1093,12 +1132,12 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
1093
1132
|
`using the conversation. Example:\n${CODE_SIGNAL}\nAdd a hover popover to message reactions ` +
|
|
1094
1133
|
`that lists who reacted with each emoji.\n\n` +
|
|
1095
1134
|
`Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
|
|
1096
|
-
`yet —
|
|
1097
|
-
`
|
|
1098
|
-
`action language.` +
|
|
1135
|
+
`yet — output ONLY the token ${CHAT_SIGNAL}. When unsure, choose ${CHAT_SIGNAL}; ` +
|
|
1136
|
+
`implementation and PR flow are for clear action language.` +
|
|
1099
1137
|
`${followupBlock}\n\n` +
|
|
1100
|
-
`You are only routing here — output ONLY
|
|
1101
|
-
|
|
1138
|
+
`You are only routing here — output ONLY the routing token (plus the imperative spec for ` +
|
|
1139
|
+
`${CODE_SIGNAL}). Do NOT use any tools, edit files, or run commands; separate tool-capable ` +
|
|
1140
|
+
`steps write the conversational answer or do the coding.` +
|
|
1102
1141
|
`${dmJudgmentBlock(implicitDm)}\n\n` +
|
|
1103
1142
|
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
1104
1143
|
const run = await doRun({
|
|
@@ -1134,7 +1173,10 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
1134
1173
|
const quiet = parseNoReply(out);
|
|
1135
1174
|
if (quiet.noReply) return { code: false, noReply: true, emoji: quiet.emoji, followupSignal };
|
|
1136
1175
|
}
|
|
1137
|
-
|
|
1176
|
+
// A classifier never authors the final answer. Even if a model ignores the
|
|
1177
|
+
// token contract and emits prose, discard it and let the normal responder —
|
|
1178
|
+
// with the vendor's real tools — answer the room.
|
|
1179
|
+
return { code: false, reply: null, followupSignal };
|
|
1138
1180
|
}
|
|
1139
1181
|
|
|
1140
1182
|
/**
|
|
@@ -2048,8 +2090,10 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
2048
2090
|
// 0779: [] for every vendor without a verified image flag — their argv is
|
|
2049
2091
|
// byte-identical to before, and the prompt note still names the files.
|
|
2050
2092
|
const imageArgs = codeImageArgs(vendor, localImages);
|
|
2093
|
+
const webArgs = codeWebArgs(vendor, parts.slice(1));
|
|
2051
2094
|
const codeArgs = [
|
|
2052
2095
|
...parts.slice(1),
|
|
2096
|
+
...webArgs,
|
|
2053
2097
|
...modelArgs,
|
|
2054
2098
|
...dirArgs,
|
|
2055
2099
|
...imageArgs,
|
|
@@ -2141,6 +2185,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
2141
2185
|
tool,
|
|
2142
2186
|
channelId,
|
|
2143
2187
|
threadRoot,
|
|
2188
|
+
decisionWaitMs: caps.decisionWaitMs,
|
|
2144
2189
|
provider: vendor,
|
|
2145
2190
|
}),
|
|
2146
2191
|
});
|
|
@@ -2158,6 +2203,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
2158
2203
|
tool,
|
|
2159
2204
|
channelId,
|
|
2160
2205
|
threadRoot,
|
|
2206
|
+
decisionWaitMs: caps.decisionWaitMs,
|
|
2161
2207
|
}),
|
|
2162
2208
|
});
|
|
2163
2209
|
} else if (gateCodexPermissions) {
|
|
@@ -2177,6 +2223,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
2177
2223
|
tool,
|
|
2178
2224
|
channelId,
|
|
2179
2225
|
threadRoot,
|
|
2226
|
+
decisionWaitMs: caps.decisionWaitMs,
|
|
2180
2227
|
provider: vendor,
|
|
2181
2228
|
}),
|
|
2182
2229
|
});
|
|
@@ -2196,6 +2243,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
2196
2243
|
tool,
|
|
2197
2244
|
channelId,
|
|
2198
2245
|
threadRoot,
|
|
2246
|
+
decisionWaitMs: caps.decisionWaitMs,
|
|
2199
2247
|
provider: vendor,
|
|
2200
2248
|
}),
|
|
2201
2249
|
});
|
|
@@ -2715,12 +2763,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2715
2763
|
onStopRequested,
|
|
2716
2764
|
});
|
|
2717
2765
|
}
|
|
2718
|
-
// Not a coding task →
|
|
2719
|
-
//
|
|
2720
|
-
if (routed.reply) {
|
|
2721
|
-
await tool("post_message", { channelId, parentId, body: routed.reply });
|
|
2722
|
-
return { status: "chat" };
|
|
2723
|
-
}
|
|
2766
|
+
// Not a coding task → the classifier is finished. The separate responder
|
|
2767
|
+
// below owns the answer and keeps its normal tools.
|
|
2724
2768
|
}
|
|
2725
2769
|
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
|
|
2726
2770
|
return { status: "chat" };
|
|
@@ -2780,14 +2824,19 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2780
2824
|
await reactQuietly({ tool, caps, message, emoji: routed.emoji });
|
|
2781
2825
|
return { status: "quiet" };
|
|
2782
2826
|
}
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2827
|
+
await respondConversationally({
|
|
2828
|
+
message,
|
|
2829
|
+
channelId,
|
|
2830
|
+
tool,
|
|
2831
|
+
me,
|
|
2832
|
+
cfg,
|
|
2833
|
+
repoLink,
|
|
2834
|
+
parentId,
|
|
2835
|
+
workspaceMemory,
|
|
2836
|
+
signal,
|
|
2837
|
+
context,
|
|
2838
|
+
caps,
|
|
2839
|
+
});
|
|
2791
2840
|
return { status: "chat" };
|
|
2792
2841
|
}
|
|
2793
2842
|
// routed.code → fall through to the coding flow below.
|
|
@@ -3300,6 +3349,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3300
3349
|
let drainProgress = async () => {};
|
|
3301
3350
|
// 0782 — the silent-work stop poll, torn down in the same finally as the run.
|
|
3302
3351
|
let stopPoller = null;
|
|
3352
|
+
// Structured transports expose the final assistant summary as an event;
|
|
3353
|
+
// argv transports fold it into the progress emitter. Keep one local value
|
|
3354
|
+
// so GitHub title/provenance selection never reaches for an out-of-scope
|
|
3355
|
+
// parser variable (0884).
|
|
3356
|
+
let resultText = "";
|
|
3303
3357
|
if (streamOn) {
|
|
3304
3358
|
if (!progressId) {
|
|
3305
3359
|
try {
|
|
@@ -3377,9 +3431,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3377
3431
|
// `exec`, so it has to sit ahead of it. [] for every vendor without a
|
|
3378
3432
|
// verified flag, leaving their argv byte-identical to before.
|
|
3379
3433
|
const imageArgs = codeImageArgs(vendor, localImages);
|
|
3434
|
+
const webArgs = codeWebArgs(vendor, parts.slice(1));
|
|
3380
3435
|
const codeArgs = streamOn
|
|
3381
|
-
? [...parts.slice(1), ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs, ...streamArgs]
|
|
3382
|
-
: [...parts.slice(1), ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs];
|
|
3436
|
+
? [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs, ...streamArgs]
|
|
3437
|
+
: [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs];
|
|
3383
3438
|
const handleCliData = (c) => {
|
|
3384
3439
|
// Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
|
|
3385
3440
|
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
@@ -3420,6 +3475,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3420
3475
|
} catch {
|
|
3421
3476
|
/* a progress fold must never break the run */
|
|
3422
3477
|
}
|
|
3478
|
+
if (ev?.t === "result" && typeof ev.summary === "string") {
|
|
3479
|
+
resultText = ev.summary;
|
|
3480
|
+
}
|
|
3423
3481
|
};
|
|
3424
3482
|
let run;
|
|
3425
3483
|
try {
|
|
@@ -3481,6 +3539,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3481
3539
|
tool,
|
|
3482
3540
|
channelId,
|
|
3483
3541
|
threadRoot,
|
|
3542
|
+
decisionWaitMs: caps.decisionWaitMs,
|
|
3484
3543
|
runId,
|
|
3485
3544
|
provider: vendor,
|
|
3486
3545
|
}),
|
|
@@ -3502,6 +3561,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3502
3561
|
tool,
|
|
3503
3562
|
channelId,
|
|
3504
3563
|
threadRoot,
|
|
3564
|
+
decisionWaitMs: caps.decisionWaitMs,
|
|
3505
3565
|
runId,
|
|
3506
3566
|
}),
|
|
3507
3567
|
});
|
|
@@ -3525,6 +3585,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3525
3585
|
tool,
|
|
3526
3586
|
channelId,
|
|
3527
3587
|
threadRoot,
|
|
3588
|
+
decisionWaitMs: caps.decisionWaitMs,
|
|
3528
3589
|
runId,
|
|
3529
3590
|
provider: vendor,
|
|
3530
3591
|
}),
|
|
@@ -3548,6 +3609,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3548
3609
|
tool,
|
|
3549
3610
|
channelId,
|
|
3550
3611
|
threadRoot,
|
|
3612
|
+
decisionWaitMs: caps.decisionWaitMs,
|
|
3551
3613
|
runId,
|
|
3552
3614
|
provider: vendor,
|
|
3553
3615
|
}),
|
|
@@ -3630,6 +3692,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3630
3692
|
}
|
|
3631
3693
|
}
|
|
3632
3694
|
}
|
|
3695
|
+
if (!resultText && emitter) {
|
|
3696
|
+
try {
|
|
3697
|
+
resultText = emitter.snapshot()?.lastLine || "";
|
|
3698
|
+
} catch {
|
|
3699
|
+
/* title fallback still has the routed task */
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3633
3702
|
if (run.aborted || signal?.aborted) {
|
|
3634
3703
|
console.log(" code → cancelled");
|
|
3635
3704
|
return { aborted: true };
|
|
@@ -3661,15 +3730,33 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3661
3730
|
Number((git(repoPath, ["rev-list", "--count", `${baseRef}..HEAD`]).stdout || "0").trim()) || 0;
|
|
3662
3731
|
if (ahead > 0) {
|
|
3663
3732
|
console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
|
|
3664
|
-
return {
|
|
3733
|
+
return {
|
|
3734
|
+
empty: true,
|
|
3735
|
+
failed: false,
|
|
3736
|
+
ahead,
|
|
3737
|
+
summary: (resultText || (streamArgs.length ? "" : run.stdout) || "").trim(),
|
|
3738
|
+
};
|
|
3665
3739
|
}
|
|
3666
3740
|
console.log(" code → no changes produced");
|
|
3667
|
-
return {
|
|
3741
|
+
return {
|
|
3742
|
+
empty: true,
|
|
3743
|
+
failed: false,
|
|
3744
|
+
ahead: 0,
|
|
3745
|
+
summary: (resultText || (streamArgs.length ? "" : run.stdout) || "").trim(),
|
|
3746
|
+
};
|
|
3668
3747
|
}
|
|
3669
3748
|
console.log(" code → diff captured");
|
|
3670
3749
|
const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
|
|
3671
3750
|
const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
|
|
3672
|
-
return {
|
|
3751
|
+
return {
|
|
3752
|
+
empty: false,
|
|
3753
|
+
diffText,
|
|
3754
|
+
truncated,
|
|
3755
|
+
omittedLines,
|
|
3756
|
+
stat,
|
|
3757
|
+
runFailed: run.status !== 0,
|
|
3758
|
+
summary: (resultText || (streamArgs.length ? "" : run.stdout) || "").trim(),
|
|
3759
|
+
};
|
|
3673
3760
|
};
|
|
3674
3761
|
|
|
3675
3762
|
// Persist the run's coding-agent session (0282) so a LATER iterate can resume it.
|
|
@@ -3886,6 +3973,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3886
3973
|
parentId: threadRoot,
|
|
3887
3974
|
settleId: selfSettleId,
|
|
3888
3975
|
runId,
|
|
3976
|
+
provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
|
|
3889
3977
|
usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
|
|
3890
3978
|
});
|
|
3891
3979
|
await recordSession(selfResult.prUrl);
|
|
@@ -3962,6 +4050,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3962
4050
|
existingPrUrl: continuingPrUrl,
|
|
3963
4051
|
settleId,
|
|
3964
4052
|
runId,
|
|
4053
|
+
provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
|
|
3965
4054
|
usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
|
|
3966
4055
|
});
|
|
3967
4056
|
// The settle already flipped the card to the report (body + metadata); editing
|
|
@@ -4024,6 +4113,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4024
4113
|
parentId: threadRoot,
|
|
4025
4114
|
existingPrUrl: continuingPrUrl,
|
|
4026
4115
|
runId,
|
|
4116
|
+
provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
|
|
4027
4117
|
// Normally {} — a gated run already reported its spend on the proposal card
|
|
4028
4118
|
// it is now shipping. Anything the CLI spent after that still comes home.
|
|
4029
4119
|
usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
|
package/src/mcp.mjs
CHANGED
|
@@ -48,13 +48,20 @@ export function makeClient({ url, token }) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Full tool objects, schemas included — capability sniffing reads these
|
|
52
|
+
// (0866: an ARG-level addition like waitMs shows up in a tool's inputSchema,
|
|
53
|
+
// not in the tool list's names).
|
|
54
|
+
async function listTools() {
|
|
52
55
|
try {
|
|
53
|
-
return (
|
|
56
|
+
return (await rpc("tools/list"))?.tools ?? [];
|
|
54
57
|
} catch {
|
|
55
58
|
return [];
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
|
|
62
|
+
async function listToolNames() {
|
|
63
|
+
return (await listTools()).map((t) => t.name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { rpc, tool, listTools, listToolNames };
|
|
60
67
|
}
|
package/src/progress-emitter.mjs
CHANGED
|
@@ -57,7 +57,8 @@ export function detectVendor(codingCmd) {
|
|
|
57
57
|
* Code installed just to answer chat (0521). Every command is the vendor's
|
|
58
58
|
* verified non-interactive print mode; the daemon appends the prompt as the last
|
|
59
59
|
* arg. codex carries --skip-git-repo-check because chat (and the read-only
|
|
60
|
-
* review sandbox) can run outside a git checkout
|
|
60
|
+
* review sandbox) can run outside a git checkout, plus the explicit web-search
|
|
61
|
+
* config because `codex exec` otherwise leaves that tool off. cursor carries
|
|
61
62
|
* --output-format text (explicit, so a CLI default change can never post raw
|
|
62
63
|
* JSONL into the channel) and --trust (its Jan-2026 workspace-trust gate fails
|
|
63
64
|
* headless runs at spawn in untrusted directories — 0572; pre-2026 CLIs reject
|
|
@@ -70,7 +71,9 @@ export function detectVendor(codingCmd) {
|
|
|
70
71
|
*/
|
|
71
72
|
export function fastChatCmd(vendor) {
|
|
72
73
|
if (vendor === "claude_code") return "claude -p --model haiku";
|
|
73
|
-
if (vendor === "codex")
|
|
74
|
+
if (vendor === "codex") {
|
|
75
|
+
return "codex exec --skip-git-repo-check -c tools.web_search=true";
|
|
76
|
+
}
|
|
74
77
|
if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
|
|
75
78
|
if (vendor === "opencode") return "opencode run";
|
|
76
79
|
if (vendor === "antigravity") return "agy -p";
|
|
@@ -107,6 +110,31 @@ export function codeStreamArgs(vendor) {
|
|
|
107
110
|
return [];
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Make Codex's built-in public web search available to code runs. This is a
|
|
115
|
+
* capability flag, not an instruction to browse; Codex decides whether the
|
|
116
|
+
* task needs it. An operator's explicit true/false override wins unchanged.
|
|
117
|
+
* Other vendors already expose their own web tools and receive no guessed
|
|
118
|
+
* flags.
|
|
119
|
+
* @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
|
|
120
|
+
* @param {string[]} baseArgs
|
|
121
|
+
* @returns {string[]}
|
|
122
|
+
*/
|
|
123
|
+
export function codeWebArgs(vendor, baseArgs = []) {
|
|
124
|
+
if (vendor !== "codex") return [];
|
|
125
|
+
const hasOverride = baseArgs.some((arg, index) => {
|
|
126
|
+
if (/^tools\.web_search=/.test(arg)) return true;
|
|
127
|
+
if (
|
|
128
|
+
(baseArgs[index - 1] === "-c" || baseArgs[index - 1] === "--config") &&
|
|
129
|
+
/^tools\.web_search=/.test(arg)
|
|
130
|
+
) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return /^--config=tools\.web_search=/.test(arg);
|
|
134
|
+
});
|
|
135
|
+
return hasOverride ? [] : ["-c", "tools.web_search=true"];
|
|
136
|
+
}
|
|
137
|
+
|
|
110
138
|
/**
|
|
111
139
|
* Extra args that hand the code run an IMAGE, appended to the code run's argv
|
|
112
140
|
* (0779). Verified against the installed binaries, per the 0521 rule — never
|
package/src/run.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { hostname } from "node:os";
|
|
6
6
|
import { basename } from "node:path";
|
|
7
7
|
import { makeClient } from "./mcp.mjs";
|
|
8
|
+
import { loadMentionCursor, saveMentionCursor } from "./cursor-store.mjs";
|
|
8
9
|
import { mentionHandle } from "./daemon.mjs";
|
|
9
10
|
import { handleTask } from "./handler.mjs";
|
|
10
11
|
import { createQueue, looksLikeCancel, dedupeKey } from "./queue.mjs";
|
|
@@ -59,7 +60,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
59
60
|
return;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
const { tool, listToolNames } = makeClient({ url: cfg.url, token: cfg.token });
|
|
63
|
+
const { tool, listTools, listToolNames } = makeClient({ url: cfg.url, token: cfg.token });
|
|
63
64
|
|
|
64
65
|
const who = await tool("whoami");
|
|
65
66
|
const me = { ...who, handle: mentionHandle(who.agentName) };
|
|
@@ -72,8 +73,23 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
72
73
|
log.log(`repos: ${Object.keys(cfg.repos).join(", ") || "(none configured)"}`);
|
|
73
74
|
|
|
74
75
|
const since = cfg.backfill ? 0 : Date.now();
|
|
75
|
-
|
|
76
|
+
// listTools (schemas included) is the primary read; a client exposing only
|
|
77
|
+
// listToolNames (an embedder's minimal mock, or an older embedded client)
|
|
78
|
+
// still names the tools — it just never advertises arg-level capabilities.
|
|
79
|
+
const toolList = typeof listTools === "function" ? await listTools() : [];
|
|
80
|
+
const toolNames = toolList.length
|
|
81
|
+
? toolList.map((t) => t.name)
|
|
82
|
+
: typeof listToolNames === "function"
|
|
83
|
+
? await listToolNames()
|
|
84
|
+
: [];
|
|
76
85
|
const useMentions = toolNames.includes("list_mentions");
|
|
86
|
+
// Arg-level capability sniffing (0866): a long-poll-capable server declares
|
|
87
|
+
// `waitMs` in the tool's inputSchema. Absent (older server) → the daemon
|
|
88
|
+
// falls back to plain interval polling, exactly as before.
|
|
89
|
+
const toolArgProps = (name) =>
|
|
90
|
+
toolList.find((t) => t.name === name)?.inputSchema?.properties ?? {};
|
|
91
|
+
const serverMentionWait = "waitMs" in toolArgProps("list_mentions");
|
|
92
|
+
const serverDecisionWait = "waitMs" in toolArgProps("get_permission_decision");
|
|
77
93
|
// Capabilities of THIS server, so the handler degrades gracefully on older
|
|
78
94
|
// deploys (e.g. no edit_message → no live heartbeat, rather than erroring).
|
|
79
95
|
const caps = {
|
|
@@ -112,6 +128,10 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
112
128
|
// that needs no words. Absent on older servers → the agent stays quiet
|
|
113
129
|
// instead, exactly as if it had no hand to wave.
|
|
114
130
|
react: toolNames.includes("add_reaction"),
|
|
131
|
+
// Long-poll hold for the permission gate (0866): each decision read blocks
|
|
132
|
+
// server-side until a human decides, so an Allow reaches the paused tool in
|
|
133
|
+
// under a second instead of on the gate's next poll. 0 on older servers.
|
|
134
|
+
decisionWaitMs: serverDecisionWait ? 20000 : 0,
|
|
115
135
|
};
|
|
116
136
|
|
|
117
137
|
// The wake doorbell (0824): capability-checked like every optional surface.
|
|
@@ -190,7 +210,16 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
190
210
|
emit({ type: "status", text: useMentions ? "watching @-mentions" : `watching ${channelIds.length} channel(s)` });
|
|
191
211
|
|
|
192
212
|
const seen = new Set();
|
|
193
|
-
|
|
213
|
+
// Resume from the persisted cursor (0866) so mentions that arrived while the
|
|
214
|
+
// daemon was down are delivered on restart instead of silently skipped —
|
|
215
|
+
// bounded by catchupMs. `backfill` still means "from the beginning" and wins.
|
|
216
|
+
const persistedCursor = cfg.backfill
|
|
217
|
+
? null
|
|
218
|
+
: loadMentionCursor(me.agentId, { catchupMs: cfg.catchupMs });
|
|
219
|
+
if (persistedCursor) log.log(`resuming mention cursor from ${persistedCursor}`);
|
|
220
|
+
const cursor = {
|
|
221
|
+
value: persistedCursor ?? (since ? new Date(since).toISOString() : null),
|
|
222
|
+
};
|
|
194
223
|
// 0860 — a DM message inside its settle window: the server withheld it and
|
|
195
224
|
// said when it matures. One shortened sleep picks it up right then, instead
|
|
196
225
|
// of the person waiting out the full poll interval mid-conversation.
|
|
@@ -343,10 +372,18 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
343
372
|
}
|
|
344
373
|
}
|
|
345
374
|
|
|
375
|
+
// Long-poll (0866): ask a capable server to HOLD the request and answer the
|
|
376
|
+
// moment a mention lands — pickup latency stops being pollMs. The server
|
|
377
|
+
// caps a hold at 25s; asking for more just gets 25s.
|
|
378
|
+
const mentionWaitMs = () =>
|
|
379
|
+
serverMentionWait && liveCfg.longPollMs > 0 ? Math.floor(liveCfg.longPollMs) : 0;
|
|
380
|
+
|
|
346
381
|
async function passViaMentions() {
|
|
382
|
+
const waitMs = mentionWaitMs();
|
|
347
383
|
const mentionArgs = {
|
|
348
384
|
...(cursor.value ? { since: cursor.value } : {}),
|
|
349
385
|
...(cfg.channelId ? { channelId: cfg.channelId } : {}),
|
|
386
|
+
...(waitMs ? { waitMs } : {}),
|
|
350
387
|
};
|
|
351
388
|
const out = await tool("list_mentions", mentionArgs);
|
|
352
389
|
// Floor the server's settle hint so a clock skew can never hot-loop the poll.
|
|
@@ -355,6 +392,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
355
392
|
? Math.max(500, out.retryAfterMs)
|
|
356
393
|
: null;
|
|
357
394
|
const mentions = (out?.mentions ?? []).slice().reverse();
|
|
395
|
+
let advanced = false;
|
|
358
396
|
for (const m of mentions) {
|
|
359
397
|
if (seen.has(m.id)) continue;
|
|
360
398
|
seen.add(m.id);
|
|
@@ -365,6 +403,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
365
403
|
if (m.parentId && boundThreadRoots.has(m.parentId)) {
|
|
366
404
|
if (!cursor.value || new Date(m.created_at).getTime() > new Date(cursor.value).getTime()) {
|
|
367
405
|
cursor.value = m.created_at;
|
|
406
|
+
advanced = true;
|
|
368
407
|
}
|
|
369
408
|
continue;
|
|
370
409
|
}
|
|
@@ -373,8 +412,17 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
373
412
|
// so a lexicographic string compare can fail to advance the cursor.
|
|
374
413
|
if (!cursor.value || new Date(m.created_at).getTime() > new Date(cursor.value).getTime()) {
|
|
375
414
|
cursor.value = m.created_at;
|
|
415
|
+
advanced = true;
|
|
376
416
|
}
|
|
377
417
|
}
|
|
418
|
+
// Persist on advance only — an idle pass has nothing new to remember —
|
|
419
|
+
// and only while catch-up is enabled: with catchupMs 0 the file would
|
|
420
|
+
// never be read back, so writing it would be pure disk noise (this is
|
|
421
|
+
// also what keeps embedded/mocked runs from touching a real ~/.hilos).
|
|
422
|
+
// Best-effort: a failed write degrades to restart-at-now, never a crash.
|
|
423
|
+
if (advanced && cursor.value && liveCfg.catchupMs > 0) {
|
|
424
|
+
saveMentionCursor(me.agentId, cursor.value, { log });
|
|
425
|
+
}
|
|
378
426
|
}
|
|
379
427
|
|
|
380
428
|
async function passViaReplyBridge() {
|
|
@@ -432,11 +480,13 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
432
480
|
} catch (e) {
|
|
433
481
|
log.error(`config reload error (keeping previous): ${e.message}`);
|
|
434
482
|
}
|
|
483
|
+
let passFailed = false;
|
|
435
484
|
try {
|
|
436
485
|
await passViaReplyBridge();
|
|
437
486
|
if (useMentions) await passViaMentions();
|
|
438
487
|
else await passViaScan();
|
|
439
488
|
} catch (e) {
|
|
489
|
+
passFailed = true;
|
|
440
490
|
log.error(`poll error: ${e.message}`);
|
|
441
491
|
}
|
|
442
492
|
// --once is for cron-style single passes: drain the queued work before exit.
|
|
@@ -446,11 +496,15 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
446
496
|
}
|
|
447
497
|
if (signal?.aborted) break;
|
|
448
498
|
// Interruptible so an abort during the sleep returns promptly instead of
|
|
449
|
-
// waiting out the full poll interval
|
|
450
|
-
// sleep
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
499
|
+
// waiting out the full poll interval, and the doorbell (0824) still cuts
|
|
500
|
+
// any sleep short. A long-polled pass (0866) already spent its waiting
|
|
501
|
+
// inside the held request, so only a short breather follows it — unless
|
|
502
|
+
// the pass FAILED, where re-holding immediately would hammer a hiccuping
|
|
503
|
+
// server; an error always backs off by the full pollMs. A settling DM
|
|
504
|
+
// (0860) shortens one sleep to the server's hint in either mode.
|
|
505
|
+
const held = useMentions && mentionWaitMs() > 0 && !passFailed;
|
|
506
|
+
const base = held ? 250 : liveCfg.pollMs;
|
|
507
|
+
await interruptibleSleep(pollAgainMs != null ? Math.min(base, pollAgainMs) : base);
|
|
454
508
|
} while (!signal?.aborted);
|
|
455
509
|
|
|
456
510
|
// Clean stop: close the doorbell socket, make sure any active job is cancelled
|