@lucascouts/claude-agent-tui 0.1.0

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.
Files changed (112) hide show
  1. package/LICENSE +191 -0
  2. package/NOTICE +14 -0
  3. package/README.md +50 -0
  4. package/dist/acp-agent.d.ts +594 -0
  5. package/dist/acp-agent.d.ts.map +1 -0
  6. package/dist/acp-agent.js +2139 -0
  7. package/dist/ansi-mirror.d.ts +42 -0
  8. package/dist/ansi-mirror.d.ts.map +1 -0
  9. package/dist/ansi-mirror.js +61 -0
  10. package/dist/besteffort.d.ts +44 -0
  11. package/dist/besteffort.d.ts.map +1 -0
  12. package/dist/besteffort.js +100 -0
  13. package/dist/billing/entrypoint-guard.d.ts +97 -0
  14. package/dist/billing/entrypoint-guard.d.ts.map +1 -0
  15. package/dist/billing/entrypoint-guard.js +166 -0
  16. package/dist/claude-path.d.ts +12 -0
  17. package/dist/claude-path.d.ts.map +1 -0
  18. package/dist/claude-path.js +61 -0
  19. package/dist/diff-enriched-reader.d.ts +41 -0
  20. package/dist/diff-enriched-reader.d.ts.map +1 -0
  21. package/dist/diff-enriched-reader.js +106 -0
  22. package/dist/diff-source.d.ts +104 -0
  23. package/dist/diff-source.d.ts.map +1 -0
  24. package/dist/diff-source.js +164 -0
  25. package/dist/end-of-turn.d.ts +172 -0
  26. package/dist/end-of-turn.d.ts.map +1 -0
  27. package/dist/end-of-turn.js +415 -0
  28. package/dist/engine-lifecycle.d.ts +222 -0
  29. package/dist/engine-lifecycle.d.ts.map +1 -0
  30. package/dist/engine-lifecycle.js +236 -0
  31. package/dist/engine-pty.d.ts +143 -0
  32. package/dist/engine-pty.d.ts.map +1 -0
  33. package/dist/engine-pty.js +222 -0
  34. package/dist/engine-watcher.d.ts +83 -0
  35. package/dist/engine-watcher.d.ts.map +1 -0
  36. package/dist/engine-watcher.js +173 -0
  37. package/dist/engine.d.ts +30 -0
  38. package/dist/engine.d.ts.map +1 -0
  39. package/dist/engine.js +34 -0
  40. package/dist/event-switch.d.ts +164 -0
  41. package/dist/event-switch.d.ts.map +1 -0
  42. package/dist/event-switch.js +206 -0
  43. package/dist/gate/port.d.ts +38 -0
  44. package/dist/gate/port.d.ts.map +1 -0
  45. package/dist/gate/port.js +126 -0
  46. package/dist/gate/settings-writer.d.ts +130 -0
  47. package/dist/gate/settings-writer.d.ts.map +1 -0
  48. package/dist/gate/settings-writer.js +349 -0
  49. package/dist/index.d.ts +3 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +106 -0
  52. package/dist/jsonl.d.ts +267 -0
  53. package/dist/jsonl.d.ts.map +1 -0
  54. package/dist/jsonl.js +527 -0
  55. package/dist/lib.d.ts +6 -0
  56. package/dist/lib.d.ts.map +1 -0
  57. package/dist/lib.js +5 -0
  58. package/dist/linearize.d.ts +219 -0
  59. package/dist/linearize.d.ts.map +1 -0
  60. package/dist/linearize.js +444 -0
  61. package/dist/live-diff-env.d.ts +7 -0
  62. package/dist/live-diff-env.d.ts.map +1 -0
  63. package/dist/live-diff-env.js +18 -0
  64. package/dist/live-subagent-env.d.ts +7 -0
  65. package/dist/live-subagent-env.d.ts.map +1 -0
  66. package/dist/live-subagent-env.js +19 -0
  67. package/dist/permissions/allow-inject.d.ts +67 -0
  68. package/dist/permissions/allow-inject.d.ts.map +1 -0
  69. package/dist/permissions/allow-inject.js +85 -0
  70. package/dist/permissions/deny.d.ts +60 -0
  71. package/dist/permissions/deny.d.ts.map +1 -0
  72. package/dist/permissions/deny.js +81 -0
  73. package/dist/permissions/gate-wiring.d.ts +112 -0
  74. package/dist/permissions/gate-wiring.d.ts.map +1 -0
  75. package/dist/permissions/gate-wiring.js +350 -0
  76. package/dist/permissions/hook-server.d.ts +72 -0
  77. package/dist/permissions/hook-server.d.ts.map +1 -0
  78. package/dist/permissions/hook-server.js +179 -0
  79. package/dist/permissions/permission-mode.d.ts +67 -0
  80. package/dist/permissions/permission-mode.d.ts.map +1 -0
  81. package/dist/permissions/permission-mode.js +100 -0
  82. package/dist/permissions/request-permission.d.ts +102 -0
  83. package/dist/permissions/request-permission.d.ts.map +1 -0
  84. package/dist/permissions/request-permission.js +124 -0
  85. package/dist/settings.d.ts +68 -0
  86. package/dist/settings.d.ts.map +1 -0
  87. package/dist/settings.js +182 -0
  88. package/dist/stop-reason-map.d.ts +17 -0
  89. package/dist/stop-reason-map.d.ts.map +1 -0
  90. package/dist/stop-reason-map.js +33 -0
  91. package/dist/subagent-source.d.ts +63 -0
  92. package/dist/subagent-source.d.ts.map +1 -0
  93. package/dist/subagent-source.js +132 -0
  94. package/dist/subagent-watcher.d.ts +40 -0
  95. package/dist/subagent-watcher.d.ts.map +1 -0
  96. package/dist/subagent-watcher.js +108 -0
  97. package/dist/tools.d.ts +119 -0
  98. package/dist/tools.d.ts.map +1 -0
  99. package/dist/tools.js +729 -0
  100. package/dist/usage-env.d.ts +7 -0
  101. package/dist/usage-env.d.ts.map +1 -0
  102. package/dist/usage-env.js +16 -0
  103. package/dist/usage.d.ts +54 -0
  104. package/dist/usage.d.ts.map +1 -0
  105. package/dist/usage.js +53 -0
  106. package/dist/utils.d.ts +16 -0
  107. package/dist/utils.d.ts.map +1 -0
  108. package/dist/utils.js +83 -0
  109. package/dist/zed-register.d.ts +26 -0
  110. package/dist/zed-register.d.ts.map +1 -0
  111. package/dist/zed-register.js +106 -0
  112. package/package.json +79 -0
