@hwip/cli 0.0.1 → 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 (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1979 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +292 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +35 -0
  39. package/dist/blocked-prompt-seal.js +89 -0
  40. package/dist/blocked-prompt.d.ts +57 -0
  41. package/dist/blocked-prompt.js +181 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +272 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5680 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -0,0 +1,718 @@
1
+ "use strict";
2
+ /**
3
+ * Claude Code session transcript (`~/.claude/projects/<hash>/<uuid>.jsonl`) →
4
+ * the small events the agent chat timeline draws.
5
+ *
6
+ * Two pure pieces, deliberately split: reading bytes off a growing file, and
7
+ * deciding what of it is worth putting on the wire. Neither touches the socket,
8
+ * so both are testable without tmux, WebSocket, or a clock.
9
+ *
10
+ * Everything here is sized by what a real transcript actually contains, measured
11
+ * 2026-09-08 over 1443 files in `~/.claude/projects`: the largest file was
12
+ * 75.9 MB (p90 1.1 MB) and the longest single line 915,292 bytes. Those two
13
+ * numbers are why the reader starts near the end and refuses oversized lines
14
+ * before parsing them — a tail that starts at byte 0, or that hands a ~1 MB
15
+ * string to `JSON.parse` and only then decides to drop it, has already paid the
16
+ * cost the cap exists to avoid.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.projectTranscriptEntries = exports.targetFrom = exports.clamp = exports.finiteNumber = exports.todoEventOf = exports.MAX_TODO_TEXT_CHARS = exports.MAX_TODO_ITEMS = exports.sliceSinceLastPrompt = exports.oneLine = exports.resultLinesOf = exports.EDIT_DIFF_MAX_CHARS = exports.readSource = exports.readTranscriptDelta = exports.initialTailState = exports.MAX_EVENT_TEXT_CHARS = exports.MAX_EVENT_FIELD_CHARS = exports.MAX_TRANSCRIPT_LINE_CHARS = exports.MAX_TAIL_BYTES_PER_TICK = exports.TRANSCRIPT_BACKFILL_BYTES = void 0;
20
+ const node_fs_1 = require("node:fs");
21
+ const todo_fold_js_1 = require("./todo-fold.js");
22
+ /**
23
+ * How far back from the end of the file the first read starts.
24
+ *
25
+ * Not zero: a fresh viewer that sees nothing until the *next* tool call is a
26
+ * blank screen for whoever just picked up their phone mid-turn. Not the whole
27
+ * file either — that is up to 75.9 MB. A fixed window from the end costs the
28
+ * same on an 8 KB transcript and a 76 MB one.
29
+ */
30
+ exports.TRANSCRIPT_BACKFILL_BYTES = 256 * 1024;
31
+ /** Ceiling on one tick's read, so a burst of appends cannot be swallowed whole. */
32
+ exports.MAX_TAIL_BYTES_PER_TICK = 256 * 1024;
33
+ /**
34
+ * A line longer than this is abandoned unparsed, the reader skipping to the next
35
+ * newline. The measured worst case is a 915 KB `tool_result`, whose body this
36
+ * module drops anyway — buffering one to completion would be paying megabytes to
37
+ * learn a tool id.
38
+ */
39
+ exports.MAX_TRANSCRIPT_LINE_CHARS = 1024 * 1024;
40
+ /**
41
+ * Ceiling on a tool's label — its name, and the short string saying what it
42
+ * acted on. A path or a one-line description; anything longer is a runaway
43
+ * input, not a label.
44
+ */
45
+ exports.MAX_EVENT_FIELD_CHARS = 512;
46
+ /**
47
+ * Ceiling on prose — what the agent wrote, and what the person asked.
48
+ *
49
+ * Deliberately far above the label cap: 512 characters is a filename, and
50
+ * applying it here cut every reply off mid-sentence. 5000 is what the
51
+ * completion push already carries for the same text (`hwip-stop.sh`), so the
52
+ * live lane and the push that replaces it agree on how much of a reply is
53
+ * worth sending.
54
+ */
55
+ exports.MAX_EVENT_TEXT_CHARS = 5000;
56
+ /**
57
+ * How much of `buffer[0..read)` ends on a UTF-8 character boundary.
58
+ *
59
+ * A sequence is at most 4 bytes, so at most 3 trailing bytes can belong to a
60
+ * character whose remainder has not arrived. Walk back over continuation bytes
61
+ * (`10xxxxxx`) to the lead byte and ask how long its character should be; if the
62
+ * buffer holds all of it, the read is already complete.
63
+ */
64
+ const completeUtf8Length = (buffer, read) => {
65
+ for (let back = 1; back <= 3 && back <= read; back++) {
66
+ const byte = buffer[read - back];
67
+ if ((byte & 0b1100_0000) === 0b1000_0000)
68
+ continue; // continuation — keep walking
69
+ const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
70
+ return back >= width ? read : read - back;
71
+ }
72
+ return read;
73
+ };
74
+ /**
75
+ * No state yet. `null` rather than a sentinel-filled record: "never read this
76
+ * file" and "read it and got nothing" are different, and an out-of-band value
77
+ * that has to stay below every real offset is a trap for the next `>=`.
78
+ */
79
+ const initialTailState = () => null;
80
+ exports.initialTailState = initialTailState;
81
+ /*
82
+ * An oversized line is dropped, but not its verdict. The line is almost always
83
+ * a tool_result carrying a screenshot, and without a result its call spins as
84
+ * running in the viewer forever. The ids sit at the head of the line, before
85
+ * the payload (measured on real screenshot results, 2026-09-12), so the head is
86
+ * read for ids and a content-free stand-in takes the line's place. The tail is
87
+ * read for `is_error`; a failure flagged anywhere else reads as ok — a wrong
88
+ * verdict on a rare case, against a spinner that never stops. Nothing in
89
+ * between is kept.
90
+ */
91
+ const SALVAGE_HEAD_CHARS = 16 * 1024;
92
+ const SALVAGE_TAIL_CHARS = 4 * 1024;
93
+ const salvageIdsOf = (head) => head.includes('"tool_result"')
94
+ ? [...head.slice(0, SALVAGE_HEAD_CHARS).matchAll(/"tool_use_id":"([^"\\]{1,200})"/g)].map((m) => m[1])
95
+ : [];
96
+ /** A transcript line with just the verdicts, for the projection to read like any other. */
97
+ const salvagedLine = (ids, tail) => JSON.stringify({
98
+ type: 'user',
99
+ message: {
100
+ role: 'user',
101
+ content: ids.map((id) => ({
102
+ type: 'tool_result',
103
+ tool_use_id: id,
104
+ ...(/"is_error":\s*true/.test(tail.slice(-SALVAGE_TAIL_CHARS)) ? { is_error: true } : {}),
105
+ })),
106
+ },
107
+ });
108
+ /**
109
+ * Read what was appended since `prev`, or `null` when there is nothing to do —
110
+ * the file is unreadable, or neither its size nor its mtime moved.
111
+ *
112
+ * `null` is the common case (an idle session) and is deliberately allocation
113
+ * free: no buffer, no string, no array.
114
+ */
115
+ const readTranscriptDelta = (path, prev, opts = {}) => {
116
+ let stat;
117
+ try {
118
+ stat = (0, node_fs_1.statSync)(path);
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ const { size, mtimeMs, ino } = stat;
124
+ // Caught up AND untouched. `offset >= size` matters on its own: a tick that
125
+ // stopped at MAX_TAIL_BYTES_PER_TICK leaves bytes behind on a file that has
126
+ // not changed since, and skipping there would strand them until the next
127
+ // append. `ino` matters here too, not only below: a replacement that matched
128
+ // the old size and mtime would otherwise skip out before the rotation check
129
+ // downstream ever ran.
130
+ if (prev && prev.offset >= size && size === prev.size && mtimeMs === prev.mtimeMs && ino === prev.ino) {
131
+ return null;
132
+ }
133
+ // A new inode at the same path is a different file, and a file that shrank
134
+ // below the offset was truncated in place. Size alone misses the first case:
135
+ // a replacement that happens to be longer than the old read position reads as
136
+ // an ordinary append, and the offset then points into the middle of a record
137
+ // that was never there.
138
+ //
139
+ // Neither covers a `/clear`, which writes a file under a *different* name —
140
+ // the old one simply stops growing. Following that means re-resolving the
141
+ // session id, which is the caller's job (`turn-watch` re-resolves on a
142
+ // cadence) because only it knows which session this path was for.
143
+ //
144
+ // Either way — first attach or rotation — everything remembered about the old
145
+ // contents is meaningless, so the read restarts from a window off the end.
146
+ const restart = !prev || size < prev.offset || ino !== prev.ino;
147
+ // `fromStart` is for a caller that counts rather than shows: a window off the
148
+ // end is right for a viewer who wants the latest screen, and wrong for a total.
149
+ const start = restart ? (opts.fromStart ? 0 : Math.max(0, size - exports.TRANSCRIPT_BACKFILL_BYTES)) : prev.offset;
150
+ // A window that starts mid-line yields a fragment of a record that began
151
+ // before it, so that fragment is dropped rather than parsed.
152
+ const truncatedStart = restart && start > 0;
153
+ const carry = restart ? '' : prev.carry;
154
+ const carryBytesBefore = restart ? 0 : prev.carryBytes ?? Buffer.byteLength(prev.carry, 'utf-8');
155
+ let resyncing = restart ? false : prev.resyncing;
156
+ let resyncFrom = restart ? undefined : prev.resyncFrom;
157
+ let salvage = restart ? [] : prev.salvage;
158
+ const want = Math.min(size - start, exports.MAX_TAIL_BYTES_PER_TICK);
159
+ if (want <= 0) {
160
+ return {
161
+ lines: [],
162
+ state: { offset: start, size, mtimeMs, ino, carry, carryBytes: carryBytesBefore, resyncing, ...(resyncFrom !== undefined ? { resyncFrom } : {}), salvage },
163
+ bytesRead: 0,
164
+ droppedLines: 0,
165
+ };
166
+ }
167
+ const buffer = Buffer.allocUnsafe(want);
168
+ let read = 0;
169
+ let fd;
170
+ try {
171
+ fd = (0, node_fs_1.openSync)(path, 'r');
172
+ read = (0, node_fs_1.readSync)(fd, buffer, 0, want, start);
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ finally {
178
+ if (fd !== undefined)
179
+ (0, node_fs_1.closeSync)(fd);
180
+ }
181
+ // A tick that stops at MAX_TAIL_BYTES_PER_TICK can land inside a multi-byte
182
+ // character. Decoding to that boundary turns both halves into U+FFFD, and the
183
+ // line then fails JSON.parse and vanishes without even being counted as
184
+ // dropped — a silent hole that only shows up on non-ASCII transcripts under
185
+ // bursty appends. Back the read up to the last complete sequence instead; the
186
+ // bytes are not lost, the next tick starts on them.
187
+ const usable = read < want ? read : completeUtf8Length(buffer, read);
188
+ const chunk = buffer.toString('utf8', 0, usable);
189
+ const lines = [];
190
+ let droppedLines = 0;
191
+ let pending = carry;
192
+ let cursor = 0;
193
+ while (cursor <= chunk.length) {
194
+ const nl = chunk.indexOf('\n', cursor);
195
+ if (nl === -1) {
196
+ pending += chunk.slice(cursor);
197
+ break;
198
+ }
199
+ const complete = pending + chunk.slice(cursor, nl);
200
+ cursor = nl + 1;
201
+ pending = '';
202
+ if (resyncing) {
203
+ // This newline ends the oversized line; the next one starts clean.
204
+ resyncing = false;
205
+ if (salvage.length)
206
+ lines.push(salvagedLine(salvage, complete));
207
+ salvage = [];
208
+ continue;
209
+ }
210
+ if (complete.length > exports.MAX_TRANSCRIPT_LINE_CHARS) {
211
+ droppedLines += 1;
212
+ const ids = salvageIdsOf(complete.slice(0, SALVAGE_HEAD_CHARS));
213
+ if (ids.length)
214
+ lines.push(salvagedLine(ids, complete));
215
+ continue;
216
+ }
217
+ lines.push(complete);
218
+ }
219
+ // The window began mid-line, so whatever came before the first newline is a
220
+ // fragment of a record that started before `start`.
221
+ if (truncatedStart && lines.length > 0)
222
+ lines.shift();
223
+ // The bytes of the file `pending` stands for: back to the last newline of this read, or past the whole read when it had none.
224
+ const lastNewline = usable > 0 ? buffer.lastIndexOf(0x0a, usable - 1) : -1;
225
+ let pendingBytes = lastNewline >= 0 ? usable - lastNewline - 1 : carryBytesBefore + usable;
226
+ if (pending.length > exports.MAX_TRANSCRIPT_LINE_CHARS) {
227
+ // Still no newline and already past the cap — stop growing the buffer and
228
+ // discard bytes until the line ends. Holding it to completion would cost
229
+ // megabytes for a record whose body never reaches the wire anyway.
230
+ droppedLines += 1;
231
+ // Already resyncing means this is the middle of the same line, not its head.
232
+ if (!resyncing) {
233
+ salvage = salvageIdsOf(pending.slice(0, SALVAGE_HEAD_CHARS));
234
+ // The line began where the held bytes begin: the last offset a restart may resume from.
235
+ resyncFrom = start + usable - pendingBytes;
236
+ }
237
+ pending = '';
238
+ pendingBytes = 0;
239
+ resyncing = true;
240
+ }
241
+ return {
242
+ lines,
243
+ state: { offset: start + usable, size, mtimeMs, ino, carry: pending, carryBytes: pendingBytes, resyncing, ...(resyncing && resyncFrom !== undefined ? { resyncFrom } : {}), salvage },
244
+ bytesRead: usable,
245
+ droppedLines,
246
+ };
247
+ };
248
+ exports.readTranscriptDelta = readTranscriptDelta;
249
+ /** One read of a source, through its own reader when it has one. */
250
+ const readSource = (source, prev) => source.read ? source.read(source.path, prev) : (0, exports.readTranscriptDelta)(source.path, prev);
251
+ exports.readSource = readSource;
252
+ /** Edits larger than this are counted, not diffed: splitting them into lines
253
+ * is the one allocation here that scales with what the agent wrote. */
254
+ exports.EDIT_DIFF_MAX_CHARS = 64 * 1024;
255
+ /** Lines in `text`, counted without splitting it. A trailing newline ends the last line rather than starting another. */
256
+ const countLines = (text) => {
257
+ if (!text)
258
+ return 0;
259
+ let lines = 1;
260
+ for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1))
261
+ lines++;
262
+ return text.endsWith('\n') ? lines - 1 : lines;
263
+ };
264
+ /** Lines one edit removed and added, once the lines it merely quoted around the change are dropped. */
265
+ const editDelta = (before, after) => {
266
+ if (before.length + after.length > exports.EDIT_DIFF_MAX_CHARS)
267
+ return { add: countLines(after), del: countLines(before) };
268
+ const a = before.split('\n');
269
+ const b = after.split('\n');
270
+ let start = 0;
271
+ while (start < a.length && start < b.length && a[start] === b[start])
272
+ start++;
273
+ let endA = a.length;
274
+ let endB = b.length;
275
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
276
+ endA--;
277
+ endB--;
278
+ }
279
+ return { add: endB - start, del: endA - start };
280
+ };
281
+ const str = (value) => (typeof value === 'string' ? value : undefined);
282
+ /** `add`/`del` for a call that writes a file, or nothing for any other call. */
283
+ const lineChangesOf = (name, input) => {
284
+ if (!input || typeof input !== 'object')
285
+ return {};
286
+ const record = input;
287
+ if (name === 'Write') {
288
+ const content = str(record.content);
289
+ return content ? { add: countLines(content) } : {};
290
+ }
291
+ let edits;
292
+ if (name === 'Edit') {
293
+ // `replace_all` edits every occurrence; the input names the change
294
+ // once, so it counts once — a low count, never a made-up one.
295
+ edits = [record];
296
+ }
297
+ else if (name === 'MultiEdit' && Array.isArray(record.edits)) {
298
+ edits = record.edits.filter((e) => !!e && typeof e === 'object');
299
+ }
300
+ else {
301
+ return {};
302
+ }
303
+ let add = 0;
304
+ let del = 0;
305
+ for (const edit of edits) {
306
+ const before = str(edit.old_string);
307
+ const after = str(edit.new_string);
308
+ if (before === undefined || after === undefined)
309
+ continue;
310
+ const delta = editDelta(before, after);
311
+ add += delta.add;
312
+ del += delta.del;
313
+ }
314
+ return add || del ? { add, del } : {};
315
+ };
316
+ /** Lines of a result's text — a string, or the text blocks of a block list. Images and the like count as nothing. */
317
+ /**
318
+ * How long a tool result was, from either shape a result body takes: a bare
319
+ * string, or blocks with a `text` field. Exported for the same reason as
320
+ * `clamp` — the shape is not one agent's vocabulary, and `lines` means the same
321
+ * number on the wire whoever produced it.
322
+ */
323
+ const resultLinesOf = (content) => {
324
+ if (typeof content === 'string')
325
+ return countLines(content);
326
+ if (!Array.isArray(content))
327
+ return 0;
328
+ let lines = 0;
329
+ for (const block of content) {
330
+ if (block && typeof block === 'object' && block.type === 'text') {
331
+ lines += countLines(str(block.text) ?? '');
332
+ }
333
+ }
334
+ return lines;
335
+ };
336
+ exports.resultLinesOf = resultLinesOf;
337
+ /**
338
+ * One line, always.
339
+ *
340
+ * A label is not a paragraph: a tool argument that runs to hundreds of
341
+ * characters with newlines in it renders as a wall where a row's label belongs.
342
+ * Applied by the projectors that need it rather than inside `targetFrom`,
343
+ * because that helper feeds Claude's projector too and the Spec requires a
344
+ * Claude timeline to stay byte-identical.
345
+ */
346
+ const oneLine = (target) => target?.replace(/\s+/g, ' ');
347
+ exports.oneLine = oneLine;
348
+ /**
349
+ * The backfill window, applied the same way by every projector.
350
+ *
351
+ * This is about `TurnEvent` and `ProjectorOptions`, not about any one agent's
352
+ * vocabulary — unlike the per-format helpers beside it, which are deliberately
353
+ * not shared. Every projector ends with this call, and a fourth one should too.
354
+ */
355
+ const sliceSinceLastPrompt = (events, opts) => {
356
+ if (!opts.sinceLastPrompt)
357
+ return events;
358
+ // Scanned backwards rather than by mapping to kinds and taking the last
359
+ // index: the map allocates a whole array to find one number.
360
+ for (let i = events.length - 1; i >= 0; i--) {
361
+ if (events[i].kind === 'prompt')
362
+ return events.slice(i);
363
+ }
364
+ return events;
365
+ };
366
+ exports.sliceSinceLastPrompt = sliceSinceLastPrompt;
367
+ /**
368
+ * Ceilings on a `todo` event: tasks kept, and characters per task. 30 tasks of
369
+ * 100 Hangul characters each is under 10KB of JSON, inside the 12KB frame
370
+ * (`MAX_TURN_FRAME_BYTES` in `turn-watch`), which sends an oversized event whole
371
+ * rather than cutting it.
372
+ */
373
+ exports.MAX_TODO_ITEMS = 30;
374
+ exports.MAX_TODO_TEXT_CHARS = 100;
375
+ /**
376
+ * The `todo` event for a list. Over `MAX_TODO_ITEMS`, finished tasks go first,
377
+ * oldest first — what is left to do is what the phone is watching for — and
378
+ * `total` · `done` keep the real counts.
379
+ */
380
+ const todoEventOf = (list, at) => {
381
+ let items = list.items;
382
+ if (items.length > exports.MAX_TODO_ITEMS) {
383
+ let surplus = items.length - exports.MAX_TODO_ITEMS;
384
+ items = items.filter((item) => {
385
+ if (surplus > 0 && item.status === 'completed') {
386
+ surplus--;
387
+ return false;
388
+ }
389
+ return true;
390
+ });
391
+ items = items.slice(0, exports.MAX_TODO_ITEMS);
392
+ }
393
+ return {
394
+ kind: 'todo',
395
+ items: items.map((item) => ({ ...item, text: (0, exports.clamp)(item.text, exports.MAX_TODO_TEXT_CHARS) })),
396
+ ...(list.items.length > items.length
397
+ ? { total: list.items.length, done: list.items.filter((item) => item.status === 'completed').length }
398
+ : {}),
399
+ ...(at ? { at } : {}),
400
+ };
401
+ };
402
+ exports.todoEventOf = todoEventOf;
403
+ /** A usable number, or 0 — token counts are the one place a missing field must not become `NaN` on the wire. */
404
+ const finiteNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
405
+ exports.finiteNumber = finiteNumber;
406
+ /** The `msg` for an assistant entry, or null when it carries no usable usage. */
407
+ const messageMetaOf = (entry, at) => {
408
+ if (entry.type !== 'assistant')
409
+ return null;
410
+ const message = entry.message;
411
+ if (!message || typeof message !== 'object')
412
+ return null;
413
+ const record = message;
414
+ const mid = str(record.id);
415
+ const usage = record.usage;
416
+ const model = str(record.model);
417
+ // `<synthetic>` is Claude Code standing in for the API (an error, an
418
+ // interruption) — no model ran, and its zeroed usage would read as one did.
419
+ if (!mid || !usage || typeof usage !== 'object' || model === '<synthetic>')
420
+ return null;
421
+ const u = usage;
422
+ return {
423
+ kind: 'msg',
424
+ mid: (0, exports.clamp)(mid),
425
+ ...(model ? { model: (0, exports.clamp)(model) } : {}),
426
+ out: (0, exports.finiteNumber)(u.output_tokens),
427
+ ctx: (0, exports.finiteNumber)(u.input_tokens) + (0, exports.finiteNumber)(u.cache_read_input_tokens) + (0, exports.finiteNumber)(u.cache_creation_input_tokens),
428
+ ...(at ? { at } : {}),
429
+ };
430
+ };
431
+ /**
432
+ * Which input field reads as "what this call is about". Ordered, first match
433
+ * wins: `description` leads because it is the sentence a human wrote about the
434
+ * call, which is exactly what a collapsed timeline row wants.
435
+ *
436
+ * `command` is deliberately absent. It would only ever be reached for a Bash
437
+ * call with no `description`, and measured over the 60 most recent transcripts
438
+ * that is 0 of 2985 calls — while a command line is the single field here most
439
+ * likely to carry a secret (an `Authorization` header, a token in a URL, a
440
+ * profile name). Zero measured value against the worst downside on the list.
441
+ *
442
+ * This list is Claude Code's vocabulary. Another agent names its inputs
443
+ * differently, so a second agent means a second projector, not a longer list —
444
+ * see the note on `projectTranscriptEntries`.
445
+ */
446
+ const TARGET_KEYS = ['description', 'file_path', 'path', 'pattern', 'query', 'url', 'skill'];
447
+ /**
448
+ * Cut one field to the wire's budget. Exported because every projector shares
449
+ * the same 12KB frame ceiling (`MAX_TURN_FRAME_BYTES` in `turn-watch`), so a
450
+ * second projector with its own idea of "long enough" would be a second way to
451
+ * overflow the one transport.
452
+ */
453
+ const clamp = (value, max = exports.MAX_EVENT_FIELD_CHARS) => {
454
+ if (value.length <= max)
455
+ return value;
456
+ const cut = value.slice(0, max);
457
+ // A UTF-16 slice can strand a high surrogate at the edge (e.g. an emoji
458
+ // straddling the budget) — back off one unit so the wire never carries
459
+ // a lone surrogate. Length stays within budget either way.
460
+ return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
461
+ };
462
+ exports.clamp = clamp;
463
+ /**
464
+ * First of `keys` that holds a non-blank string, clamped — the loop every
465
+ * projector needs, without the key list every projector has to choose for
466
+ * itself. The keys stay the caller's: they are that agent's vocabulary, and
467
+ * merging them into one list is exactly what the `TARGET_KEYS` note forbids.
468
+ */
469
+ const targetFrom = (input, keys) => {
470
+ if (!input || typeof input !== 'object')
471
+ return undefined;
472
+ const record = input;
473
+ for (const key of keys) {
474
+ const value = record[key];
475
+ if (typeof value === 'string' && value.trim())
476
+ return (0, exports.clamp)(value.trim());
477
+ }
478
+ return undefined;
479
+ };
480
+ exports.targetFrom = targetFrom;
481
+ const targetOf = (input) => (0, exports.targetFrom)(input, TARGET_KEYS);
482
+ const contentBlocks = (entry) => {
483
+ const message = entry.message;
484
+ if (!message || typeof message !== 'object')
485
+ return [];
486
+ const content = message.content;
487
+ if (!Array.isArray(content))
488
+ return [];
489
+ return content.filter((b) => !!b && typeof b === 'object');
490
+ };
491
+ /**
492
+ * A user entry whose content is a plain string is something the person typed.
493
+ * One carrying an array of `tool_result` blocks is the harness answering the
494
+ * model, and counting it as a prompt would cut every turn into fragments —
495
+ * the same distinction `plugin/hooks/hwip-stop.sh` draws to scope a turn.
496
+ */
497
+ /**
498
+ * Harness plumbing that Claude Code writes into the conversation as if a person
499
+ * had said it: background-task notifications, injected reminders, the caveat
500
+ * wrapper around a slash command's own output.
501
+ *
502
+ * Dropping these is not cosmetic. A `<task-notification>` rendered as a prompt
503
+ * bubble puts internal ids, tool-use ids and whatever a tool reported on a
504
+ * phone screen, attributed to the user — and there is no bound on what a future
505
+ * notification carries.
506
+ */
507
+ const SYNTHETIC_BLOCKS = /<(task-notification|system-reminder|local-command-caveat|local-command-stdout|command-message|command-name|command-args)>[\s\S]*?<\/\1>/g;
508
+ /** `<command-name>/simplify</command-name>` — the one wrapper worth keeping, as what it names. */
509
+ const COMMAND_NAME = /<command-name>([^<]*)<\/command-name>/;
510
+ /**
511
+ * Whether this entry is a person speaking.
512
+ *
513
+ * `origin.kind` is the honest answer where Claude Code writes it: `human` for
514
+ * both a typed message and a slash command, something else for everything the
515
+ * harness injects. Absent — an older version — it falls through to the tag
516
+ * check below, so a build that does not stamp provenance still cannot leak a
517
+ * notification it happens to phrase as a user turn.
518
+ */
519
+ const isHumanTurn = (entry) => {
520
+ if (entry.promptSource === 'system')
521
+ return false;
522
+ const origin = entry.origin;
523
+ if (origin && typeof origin === 'object') {
524
+ const kind = origin.kind;
525
+ if (typeof kind === 'string')
526
+ return kind === 'human';
527
+ }
528
+ return true;
529
+ };
530
+ /**
531
+ * What Claude Code writes as a user message when the person presses Esc: the
532
+ * whole text of the message is exactly this, in one of two forms (measured in
533
+ * local transcripts: a single text block, no `origin`, not `isMeta`, so nothing
534
+ * but the text tells it from a typed message). It says something happened to
535
+ * the turn; it is not something the person said.
536
+ *
537
+ * Matched against the whole text only. A continuation summary or a message
538
+ * that quotes the marker inside longer text is a person's or the harness's own
539
+ * prose and stays.
540
+ */
541
+ const INTERRUPT_MARKER = /^\[Request interrupted by user( for tool use)?\]$/;
542
+ const promptTextOf = (entry) => {
543
+ const text = typedTextOf(entry);
544
+ return text !== null && INTERRUPT_MARKER.test(text) ? null : text;
545
+ };
546
+ const typedTextOf = (entry) => {
547
+ if (entry.type !== 'user' || entry.isMeta)
548
+ return null;
549
+ if (!isHumanTurn(entry))
550
+ return null;
551
+ const message = entry.message;
552
+ if (!message || typeof message !== 'object')
553
+ return null;
554
+ const content = message.content;
555
+ if (typeof content === 'string')
556
+ return nonEmpty(stripAttachmentMarkers(content));
557
+ // An array is usually the harness answering the model, but the same shape
558
+ // carries what a person typed when they attached something — the prompt
559
+ // arrives as text blocks beside the attachment. Take those; a message with
560
+ // no text block at all is a pure tool_result carrier and not a prompt.
561
+ if (!Array.isArray(content))
562
+ return null;
563
+ const text = content
564
+ .filter((b) => !!b && typeof b === 'object' && b.type === 'text')
565
+ .map((b) => (typeof b.text === 'string' ? b.text : ''))
566
+ .join('\n')
567
+ .trim();
568
+ return text ? nonEmpty(stripAttachmentMarkers(text)) : null;
569
+ };
570
+ /**
571
+ * Drop the `[Image: source: /abs/path]` markers Claude Code substitutes for an
572
+ * attached image.
573
+ *
574
+ * They are a local filesystem path and nothing else: useless on the phone, which
575
+ * cannot open it, and a directory listing of this machine if it goes anywhere
576
+ * else. The attachment itself is not on this wire — showing that one exists is a
577
+ * separate feature, not a reason to ship the path.
578
+ */
579
+ const stripAttachmentMarkers = (text) => {
580
+ const command = text.match(COMMAND_NAME)?.[1]?.trim();
581
+ const body = text.replace(SYNTHETIC_BLOCKS, '');
582
+ // A slash command is a person's turn, and its name is the whole of what they
583
+ // said — the wrapper around it is not.
584
+ const withCommand = command ? `${command}\n${body}` : body;
585
+ return withCommand.replace(/\[Image:[^\]]*\]/g, '').replace(/\n{3,}/g, '\n\n');
586
+ };
587
+ /** Trimmed, or null when nothing survived — a message that was only an attachment. */
588
+ const nonEmpty = (text) => {
589
+ const trimmed = text.trim();
590
+ return trimmed ? trimmed : null;
591
+ };
592
+ /**
593
+ * Turn raw JSONL lines into wire events.
594
+ *
595
+ * Nothing a tool read or wrote survives this function: only the tool's name, a
596
+ * short label for what it acted on, whether it worked, and numbers about it —
597
+ * lines changed, result length, a message's tokens and model. That is the whole
598
+ * privacy story of the feature — the transcript holds file contents and command
599
+ * output, and this is the one place that decides none of it leaves the machine.
600
+ *
601
+ * `sinceLastPrompt` is for the backfill window: finished turns already exist in
602
+ * the chat as their completion pushes, so replaying them would double every
603
+ * message. Only the turn still in flight is new information.
604
+ *
605
+ * The prompt a person typed IS carried, unlike anything a tool read or wrote.
606
+ * It is their own words, it is the same class of content the completion push
607
+ * already sends, and without it the timeline is a list of tool names with no
608
+ * record of what was asked.
609
+ *
610
+ * Everything here — the block shapes, the entry types, `TARGET_KEYS` — is Claude
611
+ * Code's transcript format. Supporting another agent (pi, Codex) means a
612
+ * projector of its own alongside this one, satisfying `TranscriptProjector` and
613
+ * reached the way `REMOTE_AGENTS` already reaches per-agent session resolvers:
614
+ * `RemoteAgent.projectTranscript`, resolved together with the path as a
615
+ * `TranscriptSource`. `turn-watch` calls whichever projector it is handed and
616
+ * knows about none of them by name.
617
+ */
618
+ const projectTranscriptEntries = (lines, opts = {}) => {
619
+ const events = [];
620
+ // One `msg` per API message in this batch, kept where it first appeared.
621
+ const messages = new Map();
622
+ let todo = opts.todo;
623
+ let todoAt;
624
+ for (const raw of lines) {
625
+ let entry;
626
+ try {
627
+ const parsed = JSON.parse(raw);
628
+ if (!parsed || typeof parsed !== 'object')
629
+ continue;
630
+ entry = parsed;
631
+ }
632
+ catch {
633
+ continue;
634
+ }
635
+ // A subagent's own tool calls belong to the Task/Agent call that spawned
636
+ // it, which the parent transcript already shows as one row — unless this
637
+ // IS the subagent's transcript, where every line is a sidechain and
638
+ // dropping them leaves a viewer watching an empty file.
639
+ if (entry.isSidechain && !opts.includeSidechain)
640
+ continue;
641
+ const at = typeof entry.timestamp === 'string' ? entry.timestamp : undefined;
642
+ const folded = (0, todo_fold_js_1.applyTodoEntry)(todo, entry);
643
+ if (folded !== todo) {
644
+ todo = folded;
645
+ // An entry with no time keeps the last one seen: `afterRingTail` cuts by `at`.
646
+ todoAt = at ?? todoAt;
647
+ }
648
+ const prompt = promptTextOf(entry);
649
+ if (prompt !== null) {
650
+ events.push({ kind: 'prompt', text: (0, exports.clamp)(prompt, exports.MAX_EVENT_TEXT_CHARS), ...(at ? { at } : {}) });
651
+ continue;
652
+ }
653
+ const blocks = contentBlocks(entry);
654
+ const meta = messageMetaOf(entry, at);
655
+ if (meta) {
656
+ const thinking = blocks.filter((b) => b.type === 'thinking' || b.type === 'redacted_thinking').length;
657
+ const seen = messages.get(meta.mid);
658
+ if (seen) {
659
+ // Same message, later line: usage is repeated, thinking is not.
660
+ Object.assign(seen, { out: meta.out, ctx: meta.ctx });
661
+ if (thinking)
662
+ seen.thinking = (seen.thinking ?? 0) + thinking;
663
+ }
664
+ else {
665
+ if (thinking)
666
+ meta.thinking = thinking;
667
+ messages.set(meta.mid, meta);
668
+ events.push(meta);
669
+ }
670
+ }
671
+ for (const block of blocks) {
672
+ if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
673
+ const target = targetOf(block.input);
674
+ events.push({
675
+ kind: 'tool',
676
+ id: block.id,
677
+ name: (0, exports.clamp)(block.name),
678
+ ...(target ? { target } : {}),
679
+ ...lineChangesOf(block.name, block.input),
680
+ ...(at ? { at } : {}),
681
+ });
682
+ continue;
683
+ }
684
+ if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
685
+ const lines = (0, exports.resultLinesOf)(block.content);
686
+ events.push({
687
+ kind: 'tool_result',
688
+ id: block.tool_use_id,
689
+ ok: block.is_error !== true,
690
+ ...(lines ? { lines } : {}),
691
+ ...(at ? { at } : {}),
692
+ });
693
+ continue;
694
+ }
695
+ // Prose, only from the model. A user entry reaching here has already
696
+ // been offered to `promptTextOf`; treating its text blocks as
697
+ // assistant output is how an attachment marker ends up rendered as
698
+ // the agent's own words.
699
+ if (entry.type === 'assistant' &&
700
+ block.type === 'text' &&
701
+ typeof block.text === 'string' &&
702
+ block.text.trim()) {
703
+ events.push({ kind: 'text', text: (0, exports.clamp)(block.text, exports.MAX_EVENT_TEXT_CHARS), ...(at ? { at } : {}) });
704
+ }
705
+ // `thinking` falls through on purpose — the timeline shows what the
706
+ // agent did, not what it considered. Only its count leaves, on `msg`.
707
+ }
708
+ }
709
+ // One `todo` per read, after everything else: a read that moves five tasks
710
+ // sends the list once, and being last it outlives `sliceSinceLastPrompt` —
711
+ // a list made before the latest prompt is still the session's list.
712
+ if (todo && todo !== opts.todo) {
713
+ events.push((0, exports.todoEventOf)(todo, todoAt));
714
+ opts.onTodo?.(todo);
715
+ }
716
+ return (0, exports.sliceSinceLastPrompt)(events, opts);
717
+ };
718
+ exports.projectTranscriptEntries = projectTranscriptEntries;