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/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 opencode
22
- // have proven ones (0282/0573/0608); codex degrades to today's
23
- // branch+feedback iterate (deferred to 0278+).
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. codex has an UNCONFIRMED
56
- * resume flag, so we emit NOTHING rather than guess — a wrong flag could break
57
- * the run; it degrades to today's branch+feedback iterate. A falsy or
58
- * non-string sessionId also returns [] (nothing to resume).
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). Only enforced
97
- * for opencode, whose entries carry the key; other vendors resume as before.
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 === "opencode" && local.cwd !== projectKey) {
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
- await tool("link_folder", { channelId, path, title, hostname: hostname() });
127
- emit({ type: "status", text: `linked folder ${title} to its channel` });
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 }) => safeHandle(job.message, job.channelId, 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
+ }