@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.
- package/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- 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;
|