hilos-agent 0.9.0 → 0.9.2
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 +80 -16
- package/bin/hilos-agent.mjs +16 -6
- package/package.json +1 -1
- package/src/acp-session.mjs +69 -54
- package/src/agent-events.mjs +645 -45
- package/src/argv.mjs +61 -0
- package/src/attachments.mjs +310 -0
- package/src/claude-permissions.mjs +445 -0
- package/src/cli.mjs +56 -0
- package/src/codex-mcp-session.mjs +619 -0
- package/src/config.mjs +83 -7
- package/src/handler.mjs +914 -77
- package/src/hook.mjs +793 -108
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +3 -2
- package/src/model-resolve.mjs +180 -11
- package/src/permission-gate.mjs +269 -0
- package/src/progress-emitter.mjs +100 -5
- package/src/queue.mjs +21 -5
- package/src/redact.mjs +11 -1
- package/src/reply-bridge.mjs +847 -0
- package/src/resume.mjs +48 -11
- package/src/run.mjs +130 -4
- package/src/transcript.mjs +153 -0
package/src/resume.mjs
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
// - The pure transforms (parse/merge/prune/serialize) take an injected clock
|
|
19
19
|
// (nowMs) and never call Date.now/os, so they unit-test with a fixed time.
|
|
20
20
|
// - Every fs read/write is guarded: a state IO failure NEVER breaks a run.
|
|
21
|
-
// - We NEVER guess a vendor's resume flag. claude_code, cursor and
|
|
22
|
-
// have
|
|
23
|
-
// branch+feedback iterate
|
|
21
|
+
// - We NEVER guess a vendor's resume flag. claude_code, cursor, opencode and
|
|
22
|
+
// codex all have live-verified ones (0282/0573/0608/0783); anything else
|
|
23
|
+
// degrades to today's branch+feedback iterate.
|
|
24
24
|
|
|
25
25
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
26
26
|
import { homedir } from "node:os";
|
|
@@ -40,6 +40,13 @@ export const STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
40
40
|
* entry) from ever reaching `--session`. */
|
|
41
41
|
const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9_-]+$/;
|
|
42
42
|
|
|
43
|
+
/** codex thread ids are UUIDs (`01a00968-4a94-7821-…`, live-captured off
|
|
44
|
+
* `codex exec --json`'s `thread.started`). `codex exec resume` takes "a
|
|
45
|
+
* UUID or a thread name, UUIDs take precedence" — so the shape guard keeps a
|
|
46
|
+
* foreign id (an opencode `ses_…` inherited from an older state entry) from
|
|
47
|
+
* ever being read as a THREAD NAME and silently resuming the wrong thing. */
|
|
48
|
+
const CODEX_SESSION_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
49
|
+
|
|
43
50
|
/**
|
|
44
51
|
* The resume flags for a coding vendor, or [] when resume isn't safe/known.
|
|
45
52
|
*
|
|
@@ -52,10 +59,25 @@ const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9_-]+$/;
|
|
|
52
59
|
* continues the pinned session non-interactively (a follow-up recalled what
|
|
53
60
|
* the first run did); the id is the `sessionID` every `--format json` event
|
|
54
61
|
* carries. Its `--fork` twin is deliberately NOT emitted: the daemon has no
|
|
55
|
-
* thread-branching semantics to map onto it today.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
62
|
+
* thread-branching semantics to map onto it today.
|
|
63
|
+
*
|
|
64
|
+
* codex (0783): NOT a flag — a SUBCOMMAND, `codex exec resume <id> <prompt>`.
|
|
65
|
+
* Live-verified against codex-cli 0.144.1: the id is the `thread_id` that
|
|
66
|
+
* `exec --json`'s `thread.started` carries, and a resumed run answered from the
|
|
67
|
+
* first run's context (it recalled the file it had created and what was in it).
|
|
68
|
+
* The two argv facts that make this splice safe were verified the same way —
|
|
69
|
+
* flags BEFORE `resume` are parsed as `exec`'s own (so the user's
|
|
70
|
+
* `--sandbox`/`--skip-git-repo-check` still apply, and an `exec`-level `-m`
|
|
71
|
+
* reaches the resumed turn), and `--json` AFTER `resume` is accepted (it is on
|
|
72
|
+
* `codex exec resume --help` too). That is exactly the order the handler
|
|
73
|
+
* builds: base, model, resume, stream, prompt.
|
|
74
|
+
*
|
|
75
|
+
* `--last` is deliberately NOT a fallback here. It means "the newest recorded
|
|
76
|
+
* session" machine-wide, and the daemon runs threads concurrently, so it would
|
|
77
|
+
* happily resume ANOTHER thread's session. No recorded id, no resume — the
|
|
78
|
+
* branch+feedback iterate is only ever slower, never wrong.
|
|
79
|
+
*
|
|
80
|
+
* A falsy or non-string sessionId also returns [] (nothing to resume).
|
|
59
81
|
*
|
|
60
82
|
* @param {'claude_code'|'codex'|'cursor'|'opencode'|'hermes'|'unknown'} vendor
|
|
61
83
|
* @param {string|null|undefined} sessionId
|
|
@@ -63,6 +85,11 @@ const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9_-]+$/;
|
|
|
63
85
|
*/
|
|
64
86
|
export function buildResumeArgs(vendor, sessionId) {
|
|
65
87
|
if (typeof sessionId !== "string" || !sessionId.trim()) return [];
|
|
88
|
+
if (vendor === "codex") {
|
|
89
|
+
// A thread NAME is also accepted by the CLI, so an id of any other shape
|
|
90
|
+
// wouldn't error — it would resume something else. Only pass a UUID.
|
|
91
|
+
return CODEX_SESSION_RE.test(sessionId.trim()) ? ["resume", sessionId.trim()] : [];
|
|
92
|
+
}
|
|
66
93
|
if (vendor === "opencode") {
|
|
67
94
|
// A wrong id is not free here: an unknown id exits 1 ("Session not found"),
|
|
68
95
|
// and an id from ANOTHER project directory HANGS the CLI — so only pass a
|
|
@@ -74,7 +101,14 @@ export function buildResumeArgs(vendor, sessionId) {
|
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
/** Vendors whose resume flag is proven (see buildResumeArgs). */
|
|
77
|
-
const RESUMABLE_VENDORS = new Set(["claude_code", "cursor", "opencode"]);
|
|
104
|
+
const RESUMABLE_VENDORS = new Set(["claude_code", "cursor", "opencode", "codex"]);
|
|
105
|
+
|
|
106
|
+
/** Vendors whose sessions belong to a WORKSPACE, so a resume must run in the
|
|
107
|
+
* same one. opencode: a `--session` from another project hangs its CLI (0608).
|
|
108
|
+
* codex: `exec resume` is cwd-filtered by design (its own `--all` exists to
|
|
109
|
+
* disable that filtering), and a session carries the first run's file context,
|
|
110
|
+
* so resuming it somewhere else would be quietly wrong rather than loud. */
|
|
111
|
+
const PROJECT_SCOPED_VENDORS = new Set(["opencode", "codex"]);
|
|
78
112
|
|
|
79
113
|
/**
|
|
80
114
|
* Should this iterate resume a recorded session? PURE — the handler passes what
|
|
@@ -93,8 +127,11 @@ const RESUMABLE_VENDORS = new Set(["claude_code", "cursor", "opencode"]);
|
|
|
93
127
|
* invocation. An entry written before 0608 has no vendor → degrade once, then
|
|
94
128
|
* the next capture rewrites the entry with one.
|
|
95
129
|
* - project: opencode sessions are scoped per project, and a `--session` from
|
|
96
|
-
* another project HANGS its CLI rather than erroring (0608)
|
|
97
|
-
*
|
|
130
|
+
* another project HANGS its CLI rather than erroring (0608); codex filters
|
|
131
|
+
* its recorded sessions by cwd for the same reason (0783). Enforced for those
|
|
132
|
+
* two (PROJECT_SCOPED_VENDORS); other vendors resume as before. An entry
|
|
133
|
+
* written before its vendor joined the set has no key → degrade once, then
|
|
134
|
+
* the next capture rewrites the entry with one.
|
|
98
135
|
*
|
|
99
136
|
* @param {object} o
|
|
100
137
|
* @param {string} o.vendor — the CURRENT command's vendor
|
|
@@ -114,7 +151,7 @@ export function resumeDecision({ vendor, entry, serverSessionId = null, machine,
|
|
|
114
151
|
if (local.vendor !== vendor) {
|
|
115
152
|
return { sessionId: null, reason: local.vendor ? "recorded-by-another-vendor" : "vendor-not-recorded" };
|
|
116
153
|
}
|
|
117
|
-
if (vendor
|
|
154
|
+
if (PROJECT_SCOPED_VENDORS.has(vendor) && local.cwd !== projectKey) {
|
|
118
155
|
return { sessionId: null, reason: local.cwd ? "recorded-in-another-project" : "project-not-recorded" };
|
|
119
156
|
}
|
|
120
157
|
if (serverSessionId && serverSessionId !== local.sessionId) {
|
package/src/run.mjs
CHANGED
|
@@ -8,7 +8,9 @@ import { makeClient } from "./mcp.mjs";
|
|
|
8
8
|
import { mentionHandle } from "./daemon.mjs";
|
|
9
9
|
import { handleTask } from "./handler.mjs";
|
|
10
10
|
import { createQueue, looksLikeCancel, dedupeKey } from "./queue.mjs";
|
|
11
|
+
import { cleanupImages, fetchMentionImages } from "./attachments.mjs";
|
|
11
12
|
import { reloadConfig } from "./config.mjs";
|
|
13
|
+
import { scanReplyBridge, handleReplyBridgeJob } from "./reply-bridge.mjs";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* The poll loop. Embeddable: pass a `signal` to stop it cleanly (interrupts the
|
|
@@ -109,6 +111,10 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
109
111
|
runtimePermissions:
|
|
110
112
|
toolNames.includes("request_permission") &&
|
|
111
113
|
toolNames.includes("get_permission_decision"),
|
|
114
|
+
// Opt-in run transcripts (0792). Two gates, and BOTH must say yes: the
|
|
115
|
+
// server has to offer the tool, and the operator has to have turned
|
|
116
|
+
// `uploadTranscripts` on. Older servers simply never see the call.
|
|
117
|
+
uploadTranscript: toolNames.includes("upload_run_transcript"),
|
|
112
118
|
};
|
|
113
119
|
|
|
114
120
|
// Register local folders (0324/0325): a folder-mode daemon announces each
|
|
@@ -123,8 +129,22 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
123
129
|
if (!channelId || !path) continue;
|
|
124
130
|
const title = basename(path);
|
|
125
131
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
// ifVacant (0546): startup is an IMPLICIT link — a stale folders entry in
|
|
133
|
+
// this machine's config must never replace a source the team connected
|
|
134
|
+
// deliberately. A refusal logs; a person can re-point the channel on
|
|
135
|
+
// purpose from the UI or by asking the agent in-channel.
|
|
136
|
+
const res = await tool("link_folder", { channelId, path, title, hostname: hostname(), ifVacant: true });
|
|
137
|
+
// 0546 — a channel has ONE code source, so registering this folder may
|
|
138
|
+
// have replaced a repo or another folder. The server says which; repeat
|
|
139
|
+
// it rather than report a plain "linked" over someone else's binding.
|
|
140
|
+
if (res && res.ok === false) {
|
|
141
|
+
log.error(`link_folder refused for ${path}: ${res.error}`);
|
|
142
|
+
} else {
|
|
143
|
+
emit({
|
|
144
|
+
type: "status",
|
|
145
|
+
text: res?.message || `linked folder ${title} to its channel`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
128
148
|
} catch (e) {
|
|
129
149
|
log.error(`link_folder failed for ${path}: ${e?.message || e}`);
|
|
130
150
|
}
|
|
@@ -151,9 +171,15 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
151
171
|
// lets a "stop" (and new mentions) be picked up while a task is still running.
|
|
152
172
|
const queue = createQueue({
|
|
153
173
|
concurrency: cfg.queueConcurrency || 1,
|
|
154
|
-
runJob: (job, { signal: jobSignal }) =>
|
|
174
|
+
runJob: (job, { signal: jobSignal }) =>
|
|
175
|
+
job.kind === "reply-bridge"
|
|
176
|
+
? safeBridgeHandle(job.bridge, jobSignal)
|
|
177
|
+
: safeHandle(job.message, job.channelId, jobSignal),
|
|
155
178
|
});
|
|
156
179
|
|
|
180
|
+
let boundThreadRoots = new Set();
|
|
181
|
+
let replyScanOffset = 0;
|
|
182
|
+
|
|
157
183
|
// Stopping the embedded daemon: the moment the caller aborts, cancel the active
|
|
158
184
|
// job (via the queue's existing cancel path — it aborts the in-flight job's
|
|
159
185
|
// signal) so a multi-minute run is torn down promptly, and emit a stopping
|
|
@@ -172,10 +198,43 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
172
198
|
|
|
173
199
|
async function safeHandle(message, channelId, jobSignal) {
|
|
174
200
|
emit({ type: "task-start", channelId, messageId: message.id, text: (message.body || "").slice(0, 200) });
|
|
201
|
+
// 0779 — pull this mention's images to disk HERE, as the job starts, not
|
|
202
|
+
// when it was enqueued. Downloading at enqueue meant a burst of image
|
|
203
|
+
// mentions held every job's bytes (up to 4 x 10MB each) on the user's disk
|
|
204
|
+
// while one long job ran; this way at most one temp dir exists at a time.
|
|
205
|
+
// The url is signed for an hour, which covers any normal queue wait — and a
|
|
206
|
+
// job that waits longer than that simply runs text-only, which the
|
|
207
|
+
// transcript already accounts for. Fail-soft: never blocks the run.
|
|
208
|
+
let images = { dir: null, files: [] };
|
|
209
|
+
try {
|
|
210
|
+
images = await fetchMentionImages(message, { log });
|
|
211
|
+
} catch {
|
|
212
|
+
/* an attachment is never the reason a run doesn't happen */
|
|
213
|
+
}
|
|
214
|
+
const withImages = images.files.length
|
|
215
|
+
? { ...message, imageDir: images.dir, images: images.files }
|
|
216
|
+
: message;
|
|
217
|
+
// 0782 — a person pressed Stop on this run's live card. The server already
|
|
218
|
+
// settled the run row (status canceled); this end says so in the room and
|
|
219
|
+
// tears the work down through the SAME cancel path a chat "stop" uses, so a
|
|
220
|
+
// stopped run leaves a process group killed and a card that isn't working.
|
|
221
|
+
// Once per job: the signal stays set on the row until the run ends.
|
|
222
|
+
let stopNoticed = false;
|
|
223
|
+
const onStopRequested = ({ by } = {}) => {
|
|
224
|
+
if (stopNoticed) return;
|
|
225
|
+
stopNoticed = true;
|
|
226
|
+
const who = typeof by === "string" && by.trim() ? by.trim() : null;
|
|
227
|
+
void tool("post_message", {
|
|
228
|
+
channelId,
|
|
229
|
+
parentId: message.parentId ?? null,
|
|
230
|
+
body: who ? `Stopped by ${who} from the room.` : "Stopped from the room.",
|
|
231
|
+
}).catch(() => {});
|
|
232
|
+
queue.cancelActive("stopped from the room");
|
|
233
|
+
};
|
|
175
234
|
try {
|
|
176
235
|
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
177
236
|
// liveCfg so a job uses the latest model/permission/codingCmd at run time.
|
|
178
|
-
const result = await handler({ message, channelId, tool, me, caps }, liveCfg, undefined, { signal: jobSignal });
|
|
237
|
+
const result = await handler({ message: withImages, channelId, tool, me, caps }, liveCfg, undefined, { signal: jobSignal, onStopRequested });
|
|
179
238
|
emit({ type: "task-done", channelId, status: result && result.status });
|
|
180
239
|
} catch (e) {
|
|
181
240
|
emit({ type: "task-error", channelId, error: e?.message || String(e) });
|
|
@@ -184,6 +243,34 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
184
243
|
channelId,
|
|
185
244
|
body: `Hit an error working on that: ${e.message}`,
|
|
186
245
|
}).catch(() => {});
|
|
246
|
+
} finally {
|
|
247
|
+
// 0779 — the downloaded screenshots die with the run, success or not.
|
|
248
|
+
// They are a person's files, and nothing after this run has any use for
|
|
249
|
+
// them. Downloading at execution time means this always pairs with the
|
|
250
|
+
// fetch above: there is no path that leaves a dir behind.
|
|
251
|
+
await cleanupImages(images.dir);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function safeBridgeHandle(bridge, jobSignal) {
|
|
256
|
+
const { binding, replies } = bridge;
|
|
257
|
+
emit({
|
|
258
|
+
type: "task-start",
|
|
259
|
+
channelId: binding.channelId,
|
|
260
|
+
messageId: replies[0]?.id,
|
|
261
|
+
text: (replies[0]?.body || "").slice(0, 200),
|
|
262
|
+
source: "reply-bridge",
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
const result = await handleReplyBridgeJob(bridge, liveCfg, {
|
|
266
|
+
tool,
|
|
267
|
+
log,
|
|
268
|
+
signal: jobSignal,
|
|
269
|
+
});
|
|
270
|
+
emit({ type: "task-done", channelId: binding.channelId, status: result?.status, source: "reply-bridge" });
|
|
271
|
+
} catch (e) {
|
|
272
|
+
emit({ type: "task-error", channelId: binding.channelId, error: e?.message || String(e), source: "reply-bridge" });
|
|
273
|
+
log.error(`reply bridge error: ${e?.message || e}`);
|
|
187
274
|
}
|
|
188
275
|
}
|
|
189
276
|
|
|
@@ -229,6 +316,16 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
229
316
|
for (const m of mentions) {
|
|
230
317
|
if (seen.has(m.id)) continue;
|
|
231
318
|
seen.add(m.id);
|
|
319
|
+
// A human reply in a locally-bound thread belongs to the provider
|
|
320
|
+
// session that created that anchor — even when the person also typed an
|
|
321
|
+
// @mention. Let the bridge take it; starting a fresh repo task here would
|
|
322
|
+
// duplicate work and lose the session context this feature exists for.
|
|
323
|
+
if (m.parentId && boundThreadRoots.has(m.parentId)) {
|
|
324
|
+
if (!cursor.value || new Date(m.created_at).getTime() > new Date(cursor.value).getTime()) {
|
|
325
|
+
cursor.value = m.created_at;
|
|
326
|
+
}
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
232
329
|
await intake(m, m.channelId);
|
|
233
330
|
// Compare numerically — created_at formats differ (Z vs +00:00 offset),
|
|
234
331
|
// so a lexicographic string compare can fail to advance the cursor.
|
|
@@ -238,6 +335,34 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
238
335
|
}
|
|
239
336
|
}
|
|
240
337
|
|
|
338
|
+
async function passViaReplyBridge() {
|
|
339
|
+
if (liveCfg.replyBridge === false) {
|
|
340
|
+
boundThreadRoots = new Set();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const scanned = await scanReplyBridge({
|
|
344
|
+
tool,
|
|
345
|
+
token: liveCfg.token,
|
|
346
|
+
agentId: me.agentId,
|
|
347
|
+
offset: replyScanOffset,
|
|
348
|
+
});
|
|
349
|
+
replyScanOffset = scanned.nextOffset;
|
|
350
|
+
boundThreadRoots = scanned.boundThreadRoots;
|
|
351
|
+
for (const bridge of scanned.jobs) {
|
|
352
|
+
const ids = bridge.replies.map((reply) => reply.id).filter(Boolean);
|
|
353
|
+
const key = `reply-bridge:${bridge.binding.threadRootId}:${ids.join(",")}`;
|
|
354
|
+
const queued = queue.enqueue({ kind: "reply-bridge", bridge, key });
|
|
355
|
+
if (queued.deduped) continue;
|
|
356
|
+
if (liveCfg.queueAcks && !queued.startedImmediately) {
|
|
357
|
+
await tool("post_message", {
|
|
358
|
+
channelId: bridge.binding.channelId,
|
|
359
|
+
parentId: bridge.binding.threadRootId,
|
|
360
|
+
body: "Got it — this will continue in the same local session after the current task.",
|
|
361
|
+
}).catch(() => {});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
241
366
|
async function passViaScan() {
|
|
242
367
|
for (const channelId of channelIds) {
|
|
243
368
|
const out = await tool("read_channel", { channelId, limit: 30 });
|
|
@@ -266,6 +391,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
266
391
|
log.error(`config reload error (keeping previous): ${e.message}`);
|
|
267
392
|
}
|
|
268
393
|
try {
|
|
394
|
+
await passViaReplyBridge();
|
|
269
395
|
if (useMentions) await passViaMentions();
|
|
270
396
|
else await passViaScan();
|
|
271
397
|
} catch (e) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The daemon's half of the run transcript.
|
|
2
|
+
//
|
|
3
|
+
// The progress emitter reads the coding CLI's stream and throws the bytes away:
|
|
4
|
+
// what survives is an eight-step ring, twenty file names, and thirty activity
|
|
5
|
+
// rows, overwritten in place. That is the right shape for a LIVE card and the
|
|
6
|
+
// wrong shape for a record — once the run settles, the only account of what the
|
|
7
|
+
// agent actually did is gone.
|
|
8
|
+
//
|
|
9
|
+
// This is a tap on the same run, kept beside the parser rather than inside it:
|
|
10
|
+
// a bounded tail, redacted with the package's own pass before it leaves the
|
|
11
|
+
// machine. The server redacts and caps it again on arrival either way.
|
|
12
|
+
//
|
|
13
|
+
// CONSENT: nothing here runs unless the operator set `uploadTranscripts: true`.
|
|
14
|
+
// This is their machine.
|
|
15
|
+
//
|
|
16
|
+
// WHAT GOES IN. A run reaches the daemon over one of two kinds of transport,
|
|
17
|
+
// and the tap has to keep both honest:
|
|
18
|
+
//
|
|
19
|
+
// - **Raw NDJSON** (an argv `claude -p --output-format stream-json`, a gated
|
|
20
|
+
// claude run, opencode's JSON bridge). stdout IS the record; lines pass
|
|
21
|
+
// through untouched.
|
|
22
|
+
// - **Structured sessions** (ACP, `codex mcp-server`). stdout carries only
|
|
23
|
+
// the assistant's PROSE — every tool call travels beside it as a normalized
|
|
24
|
+
// event. Keeping stdout alone would ship a transcript with no execution in
|
|
25
|
+
// it at all, which is the worst of both: bytes uploaded, nothing learned.
|
|
26
|
+
// So events are recorded too, one `hilos_event` line each, and prose lines
|
|
27
|
+
// are wrapped into the same shape so the whole file is one readable NDJSON
|
|
28
|
+
// record rather than a mix of frames and loose text.
|
|
29
|
+
//
|
|
30
|
+
// `lib/transcript-digest.ts` reads `hilos_event`, which is why the shape is
|
|
31
|
+
// worth the wrapping.
|
|
32
|
+
|
|
33
|
+
import { redactSecrets, truncateTranscriptTail, TRANSCRIPT_TRUNCATED_LINE } from "./redact.mjs";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How much of the stream a run may ship. A 25-minute Claude run writes a few MB
|
|
37
|
+
* of stream-json; this keeps the upload to one ordinary request body instead of
|
|
38
|
+
* a surprise multi-megabyte POST off someone's laptop. The tail is what matters
|
|
39
|
+
* (the run's payoff is at the end), so the front is what gets cut.
|
|
40
|
+
*
|
|
41
|
+
* The server caps at its own, higher ceiling as well — this one exists so the
|
|
42
|
+
* bytes never leave the machine, not to be the enforcement point.
|
|
43
|
+
*/
|
|
44
|
+
export const DAEMON_TRANSCRIPT_MAX_CHARS = 1_000_000;
|
|
45
|
+
|
|
46
|
+
/** No single event line may dominate the tail. */
|
|
47
|
+
const MAX_EVENT_LINE = 2_000;
|
|
48
|
+
|
|
49
|
+
/** Accounting and bookkeeping, not narration — these never become a record. */
|
|
50
|
+
const UNRECORDED_EVENTS = new Set(["usage", "session"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A bounded, whole-line NDJSON record of a run.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [o]
|
|
56
|
+
* @param {number} [o.maxChars] — ceiling for the retained tail.
|
|
57
|
+
* @returns {{ push: (chunk: string) => void, pushEvent: (ev: object) => void,
|
|
58
|
+
* text: () => string, chars: () => number }}
|
|
59
|
+
*/
|
|
60
|
+
export function createTranscriptTap({ maxChars = DAEMON_TRANSCRIPT_MAX_CHARS } = {}) {
|
|
61
|
+
// Trim lazily, at roughly double the ceiling: a per-chunk cut on a long run
|
|
62
|
+
// would copy the whole buffer thousands of times for no benefit.
|
|
63
|
+
const slack = Math.max(maxChars, 1) * 2;
|
|
64
|
+
let buf = "";
|
|
65
|
+
// An unterminated stdout line, held until its newline arrives. A chunk
|
|
66
|
+
// boundary is not a record boundary — codex feeds token deltas, so without
|
|
67
|
+
// this the file would be one wrapped line per delta.
|
|
68
|
+
let partial = "";
|
|
69
|
+
// Whether anything has been cut off the front. It has to be REMEMBERED rather
|
|
70
|
+
// than inferred at the end: a trim that lands exactly on the ceiling leaves a
|
|
71
|
+
// buffer `truncateTranscriptTail` considers already short enough, so the cut
|
|
72
|
+
// would ship unmarked with a half record on top of it.
|
|
73
|
+
let truncated = false;
|
|
74
|
+
|
|
75
|
+
function trim() {
|
|
76
|
+
const cut = buf.slice(buf.length - maxChars);
|
|
77
|
+
const newline = cut.indexOf("\n");
|
|
78
|
+
// Drop the partial record the cut opened on — never ship a fragment.
|
|
79
|
+
buf = newline >= 0 ? cut.slice(newline + 1) : "";
|
|
80
|
+
truncated = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function append(line) {
|
|
84
|
+
buf += line + "\n";
|
|
85
|
+
if (buf.length > slack) trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One line of the run's own output: a frame as-is, or prose wrapped. */
|
|
89
|
+
function record(line) {
|
|
90
|
+
const text = line.trim();
|
|
91
|
+
if (!text) return;
|
|
92
|
+
// A frame speaks for itself. Anything else is the assistant talking, and it
|
|
93
|
+
// becomes a note so the record stays one parseable shape end to end.
|
|
94
|
+
append(text.startsWith("{") ? text : event({ t: "note", text }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function event(ev) {
|
|
98
|
+
const line = JSON.stringify({ type: "hilos_event", ...ev });
|
|
99
|
+
return line.length > MAX_EVENT_LINE ? line.slice(0, MAX_EVENT_LINE) + '"}' : line;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
/** Feed a raw stdout chunk. */
|
|
104
|
+
push(chunk) {
|
|
105
|
+
const text = String(chunk ?? "");
|
|
106
|
+
if (!text) return;
|
|
107
|
+
partial += text;
|
|
108
|
+
const lines = partial.split("\n");
|
|
109
|
+
partial = lines.pop() ?? ""; // the tail may be half a line
|
|
110
|
+
for (const line of lines) record(line);
|
|
111
|
+
// A transport that never sends a newline would otherwise buffer forever.
|
|
112
|
+
if (partial.length > MAX_EVENT_LINE * 4) {
|
|
113
|
+
record(partial);
|
|
114
|
+
partial = "";
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
/**
|
|
118
|
+
* Fold one normalized AgentEvent into the record. This is how a structured
|
|
119
|
+
* session's execution — the edits, reads, and commands that never touch
|
|
120
|
+
* stdout — reaches the transcript at all.
|
|
121
|
+
*/
|
|
122
|
+
pushEvent(ev) {
|
|
123
|
+
if (!ev || typeof ev !== "object") return;
|
|
124
|
+
const t = typeof ev.t === "string" ? ev.t : "";
|
|
125
|
+
if (!t || UNRECORDED_EVENTS.has(t)) return;
|
|
126
|
+
append(event(ev));
|
|
127
|
+
},
|
|
128
|
+
/**
|
|
129
|
+
* The transcript to upload: the held partial line flushed, the whole cut to
|
|
130
|
+
* the ceiling at a line boundary, then redacted — cap before redact, so
|
|
131
|
+
* every surviving line is whole and no secret can be halved into something
|
|
132
|
+
* the redactor no longer recognizes. The same order `lib/run-transcripts.ts`
|
|
133
|
+
* uses server-side, for the same reason.
|
|
134
|
+
*
|
|
135
|
+
* Returns "" when the run produced nothing at all: an empty transcript is an
|
|
136
|
+
* honest omission, and the caller uploads nothing.
|
|
137
|
+
*/
|
|
138
|
+
text() {
|
|
139
|
+
if (partial.trim()) {
|
|
140
|
+
record(partial);
|
|
141
|
+
partial = "";
|
|
142
|
+
}
|
|
143
|
+
if (!buf.trim()) return "";
|
|
144
|
+
const body = redactSecrets(truncateTranscriptTail(buf, maxChars));
|
|
145
|
+
if (!truncated || body.startsWith(TRANSCRIPT_TRUNCATED_LINE)) return body;
|
|
146
|
+
return `${TRANSCRIPT_TRUNCATED_LINE}\n${body}`;
|
|
147
|
+
},
|
|
148
|
+
/** Characters held right now — for the daemon's own log line. */
|
|
149
|
+
chars() {
|
|
150
|
+
return buf.length + partial.length;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|