package/dist/jsonl.js ADDED
@@ -0,0 +1,527 @@
1
+ // === §6 JSONL data layer for the read-only tail watcher (story 015, Task Group 1) ===
2
+ //
3
+ // Dependency-free on purpose: ONLY node: builtins (the fork pins node-pty + the SDK as the
4
+ // only runtime deps). The proven glob logic is PORTED from experiments/lib/jsonl.ts — we do
5
+ // NOT import the probe harness, because the fork is a publishable package and must not depend
6
+ // on experiments/.
7
+ //
8
+ // Two locating facts pinned by IMPLEMENTACAO-FORK-ACP §6 / story 015 R1:
9
+ // 1. The transcript is found by GLOB on the sessionId basename:
10
+ // the `<sessionId>.jsonl` basename anywhere under `~/.claude/projects` (R1.1). We NEVER compute or decode the
11
+ // encoded project-dir name — the cwd→dir transform is irreversible and ambiguous
12
+ // (`a_b` / `a.b` / `"a b"` / `a/b` all collapse to the same `a-b` dir). So the encoded
13
+ // dir is never a reliable source of either the path OR the cwd.
14
+ // 2. The runtime cwd is read from the `.cwd` field of an event INSIDE the matched file
15
+ // (R1.2), never from the directory name.
16
+ //
17
+ // The file-discovery watchdog here (FILE_DISCOVERY_WATCHDOG_MS = 2000) is DISTINCT from the
18
+ // 5583 ms turn watchdog and MUST NOT be conflated with it. This module only ever uses the
19
+ // file-discovery watchdog.
20
+ import { closeSync, globSync, openSync, readFileSync, readSync, statSync, watch } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { StringDecoder } from "node:string_decoder";
24
+ /**
25
+ * Build the absolute glob pattern used to locate a session's transcript:
26
+ * the `<sessionId>.jsonl` basename anywhere under `~/.claude/projects`, with `~` expanded via os.homedir
27
+ * (globSync does NOT perform tilde expansion). Exposed so callers can report the
28
+ * EXACT pattern they globbed in a not-found/ambiguity diagnostic without
29
+ * re-deriving (and risking drift from) the string used by findTranscript (R1.3).
30
+ *
31
+ * @param sessionId the session id; equals the transcript filename basename.
32
+ * @returns the absolute glob pattern findTranscript passes to globSync.
33
+ */
34
+ export function transcriptGlob(sessionId) {
35
+ return join(homedir(), ".claude", "projects", "**", `${sessionId}.jsonl`);
36
+ }
37
+ /**
38
+ * Find Claude transcript file(s) for a given session id.
39
+ *
40
+ * Globs the `<sessionId>.jsonl` basename anywhere under `~/.claude/projects` (`~` expanded via os.homedir)
41
+ * so the file is found independent of the cwd→dir encoding (R1.1). Returns ALL
42
+ * matches (normally one) as absolute paths; an empty array means "not found".
43
+ *
44
+ * @param sessionId the session id; equals the transcript filename basename.
45
+ * @returns absolute paths to matching `<sessionId>.jsonl` files (possibly empty).
46
+ */
47
+ export function findTranscript(sessionId) {
48
+ // globSync returns paths relative to cwd unless the pattern is absolute, which it is here
49
+ // (homedir() is absolute, via transcriptGlob), so matches are absolute too. transcriptGlob
50
+ // is the single source of the pattern string.
51
+ return globSync(transcriptGlob(sessionId));
52
+ }
53
+ /**
54
+ * The file-discovery watchdog window (ms): how long {@link locateTranscript} keeps polling the
55
+ * glob for the transcript to appear before failing with a not-found diagnostic (R1.3).
56
+ *
57
+ * DISTINCT from the 5583 ms turn watchdog — never conflate them. This story uses ONLY the
58
+ * file-discovery watchdog.
59
+ */
60
+ export const FILE_DISCOVERY_WATCHDOG_MS = 2000;
61
+ /** Default polling interval (ms) between glob attempts while waiting for the transcript. */
62
+ export const DEFAULT_POLL_INTERVAL_MS = 50;
63
+ /** Default monotonic clock — `performance.now()` so the elapsed window is unaffected by wall-clock jumps. */
64
+ function defaultNow() {
65
+ return performance.now();
66
+ }
67
+ /** Default sleep — resolve after `ms` via `setTimeout`. */
68
+ function defaultSleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+ /**
72
+ * Poll the glob until EXACTLY ONE match appears or the file-discovery watchdog elapses (R1.1,
73
+ * R1.3, R1.4). Deterministic/test-friendly via injected `glob`/`now`/`sleep` so tests never wait
74
+ * a real 2000 ms.
75
+ *
76
+ * - 0 matches at watchdog expiry → throw a not-found error whose message carries the EXACT glob
77
+ * pattern AND the elapsed ms (R1.3).
78
+ * - >1 matches → throw a multi-match error listing EVERY matched path, so the ambiguity is loud
79
+ * rather than silently resolved (R1.4).
80
+ * - exactly 1 → resolve `{ transcriptPath }`.
81
+ *
82
+ * @param sessionId the session id; equals the transcript filename basename.
83
+ * @param opts injectable watchdog/poll/glob/clock/sleep seams.
84
+ */
85
+ export async function locateTranscript(sessionId, opts = {}) {
86
+ const watchdogMs = opts.watchdogMs ?? FILE_DISCOVERY_WATCHDOG_MS;
87
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
88
+ const glob = opts.glob ?? findTranscript;
89
+ const now = opts.now ?? defaultNow;
90
+ const sleep = opts.sleep ?? defaultSleep;
91
+ const start = now();
92
+ // Poll until exactly one match appears OR the watchdog window is exhausted. We attempt the
93
+ // glob first, THEN check the watchdog, so a match landing on the very last poll still wins;
94
+ // the not-found fault only fires once the elapsed window has actually passed.
95
+ for (;;) {
96
+ const matches = glob(sessionId);
97
+ // R1.2: a caller can cancel a (possibly unbounded) poll. If the signal aborted, short-circuit
98
+ // BEFORE the not-found watchdog branch with a recognizable AbortError sentinel so the caller can
99
+ // swallow exactly this case (cancelled before the transcript appeared) and surface every other
100
+ // fault. Prefer the signal's own reason — for `new AbortController().abort()` (no reason) that is
101
+ // a DOMException already named "AbortError" — and fall back to a named Error otherwise.
102
+ if (opts.signal?.aborted) {
103
+ throw (opts.signal.reason ??
104
+ Object.assign(new Error("transcript discovery aborted"), { name: "AbortError" }));
105
+ }
106
+ if (matches.length > 1) {
107
+ // R1.4: more than one transcript matched the sessionId basename. Fail loudly listing every
108
+ // matched path rather than silently picking one — the ambiguity must be visible.
109
+ throw new Error(`Multiple transcripts matched sessionId "${sessionId}" — refusing to pick one. ` +
110
+ `Matched ${matches.length} paths:\n${matches.map((p) => ` - ${p}`).join("\n")}`);
111
+ }
112
+ if (matches.length === 1) {
113
+ return { transcriptPath: matches[0] };
114
+ }
115
+ // Zero matches so far. If the watchdog window has elapsed, fail with the not-found diagnostic;
116
+ // otherwise sleep one poll interval and retry.
117
+ const elapsed = now() - start;
118
+ if (elapsed >= watchdogMs) {
119
+ // R1.3: name the EXACT glob pattern (via transcriptGlob — single source of the string) and
120
+ // the elapsed time so the diagnostic is actionable.
121
+ throw new Error(`Transcript not found for sessionId "${sessionId}" after ${Math.round(elapsed)}ms ` +
122
+ `(file-discovery watchdog ${watchdogMs}ms). Globbed: ${transcriptGlob(sessionId)}`);
123
+ }
124
+ await sleep(pollIntervalMs);
125
+ }
126
+ }
127
+ /**
128
+ * Read the runtime cwd from the `.cwd` field of the FIRST event inside the file that carries it
129
+ * (R1.2). NEVER decode the directory name — the cwd→dir transform is irreversible, so the encoded
130
+ * dir is not a reliable cwd source.
131
+ *
132
+ * Scans complete lines top-to-bottom and returns the first record whose `.cwd` is a non-empty
133
+ * string. Returns `undefined` when the file has no complete line yet, or no event carries `.cwd`
134
+ * yet — the caller should DEFER (re-read after more lines land) rather than guess from the dir
135
+ * name. Corrupted/partial lines are skipped without throwing.
136
+ *
137
+ * @param transcriptPath absolute path to a `.jsonl` transcript (the path {@link locateTranscript}
138
+ * resolved).
139
+ * @returns the inside `.cwd` value, or `undefined` if none is present yet.
140
+ */
141
+ export function readCwdFromInside(transcriptPath) {
142
+ let contents;
143
+ try {
144
+ contents = readFileSync(transcriptPath, "utf8");
145
+ }
146
+ catch {
147
+ // File not readable yet (e.g. created but not flushed) — defer rather than throw.
148
+ return undefined;
149
+ }
150
+ // Only COMPLETE lines (terminated by "\n") are guaranteed whole; the trailing residue after the
151
+ // final "\n" may be a partial write, so we never parse it here — we defer to a later re-read.
152
+ const newlineIdx = contents.lastIndexOf("\n");
153
+ if (newlineIdx < 0)
154
+ return undefined;
155
+ const completeRegion = contents.slice(0, newlineIdx);
156
+ for (const line of completeRegion.split("\n")) {
157
+ if (line.length === 0)
158
+ continue;
159
+ let record;
160
+ try {
161
+ record = JSON.parse(line);
162
+ }
163
+ catch {
164
+ // Corrupted/partial line — skip without aborting (forward-compat parser tolerance, §6).
165
+ continue;
166
+ }
167
+ if (typeof record.cwd === "string" && record.cwd.length > 0) {
168
+ return record.cwd;
169
+ }
170
+ }
171
+ return undefined;
172
+ }
173
+ /**
174
+ * Convenience: {@link locateTranscript} + {@link readCwdFromInside} → the resolved watch target
175
+ * for the read-only tail watcher (R1.1, R1.2).
176
+ *
177
+ * Adopts the single globbed path as the watch target and sources the runtime cwd from an event
178
+ * INSIDE that file. `cwd` may be `undefined` when no event has carried `.cwd` yet — the caller
179
+ * DEFERS reading it until the first complete line provides it, rather than decoding the dir name.
180
+ *
181
+ * @param sessionId the session id; equals the transcript filename basename.
182
+ * @param opts injectable watchdog/poll/glob/clock/sleep seams (forwarded to locateTranscript).
183
+ */
184
+ export async function resolveWatchTarget(sessionId, opts = {}) {
185
+ const { transcriptPath } = await locateTranscript(sessionId, opts);
186
+ const cwd = readCwdFromInside(transcriptPath);
187
+ return { transcriptPath, cwd };
188
+ }
189
+ /**
190
+ * Parse ONE complete line and, on success, forward the record. A line that fails `JSON.parse` is
191
+ * SKIPPED without aborting — one corrupt line never derails subsequent lines (R3.1). Blank lines
192
+ * (e.g. from a `\n\n` gap or a trailing newline) are ignored, not treated as errors.
193
+ *
194
+ * @param line a single COMPLETE line (no embedded `\n`); the caller guarantees completeness.
195
+ * @param onRecord invoked once iff the line parses to a JSON value.
196
+ */
197
+ function parseCompleteLine(line, onRecord) {
198
+ if (line.length === 0)
199
+ return;
200
+ let record;
201
+ try {
202
+ record = JSON.parse(line);
203
+ }
204
+ catch {
205
+ // Corrupt/partial top-level line — skip without aborting (forward-compat parser tolerance, §6).
206
+ // NOT fatal: a transiently-truncated line is re-presented once its `\n` lands (R3.2).
207
+ return;
208
+ }
209
+ onRecord(record);
210
+ }
211
+ /**
212
+ * Create a stateful {@link LineReader}. Maintains a single residue buffer across {@link
213
+ * LineReader.push} calls: each push appends to the buffer, splits on `\n`, holds the final segment
214
+ * back as the new residue (it may be a partial write), and forwards every COMPLETE line through
215
+ * `onRecord` (skipping any that fail `JSON.parse`).
216
+ *
217
+ * This is the deterministic, filesystem-free core the 2.1/2.2 scenarios drive directly.
218
+ *
219
+ * @param onRecord invoked once per newly-completed, top-level-parseable record.
220
+ */
221
+ export function createLineReader(onRecord) {
222
+ // The trailing partial after the last `\n`. Held back until a later push supplies the `\n` that
223
+ // completes it — this is what makes a non-atomic split append safe (R2.2, R2.3).
224
+ let residue = "";
225
+ return {
226
+ push(chunk) {
227
+ residue += chunk;
228
+ const segments = residue.split("\n");
229
+ // The last segment is everything after the final `\n` (or the whole buffer if there was no
230
+ // `\n`); it may be a partial line, so carry it forward as the new residue rather than parsing.
231
+ residue = segments.pop() ?? "";
232
+ for (const segment of segments) {
233
+ parseCompleteLine(segment, onRecord);
234
+ }
235
+ },
236
+ get residue() {
237
+ return residue;
238
+ },
239
+ };
240
+ }
241
+ /**
242
+ * Feed an explicit sequence of chunks through a fresh {@link LineReader} (residue buffered ACROSS
243
+ * chunks), then flush the final residue as a last line. Exposed so the cross-chunk buffering +
244
+ * corrupt-skip logic is directly unit-testable WITHOUT touching the filesystem — mirrors how the
245
+ * experiments harness exposes `feedChunks`.
246
+ *
247
+ * Note the trailing flush: unlike a live tail (where a partial residue MUST stay withheld until its
248
+ * `\n` arrives), a finite chunk sequence is "closed", so a final non-`\n`-terminated record is
249
+ * treated as complete here. Tests that assert residue-withholding therefore drive {@link
250
+ * createLineReader} directly (and inspect `.residue`) rather than going through `feedChunks`.
251
+ *
252
+ * @param chunks ordered text fragments (a record may straddle two fragments).
253
+ * @param onRecord invoked once per successfully parsed record.
254
+ */
255
+ export function feedChunks(chunks, onRecord) {
256
+ const reader = createLineReader(onRecord);
257
+ for (const chunk of chunks) {
258
+ reader.push(chunk);
259
+ }
260
+ // End of input: the final residue (no trailing `\n`) may still be a full record — flush it.
261
+ parseCompleteLine(reader.residue, onRecord);
262
+ }
263
+ /** Default watch seam: subscribe to fs.watch change events on `path`. */
264
+ function defaultWatch(path, onChange) {
265
+ // fs.watch fires on every change; the tail loop re-checks the size, so spurious events are cheap.
266
+ const watcher = watch(path, () => onChange());
267
+ return { close: () => watcher.close() };
268
+ }
269
+ /** Default file-size seam: the current byte length of `path` (0 if it is not statable yet). */
270
+ function defaultFileSize(path) {
271
+ try {
272
+ return statSync(path).size;
273
+ }
274
+ catch {
275
+ // Not statable yet (e.g. transiently missing) — treat as empty; the next change event re-checks.
276
+ return 0;
277
+ }
278
+ }
279
+ /**
280
+ * Default byte-slice seam: read bytes `[fromByte, EOF)` of `path` and decode as utf8. Reads by BYTE
281
+ * offset (not by line) so each appended byte is read exactly once — essential for a large append
282
+ * that the watcher may signal mid-write. Returns "" when there is nothing new (or on a transient
283
+ * read error), letting the caller no-op until the next change event.
284
+ */
285
+ function defaultReadSlice(path, fromByte) {
286
+ let fd;
287
+ try {
288
+ fd = openSync(path, "r");
289
+ const size = statSync(path).size;
290
+ if (size <= fromByte)
291
+ return "";
292
+ const length = size - fromByte;
293
+ const buf = Buffer.allocUnsafe(length);
294
+ let read = 0;
295
+ // Loop until the whole slice is read (a single readSync may return fewer bytes than requested).
296
+ while (read < length) {
297
+ const n = readSync(fd, buf, read, length - read, fromByte + read);
298
+ if (n <= 0)
299
+ break;
300
+ read += n;
301
+ }
302
+ // Decode ONLY whole UTF-8 characters: a trailing incomplete multi-byte sequence is held back in
303
+ // the StringDecoder's internal buffer and NOT emitted as a U+FFFD replacement char. Because that
304
+ // tail is excluded from the returned string, the caller advances its byte offset only over bytes
305
+ // that decoded to COMPLETE characters and RE-READS the incomplete tail on the next change — so a
306
+ // multi-byte char straddling an fs.watch read boundary can never corrupt the offset or the parse
307
+ // (R2.3). (A plain `buf.toString` would turn the partial bytes into U+FFFD, whose 3-byte re-encode
308
+ // then desynchronizes the offset and silently skips file bytes.)
309
+ return new StringDecoder("utf8").write(buf.subarray(0, read));
310
+ }
311
+ catch {
312
+ return "";
313
+ }
314
+ finally {
315
+ if (fd !== undefined) {
316
+ try {
317
+ closeSync(fd);
318
+ }
319
+ catch {
320
+ /* already closed / transient — ignore */
321
+ }
322
+ }
323
+ }
324
+ }
325
+ /**
326
+ * Attach an fs.watch/tail-F to `path`. On each growth, read the appended bytes from the last byte
327
+ * offset, push them through a {@link LineReader}, and forward complete records via `opts.onRecord`.
328
+ * Tracks the read offset so each byte is read exactly once. Returns a {@link TranscriptTail} whose
329
+ * `stop()` detaches the watcher and drops the buffer.
330
+ *
331
+ * The residue buffer makes a large NON-ATOMIC append safe: a ~630 KB base64 `image` line split
332
+ * across two writes is withheld until its terminating `\n` is read, so the split can not corrupt
333
+ * parsing (R2.3). A complete line that fails `JSON.parse` is skipped, never thrown (R3.1).
334
+ *
335
+ * Seams (`watch`/`readSlice`/`fileSize`) are injectable so the wiring is testable without real
336
+ * fs.watch event timing; in production they default to fs.watch + a byte-offset read.
337
+ *
338
+ * @param path absolute path to the resolved transcript (what {@link locateTranscript} resolved).
339
+ * @param opts onRecord sink plus injectable watch/readSlice/fileSize seams.
340
+ */
341
+ export function tailTranscript(path, opts) {
342
+ const watchFn = opts.watch ?? defaultWatch;
343
+ const readSlice = opts.readSlice ?? defaultReadSlice;
344
+ const fileSize = opts.fileSize ?? defaultFileSize;
345
+ const reader = createLineReader(opts.onRecord);
346
+ // Byte offset already consumed. Start from the CURRENT size so the tail forwards only bytes
347
+ // appended AFTER attach (tail-F semantics) — historical lines are Task 3's initial read, not ours.
348
+ let offset = fileSize(path);
349
+ let stopped = false;
350
+ const onChange = () => {
351
+ if (stopped)
352
+ return;
353
+ const size = fileSize(path);
354
+ // Truncation/rotation: if the file shrank below our offset, reset to read from its new start so
355
+ // we never slice a negative range or miss the rewritten head.
356
+ if (size < offset)
357
+ offset = 0;
358
+ if (size <= offset)
359
+ return; // no new bytes (a spurious change event) — nothing to do.
360
+ const slice = readSlice(path, offset);
361
+ if (slice.length === 0)
362
+ return;
363
+ // Advance by the BYTE length of the decoded slice (utf8), not the JS string length. `readSlice`
364
+ // returns only WHOLE UTF-8 characters (its default withholds a trailing incomplete multi-byte
365
+ // sequence for the next read), so this byte count exactly matches the bytes consumed and stays
366
+ // aligned to the file's byte boundaries — a multi-byte char split across a read boundary is
367
+ // re-read intact on the next change, never corrupted (R2.3).
368
+ offset += Buffer.byteLength(slice, "utf8");
369
+ reader.push(slice);
370
+ };
371
+ const subscription = watchFn(path, onChange);
372
+ return {
373
+ stop() {
374
+ if (stopped)
375
+ return;
376
+ stopped = true;
377
+ try {
378
+ subscription.close();
379
+ }
380
+ catch {
381
+ /* already closed — ignore */
382
+ }
383
+ },
384
+ };
385
+ }
386
+ /**
387
+ * Default byte threshold for {@link stripHeavyImages} / {@link projectEvent}: an `image` block's
388
+ * base64 `source.data` larger than this (~64 KB) is skipped, while a small inline image is kept.
389
+ * Chosen so a ~630 KB PNG is dropped but a typical small icon survives.
390
+ */
391
+ export const DEFAULT_IMAGE_SKIP_BYTES = 64 * 1024;
392
+ /** The placeholder substituted for a skipped image's base64 `source.data` (kept structurally valid). */
393
+ const SKIPPED_IMAGE_DATA = "";
394
+ /**
395
+ * Narrowing helper: a non-null plain object (so we can read string-keyed props safely). Arrays and
396
+ * `null` are excluded; everything else with `typeof === 'object'` qualifies.
397
+ */
398
+ function isObject(value) {
399
+ return typeof value === "object" && value !== null && !Array.isArray(value);
400
+ }
401
+ /**
402
+ * Copy `key` from `source` onto `target` ONLY if `source[key]` is not `undefined`. Keeps the
403
+ * projection defensive: an optional universal field absent on the input is left `undefined` on the
404
+ * event rather than written as an explicit `undefined`.
405
+ */
406
+ function carryIfPresent(target, source, key) {
407
+ if (source[key] !== undefined)
408
+ target[key] = source[key];
409
+ }
410
+ /**
411
+ * Standalone heavy-image stripper (used by {@link projectEvent}; exported for direct testing).
412
+ *
413
+ * When `message.content` is an ARRAY, returns a SHALLOW-CLONED message whose content array has any
414
+ * heavy `image` block's base64 `source.data` replaced by a skipped marker — PRESERVING the block's
415
+ * `type`, `source.type`, `source.media_type`, adding `source.skipped = true` and `source.bytes =
416
+ * <original byte length>` so downstream stays structurally valid and keeps a size hint. Non-image
417
+ * blocks (e.g. `text`) and images below the threshold pass through UNCHANGED. When `message.content`
418
+ * is a raw STRING (or `message` is not an object / has no array content), the input is returned
419
+ * untouched. The input message and its blocks are NEVER mutated in place (the watcher may hold
420
+ * references) — only the heavy blocks are cloned (others are shared by reference, which is safe
421
+ * because we never mutate them).
422
+ *
423
+ * "Heavy" = `source.data` is a string whose UTF-8 byte length exceeds `imageSkipBytes`
424
+ * (default {@link DEFAULT_IMAGE_SKIP_BYTES}). A `data` getter could provide TRUE laziness, but a
425
+ * skip marker is sufficient for Degrau-1 and keeps the event serializable.
426
+ *
427
+ * @param message the API message object (or any value — non-matching shapes pass through).
428
+ * @param imageSkipBytes byte threshold; `source.data` larger than this is skipped.
429
+ * @returns the message with heavy image data skipped, or the original value untouched.
430
+ */
431
+ export function stripHeavyImages(message, imageSkipBytes = DEFAULT_IMAGE_SKIP_BYTES) {
432
+ if (!isObject(message))
433
+ return message;
434
+ const content = message.content;
435
+ // Only array content can carry image blocks; a raw string (or absent content) is left untouched.
436
+ if (!Array.isArray(content))
437
+ return message;
438
+ let anyStripped = false;
439
+ const newContent = content.map((block) => {
440
+ if (!isObject(block) || block.type !== "image")
441
+ return block;
442
+ const source = block.source;
443
+ if (!isObject(source) || source.type !== "base64")
444
+ return block;
445
+ const data = source.data;
446
+ if (typeof data !== "string")
447
+ return block;
448
+ const bytes = Buffer.byteLength(data, "utf8");
449
+ if (bytes <= imageSkipBytes)
450
+ return block; // small inline image — keep as-is.
451
+ // Heavy image: shallow-clone block + source, drop the base64 payload, keep a size hint so the
452
+ // event stays structurally valid (type / source.type / media_type preserved) and downstream
453
+ // can tell an image WAS here and how big it was.
454
+ anyStripped = true;
455
+ return {
456
+ ...block,
457
+ source: {
458
+ ...source,
459
+ data: SKIPPED_IMAGE_DATA,
460
+ skipped: true,
461
+ bytes,
462
+ },
463
+ };
464
+ });
465
+ // If nothing was heavy, return the original message untouched (no needless clone).
466
+ if (!anyStripped)
467
+ return message;
468
+ return { ...message, content: newContent };
469
+ }
470
+ /**
471
+ * Project a `getSessionMessages` message (or a raw-shaped event) into a typed {@link JsonlEvent}.
472
+ *
473
+ * Maps the SDK-shape fields: `session_id → sessionId`, `parent_tool_use_id → parentToolUseId`,
474
+ * carries `uuid` / `type` / `timestamp` / `message` through, and hardcodes `userType: 'external'`.
475
+ * Every OPTIONAL universal field and per-type extra (parentUuid, cwd, gitBranch, version,
476
+ * isSidechain, entrypoint, requestId, promptId, toolUseResult, permissionMode) is passed through
477
+ * ONLY if it is present on the input (defensive forward-compat), else left `undefined`. Tolerates
478
+ * `message.content` as an ARRAY or a raw STRING. By default also strips heavy `image` base64 data
479
+ * via {@link stripHeavyImages} (see `opts.imageSkipBytes`; raw-string content is left untouched).
480
+ *
481
+ * Never throws on unusual input: a missing `uuid` becomes `''`; a non-object input yields an event
482
+ * with empty `uuid`, `type === ''`, and `message === undefined`.
483
+ *
484
+ * @param message a `getSessionMessages` message (or raw-shaped event); any value is tolerated.
485
+ * @param opts projection options (the heavy-image byte threshold).
486
+ * @returns the typed {@link JsonlEvent}.
487
+ */
488
+ export function projectEvent(message, opts = {}) {
489
+ const imageSkipBytes = opts.imageSkipBytes ?? DEFAULT_IMAGE_SKIP_BYTES;
490
+ const src = isObject(message) ? message : {};
491
+ // Strip heavy image data off the message before surfacing it (no-op on string/absent content).
492
+ const projectedMessage = stripHeavyImages(src.message, imageSkipBytes);
493
+ const event = {
494
+ uuid: typeof src.uuid === "string" ? src.uuid : "",
495
+ type: typeof src.type === "string" ? src.type : "",
496
+ message: projectedMessage,
497
+ // The story asserts the user type is always 'external' (hardcoded, not read from the input).
498
+ userType: "external",
499
+ };
500
+ // SDK-shape fields → typed names (only when present, to avoid writing explicit undefined).
501
+ if (typeof src.timestamp === "string")
502
+ event.timestamp = src.timestamp;
503
+ if (typeof src.session_id === "string")
504
+ event.sessionId = src.session_id;
505
+ if (src.parent_tool_use_id !== undefined) {
506
+ event.parentToolUseId = src.parent_tool_use_id;
507
+ }
508
+ // Optional universal fields + per-type extras: passed through verbatim ONLY if present on the
509
+ // input (absent in the SDK return; populated only by a raw-shaped fixture/event). The field name
510
+ // is the same on input and output for all of these, so a name-preserving carry suffices.
511
+ const eventRec = event;
512
+ for (const key of [
513
+ "parentUuid",
514
+ "cwd",
515
+ "gitBranch",
516
+ "version",
517
+ "isSidechain",
518
+ "entrypoint",
519
+ "requestId",
520
+ "promptId",
521
+ "toolUseResult",
522
+ "permissionMode",
523
+ ]) {
524
+ carryIfPresent(eventRec, src, key);
525
+ }
526
+ return event;
527
+ }
package/dist/lib.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { ClaudeAcpAgent, isLocalCommandMetadata, stripLocalCommandMetadata, runAcp, toAcpNotifications, streamEventToAcpNotifications, type ToolUpdateMeta, type NewSessionMeta, type SDKMessageFilter, } from "./acp-agent.js";
2
+ export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
3
+ export { toolInfoFromToolUse, toDisplayPath, planEntries, toolUpdateFromToolResult, } from "./tools.js";
4
+ export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
5
+ export type { ClaudePlanEntry } from "./tools.js";
6
+ //# sourceMappingURL=lib.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../src/lib.ts"],"names":[],"mappings":"AACA,OAAO,EACL,cAAc,EACd,sBAAsB,EACtB,yBAAyB,EACzB,MAAM,EACN,kBAAkB,EAClB,6BAA6B,EAC7B,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzF,OAAO,EACL,mBAAmB,EACnB,aAAa,EACb,WAAW,EACX,wBAAwB,GACzB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,KAAK,sBAAsB,EAAE,MAAM,eAAe,CAAC;AAG7E,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
package/dist/lib.js ADDED
@@ -0,0 +1,5 @@
1
+ // Export the main agent class and utilities for library usage
2
+ export { ClaudeAcpAgent, isLocalCommandMetadata, stripLocalCommandMetadata, runAcp, toAcpNotifications, streamEventToAcpNotifications, } from "./acp-agent.js";
3
+ export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
4
+ export { toolInfoFromToolUse, toDisplayPath, planEntries, toolUpdateFromToolResult, } from "./tools.js";
5
+ export { SettingsManager } from "./settings.js";