@sjawhar/claude-envoy 1.18.0 → 1.19.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.
@@ -0,0 +1,340 @@
1
+ // The Agents page's live view of one Claude Code session (INFRA-1026): the mod's events as the
2
+ // conversation, the two header frames read off the mod's own `$` calls, and the control subject a
3
+ // viewer takes this session on. It is driven by the channel server (`./envoy-channel-server`),
4
+ // which gives it the link and the session's NATS connection, and it reaches nothing of that
5
+ // server's: a subject is a string, and every collaborator arrives as an option.
6
+ import {
7
+ AGENT_STREAM_WATCH_INTERVAL_MS,
8
+ type AgentStreamCommand,
9
+ type AgentStreamControlMessage,
10
+ agentStreamControlSubject,
11
+ agentStreamFramesSubject,
12
+ } from "@legion/contracts"
13
+ import { messageFor } from "@legion/envoy-client/errors"
14
+ import { AgentStreamPublisher } from "@legion/pi-shared/agent-stream"
15
+ import type { ModConfigRow, ModEffort, ModEvent } from "../hooks/mod-protocol"
16
+ import { claudeAgentStreamCommands, createClaudeAgentStream } from "./agent-stream-claude"
17
+ import { rememberBounded } from "./bounded-set"
18
+ import type { ChannelForwarderConnection, ChannelTopicSubscription } from "./channel-forwarder"
19
+ import type { ModLink } from "./mod-link"
20
+
21
+ const encoder = new TextEncoder()
22
+ const decoder = new TextDecoder()
23
+
24
+ /** How long the live view waits for the mod to answer one of its reads (`$.session.model()`,
25
+ * `$.config.list()`, `$.command.list()`). Each is served out of the session's own memory, so a
26
+ * wait this long means the mod is gone rather than busy, and the header keeps its last values. */
27
+ const AGENT_STREAM_CALL_TIMEOUT_MS = 5_000
28
+ /** How long the live view waits before taking its control subject again after the subscription's
29
+ * iterator ended, which is what a dropped connection looks like from here. */
30
+ const AGENT_STREAM_RESUBSCRIBE_MS = 1_000
31
+
32
+ /**
33
+ * The most row ids this stream remembers having read, so a row that arrives twice is read once.
34
+ *
35
+ * The mod reports every row it appends itself, because Claude Code does not re-enter a mod's
36
+ * own `session.append` hook for one (`../hooks/mod.ts`); a host that does fire that hook as
37
+ * well would send the row twice, and the view would draw it twice and the session would read
38
+ * the same `command` row twice (`./mod-session`). The two copies are a moment apart, so a few
39
+ * hundred ids is past any window one could cross, and the record is bounded (`rememberBounded`)
40
+ * rather than kept for the session's life.
41
+ */
42
+ const AGENT_STREAM_SEEN_ROWS_MAX = 512
43
+
44
+ export interface AgentStreamOptions {
45
+ /** The link to this session's Claude Code mod (`./mod-link`): its events are the conversation,
46
+ * and its `$` calls are what the header's two frames are built from. */
47
+ readonly link: ModLink
48
+ /** The session's own NATS connection: the frames go out on it, and the control subject is
49
+ * taken on it. The session's NATS user may already publish and subscribe to both. */
50
+ readonly connection: ChannelForwarderConnection
51
+ /** The session id the Agents page knows this session by, which both subjects name. */
52
+ readonly sessionId: string
53
+ /**
54
+ * Whether the stream follows the session id the mod reports. Claude Code mints a new one on
55
+ * `/clear` and the channel rebinds to it (`adoptHandoff`), so the two are the same id; under
56
+ * the `ENVOY_SESSION_ID` override the session registers a fixed id of its own and the Claude
57
+ * id names nothing Dispatch knows, so the stream stays where it started.
58
+ */
59
+ readonly followsSessionId: boolean
60
+ /** Whether this session is a Legion pane, whose first command the daemon's bridge writes
61
+ * rather than a person (`./agent-stream-claude`, `PANE_SEEDED_COMMAND`). */
62
+ readonly isPane?: boolean
63
+ readonly now?: () => number
64
+ /** Where a failure that must not stop the session is written; the server's stderr by default. */
65
+ readonly warn?: (text: string) => void
66
+ /**
67
+ * Takes every event this stream reads, before the stream records it. The link's events have one
68
+ * consumer and this is it, so whatever else needs them - the router of a person's messages, and
69
+ * the turn tracking behind it - is handed them here.
70
+ */
71
+ readonly observe?: (event: ModEvent) => void
72
+ /** The Dispatch message a row of the conversation is, which tags that row so the Agents page
73
+ * shows a person's own message once rather than beside its stored copy. */
74
+ readonly tagFor?: (row: string) => string | undefined
75
+ /** Whether a row of the `command` door is the echo row a `$.command.run` of this session's
76
+ * claimed (`./mod-session`, `runsCommandRow`), asked by the row's own id: the view draws
77
+ * nothing of it, since Claude Code echoes the command it ran rather than the one the person
78
+ * sent. The order below is what makes it answer for the row being recorded - `observe`
79
+ * hands the session each event before this stream records it. */
80
+ readonly runsCommandRow?: (row: string) => boolean
81
+ /** Whether a row of the `prompt` door is the row Claude Code wrote for this session's prompt
82
+ * at the messages a turn never read (`./mod-session`, `runsUnreadPrompt`), asked by the
83
+ * row's own id: the view draws nothing of it, since that prompt quotes rows it has already
84
+ * drawn. Answered for the row being recorded by the same order `runsCommandRow` relies on. */
85
+ readonly runsUnreadPrompt?: (row: string) => boolean
86
+ /** Whether a row is the row one message this session was handed became (`./mod-session`,
87
+ * `appendedRow`), asked by the row's own id: the one `isMeta` row the view draws, since a
88
+ * card appended into the turn running is such a row and nothing else. Answered for the row
89
+ * being recorded by the same order `runsCommandRow` relies on — the session takes the row's
90
+ * event and claims it there, before the view records that event
91
+ * (`./envoy-channel-server`). The claim rides on that event rather than on the answer of the
92
+ * call that wrote the row, because the two cross: the mod holds an event behind a post
93
+ * already in flight while a call's result goes out on a fetch of its own, so either can land
94
+ * first (`../hooks/mod.ts`, `flush`). */
95
+ readonly appendedRow?: (row: string) => boolean
96
+ }
97
+
98
+ export interface AgentStreamRunner {
99
+ /** Stop answering the control subject. The mod's events end with the link itself. */
100
+ readonly stop: () => void
101
+ /** Read the session's model and levels again and publish them: a pick has just changed one,
102
+ * and the header must show it before the reply naming it reaches the page. */
103
+ readonly refresh: () => void
104
+ /** The effort a person's pick put in force, shown by the header at once: Claude Code reports
105
+ * an effort only on a request of its own, and an idle session makes none. */
106
+ readonly pickedEffort: (effort: ModEffort) => void
107
+ }
108
+
109
+ /**
110
+ * Publish this session's conversation for the Agents page's live view (INFRA-1026).
111
+ *
112
+ * The mod's events become messages on the shared publisher (`./agent-stream-claude`), and this
113
+ * answers the viewer's control subject the way every Oh My Pi session does (`packages/pi-envoy`,
114
+ * `pumpAgentStreamControl`): a message that is neither `replay` nor `watch` is ignored, the
115
+ * replay answer is taken before the message arms anything, so a bare replay from a client that
116
+ * never attached a viewer gets an empty history, and every message runs inside a guard, since
117
+ * one throw out of the iterator would leave the session deaf until the resubscribe.
118
+ *
119
+ * What differs from an Oh My Pi session is where the header's values come from. That host reads
120
+ * its model, its levels and its commands out of its own memory; here they are three calls on the
121
+ * mod, over a socket, which a session with no mod never answers. So a refresh is asked for and
122
+ * not waited on: a control message is answered from what the mod said last, and a changed answer
123
+ * reaches the viewer as its own frame a moment later. A session whose mod never connects
124
+ * publishes no settings and no commands, and its conversation stays empty.
125
+ */
126
+ export function startAgentStream(options: AgentStreamOptions): AgentStreamRunner {
127
+ const { connection, link } = options
128
+ const now = options.now ?? Date.now
129
+ const warn =
130
+ options.warn ?? ((text: string): void => void process.stderr.write(`envoy-channel: ${text}\n`))
131
+ const publisher = new AgentStreamPublisher({
132
+ now,
133
+ publish: (subject, payload) => connection.publish(subject, encoder.encode(payload)),
134
+ })
135
+ const stream = createClaudeAgentStream({
136
+ isPane: options.isPane === true,
137
+ now,
138
+ publisher,
139
+ ...(options.appendedRow === undefined ? {} : { appendedRow: options.appendedRow }),
140
+ ...(options.runsCommandRow === undefined ? {} : { runsCommandRow: options.runsCommandRow }),
141
+ ...(options.runsUnreadPrompt === undefined
142
+ ? {}
143
+ : { runsUnreadPrompt: options.runsUnreadPrompt }),
144
+ ...(options.tagFor === undefined ? {} : { tagFor: options.tagFor }),
145
+ })
146
+ /** The id both subjects name now; it moves with the mod's own id after a `/clear`. */
147
+ let session = options.sessionId
148
+ let subscription: ChannelTopicSubscription | undefined
149
+ let stopped = false
150
+ /** What the mod last said of the session's model, its `/config` rows and its commands. They
151
+ * survive a session id change: the process and its model do not change with the id. */
152
+ let model: string | undefined
153
+ let configRows: readonly ModConfigRow[] | undefined
154
+ let listedCommands: readonly AgentStreamCommand[] | undefined
155
+ let readingSettings = false
156
+ let readingCommands = false
157
+ /** Whether a viewer has ever armed this session, and when a watch last refreshed the commands:
158
+ * both as the Oh My Pi pump keeps them. */
159
+ let viewerAttached = false
160
+ let commandsRefreshedOnWatchAt = Number.NEGATIVE_INFINITY
161
+ /** Whether the mod has said anything at all, which is when its reads are first worth making. */
162
+ let primed = false
163
+
164
+ /** The rows this stream has read, newest last and bounded by `AGENT_STREAM_SEEN_ROWS_MAX`:
165
+ * what the duplicate guard below answers from. */
166
+ const seenRows = new Set<string>()
167
+
168
+ /** Whether this row has been read already, remembering it when it has not. Every event of the
169
+ * link passes here, which is the one place both readers below are behind. */
170
+ const readTwice = (uuid: string): boolean => {
171
+ if (seenRows.has(uuid)) return true
172
+ rememberBounded(seenRows, uuid, AGENT_STREAM_SEEN_ROWS_MAX)
173
+ return false
174
+ }
175
+
176
+ const framesSubject = (): string => agentStreamFramesSubject(session)
177
+
178
+ const publishSettings = (): void => {
179
+ publisher.setSettings(framesSubject(), stream.settings(model, configRows))
180
+ }
181
+
182
+ const publishCommands = (): void => {
183
+ if (listedCommands !== undefined) publisher.setCommands(framesSubject(), listedCommands)
184
+ }
185
+
186
+ /** Read the session's model and its `/config` rows, and publish what they say. One read at a
187
+ * time: a watch arrives every ten seconds and the answers rarely change. */
188
+ const refreshSettings = (): void => {
189
+ if (stopped || readingSettings || !link.connected()) return
190
+ readingSettings = true
191
+ void Promise.all([
192
+ link.request("session.model", undefined, { timeoutMs: AGENT_STREAM_CALL_TIMEOUT_MS }),
193
+ link.request("config.list", undefined, { timeoutMs: AGENT_STREAM_CALL_TIMEOUT_MS }),
194
+ ])
195
+ .then(([answeredModel, rows]) => {
196
+ model = answeredModel
197
+ configRows = rows
198
+ if (!stopped) publishSettings()
199
+ })
200
+ .catch((error: unknown) => {
201
+ warn(`the live view could not read this session's model — ${messageFor(error)}`)
202
+ })
203
+ .finally(() => {
204
+ readingSettings = false
205
+ })
206
+ }
207
+
208
+ const refreshCommands = (): void => {
209
+ if (stopped || readingCommands || !link.connected()) return
210
+ readingCommands = true
211
+ void link
212
+ .request("command.list", undefined, { timeoutMs: AGENT_STREAM_CALL_TIMEOUT_MS })
213
+ .then((commands) => {
214
+ listedCommands = claudeAgentStreamCommands(commands)
215
+ if (!stopped) publishCommands()
216
+ })
217
+ .catch((error: unknown) => {
218
+ warn(`the live view could not read this session's commands — ${messageFor(error)}`)
219
+ })
220
+ .finally(() => {
221
+ readingCommands = false
222
+ })
223
+ }
224
+
225
+ const pump = async (taken: ChannelTopicSubscription, forSession: string): Promise<void> => {
226
+ try {
227
+ for await (const message of taken) {
228
+ // Any client on the bus can publish here. A `null` payload parses cleanly and then
229
+ // throws on `.type`, and a reply over the server's max_payload throws out of the
230
+ // publish; either would end the iterator and take the session deaf with it.
231
+ try {
232
+ const parsed: unknown = JSON.parse(decoder.decode(message.data))
233
+ if (typeof parsed !== "object" || parsed === null) continue
234
+ const request = parsed as AgentStreamControlMessage
235
+ if (request.type !== "replay" && request.type !== "watch") continue
236
+ refreshSettings()
237
+ if (
238
+ request.type === "replay" ||
239
+ !viewerAttached ||
240
+ now() - commandsRefreshedOnWatchAt >= AGENT_STREAM_WATCH_INTERVAL_MS
241
+ ) {
242
+ refreshCommands()
243
+ if (request.type === "watch" && viewerAttached) commandsRefreshedOnWatchAt = now()
244
+ }
245
+ // Taken before this message arms anything, so a bare replay from a client that never
246
+ // attached a viewer gets an empty history rather than the ring.
247
+ const reply = message.reply
248
+ const answer =
249
+ request.type === "replay" && reply !== undefined
250
+ ? JSON.stringify(publisher.replay(forSession))
251
+ : undefined
252
+ const arming = !publisher.watched
253
+ publisher.noteViewer()
254
+ viewerAttached = true
255
+ if (arming) publisher.publishState(agentStreamFramesSubject(forSession))
256
+ if (answer === undefined || reply === undefined) continue
257
+ connection.publish(reply, encoder.encode(answer))
258
+ } catch (error) {
259
+ warn(`a live view control message failed — ${messageFor(error)}`)
260
+ }
261
+ }
262
+ } catch {
263
+ // A dead iterator is a dropped connection, taken again below.
264
+ }
265
+ if (subscription === taken) subscription = undefined
266
+ if (stopped || session !== forSession) return
267
+ const retry = setTimeout(() => {
268
+ if (stopped || session !== forSession) return
269
+ subscribe()
270
+ }, AGENT_STREAM_RESUBSCRIBE_MS)
271
+ retry.unref()
272
+ }
273
+
274
+ function subscribe(): void {
275
+ if (stopped) return
276
+ subscription?.unsubscribe()
277
+ const taken = connection.subscribe(agentStreamControlSubject(session))
278
+ subscription = taken
279
+ void pump(taken, session)
280
+ }
281
+
282
+ // A `/clear` ends the conversation and the process goes on under a new id: the ring holds the
283
+ // previous one, and a viewer opening the new id must not be served it.
284
+ link.onSessionId((next: string) => {
285
+ if (stopped || !options.followsSessionId || next === session) return
286
+ session = next
287
+ publisher.reset()
288
+ stream.reset()
289
+ viewerAttached = false
290
+ commandsRefreshedOnWatchAt = Number.NEGATIVE_INFINITY
291
+ // `reset` dropped the two header frames with the conversation; what the mod already said
292
+ // still holds, so they are put back under the new id rather than read again.
293
+ publishSettings()
294
+ publishCommands()
295
+ subscribe()
296
+ })
297
+
298
+ const reading = (async (): Promise<void> => {
299
+ for await (const event of link.events) {
300
+ if (!primed) {
301
+ primed = true
302
+ refreshSettings()
303
+ refreshCommands()
304
+ }
305
+ // A row that has been read already is read no second time, whoever sent it twice
306
+ // (`AGENT_STREAM_SEEN_ROWS_MAX`).
307
+ if (event.kind === "append" && readTwice(event.uuid)) continue
308
+ if (event.kind === "model") model = event.model
309
+ // Before the stream records it: an event can be the row a person's message turned out to
310
+ // be, and that row is tagged with the message it is.
311
+ options.observe?.(event)
312
+ stream.record(framesSubject(), event)
313
+ // A step is the one event that can change the thinking level, and the model event is the
314
+ // mod's own report of a model change: neither is worth a read of the mod to notice.
315
+ if (event.kind === "step" || event.kind === "model") publishSettings()
316
+ }
317
+ })()
318
+ void reading.catch((error: unknown) => {
319
+ warn(`the live view stopped reading this session's events — ${messageFor(error)}`)
320
+ })
321
+
322
+ subscribe()
323
+
324
+ return {
325
+ refresh(): void {
326
+ refreshSettings()
327
+ },
328
+ pickedEffort(effort: ModEffort): void {
329
+ if (stopped) return
330
+ stream.pickedEffort(effort)
331
+ publishSettings()
332
+ },
333
+ stop(): void {
334
+ if (stopped) return
335
+ stopped = true
336
+ subscription?.unsubscribe()
337
+ subscription = undefined
338
+ },
339
+ }
340
+ }
@@ -0,0 +1,54 @@
1
+ // The bounded, insertion-ordered records this plugin keeps (INFRA-1026): a session remembers
2
+ // several things for its whole life — the rows it claimed, the deliveries it answered, the
3
+ // attempts it handled, the Dispatch message each delivered row is — and each drops its oldest
4
+ // entry at a bound of its own. Two shapes carry them, a `Set` for a record that is its values
5
+ // alone and a `Map` for one that holds a value beside its key, and the rule is written once here
6
+ // in each, so a fix to how the oldest is dropped is made once.
7
+ //
8
+ // What is not here is a queue: a list of work still in flight, where the oldest entry is dropped
9
+ // as the cap that keeps a reply nobody sent from growing for ever, not as the record of
10
+ // something seen. `user-turn-router`'s `awaited` is the one such list, and it drops its own with
11
+ // `shift()` where it is kept.
12
+
13
+ /**
14
+ * Remember `value`, dropping the oldest entries until the record is back inside `max`.
15
+ *
16
+ * A `Set` iterates in insertion order, so its first entry is the one added longest ago, and
17
+ * adding a value the set already holds leaves that order alone. The caller names `max`, which is
18
+ * what each record's own bound says; the loop also holds a record that is already past a bound
19
+ * to it.
20
+ */
21
+ export function rememberBounded(remembered: Set<string>, value: string, max: number): void {
22
+ remembered.add(value)
23
+ while (remembered.size > max) {
24
+ const oldest = remembered.values().next().value
25
+ // A non-empty `Set` always yields one, and `max` is never below zero in this package; the
26
+ // check is what keeps a bound of a different shape from looping here forever.
27
+ if (oldest === undefined) break
28
+ remembered.delete(oldest)
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Remember `key` as `value`, dropping the oldest entries until the record is back inside `max`.
34
+ *
35
+ * The `Map`-shaped record's rule, for one that holds a value beside its key: the Dispatch
36
+ * message each delivered row is (`./user-turn-state`). A `Map` iterates in insertion order as a
37
+ * `Set` does, and setting a key the map already holds leaves that order alone, so the oldest
38
+ * entry is the one its first key names.
39
+ */
40
+ export function rememberBoundedEntry(
41
+ remembered: Map<string, string>,
42
+ key: string,
43
+ value: string,
44
+ max: number,
45
+ ): void {
46
+ remembered.set(key, value)
47
+ while (remembered.size > max) {
48
+ const oldest = remembered.keys().next().value
49
+ // As above: a non-empty `Map` always yields a key, and the check is what keeps a bound of a
50
+ // different shape from looping here forever.
51
+ if (oldest === undefined) break
52
+ remembered.delete(oldest)
53
+ }
54
+ }
@@ -148,7 +148,7 @@ function report(what: string, error: unknown): void {
148
148
  * One channel process receives both its direct Envoy route and every followed
149
149
  * topic. It deduplicates at the broker boundary, by event id and by dedupe key,
150
150
  * so an event covered by several patterns, and a re-send of one already
151
- * delivered, still becomes one Claude Code channel notification. With a replay
151
+ * delivered, still becomes one line in the session's conversation. With a replay
152
152
  * ledger, a frame the session was shown before this process started is dropped
153
153
  * too, and each frame handed on is recorded there before its claim settles.
154
154
  */
@@ -0,0 +1,195 @@
1
+ // The rows Claude Code writes of its own bookkeeping, parsed (INFRA-1026).
2
+ //
3
+ // One command a person runs writes three rows by the `command` door - the caveat, the echo of
4
+ // what was typed, and the output - and two readers of this plugin parse them. The live view draws
5
+ // the echo row as the plain command line a person typed and drops the other two, which would
6
+ // otherwise show a reader raw tags and a `/context` table inside the agent's conversation
7
+ // (`./agent-stream-claude`). The session reads the output row to answer the person who sent the
8
+ // command, since most commands resolve `$.command.run` with no output in the result at all
9
+ // (`./mod-session`, `runCommand`).
10
+ //
11
+ // A picture a person sent writes a row of the same kind: Claude Code's note of where it put the
12
+ // bytes (`isPictureAttachmentRow`), which the live view drops as it drops the command's
13
+ // bookkeeping.
14
+ //
15
+ // A line a person types while a turn is running arrives wrapped in framing of Claude Code's own
16
+ // (`claudeMidTurnMessage`), and the live view draws the person's words out of it and none of the
17
+ // wrapper.
18
+ import type { ModContentBlock } from "../hooks/mod-protocol"
19
+
20
+ /**
21
+ * The tags Claude Code wraps its own bookkeeping of a slash command in.
22
+ *
23
+ * Measured on 2.1.310: a command a person runs writes three rows by the `command` door - the
24
+ * caveat row, which says the command's output went to the person rather than to the model and
25
+ * carries `isMeta: true`; the echo row, which holds what was typed; and the output row. The
26
+ * output row's `message.type` is `user` in some runs and `system` (`name: "local_command"`) in
27
+ * others, so the tag its text starts with is the only safe discriminator
28
+ * (`/legion/scratch/tmp/infra-1026-spike/config/projects/-legion-scratch-tmp-infra-1026-proof-work/22a2c318-8433-4659-9568-a5ce1a873926.jsonl:42-44`,
29
+ * this plugin's own mod running `/usage`).
30
+ */
31
+ const COMMAND_BOOKKEEPING_TAGS = [
32
+ "<local-command-caveat>",
33
+ "<local-command-stdout>",
34
+ "<local-command-stderr>",
35
+ ] as const
36
+
37
+ /** The two tags an output row wraps what the command printed in, in the order a row carrying
38
+ * both would be read: what went to the terminal's stdout, else what went to its stderr. */
39
+ const COMMAND_OUTPUT_TAGS = ["local-command-stdout", "local-command-stderr"] as const
40
+
41
+ /** The text a row's content holds, blocks flattened: a tool result is shown as text alone. A
42
+ * block with no text of its own is named rather than dropped, as the publisher names one. */
43
+ export function claudeRowText(value: unknown): string {
44
+ if (typeof value === "string") return value
45
+ if (!Array.isArray(value)) return ""
46
+ const pieces: string[] = []
47
+ for (const item of value as readonly unknown[]) {
48
+ if (typeof item === "string") {
49
+ pieces.push(item)
50
+ continue
51
+ }
52
+ if (typeof item !== "object" || item === null) continue
53
+ const block = item as Record<string, unknown>
54
+ const text = block["text"]
55
+ const type = block["type"]
56
+ if (typeof text === "string") pieces.push(text)
57
+ else if (typeof type === "string") pieces.push(`[${type}]`)
58
+ }
59
+ return pieces.join("\n")
60
+ }
61
+
62
+ /** The value one of a row's tags holds, trimmed: the recorded row indents every tag after the
63
+ * first, so the whitespace around a value is the row's layout and not part of it. */
64
+ function taggedValue(text: string, tag: string): string | undefined {
65
+ const open = `<${tag}>`
66
+ const from = text.indexOf(open)
67
+ if (from === -1) return undefined
68
+ const to = text.indexOf(`</${tag}>`, from + open.length)
69
+ if (to === -1) return undefined
70
+ return text.slice(from + open.length, to).trim()
71
+ }
72
+
73
+ /**
74
+ * The plain command line a row of the `command` door echoes, or `undefined` where the row is not
75
+ * the echo of a command a person ran.
76
+ *
77
+ * Measured on 2.1.310: the echo row holds one text block naming `<command-name>/usage</command-
78
+ * name>`, `<command-message>usage</command-message>` and `<command-args></command-args>`,
79
+ * newline-separated, each tag on its own line (same transcript as
80
+ * `COMMAND_BOOKKEEPING_TAGS`). The command line is the name, and the arguments after a space
81
+ * where there are any - `/clear`, `/effort low` - which is what the person typed and all of the
82
+ * command the live view shows.
83
+ */
84
+ export function claudeEchoedCommandLine(content: readonly ModContentBlock[]): string | undefined {
85
+ const text = claudeRowText(content)
86
+ const name = taggedValue(text, "command-name")
87
+ if (name === undefined || name === "") return undefined
88
+ const args = taggedValue(text, "command-args") ?? ""
89
+ return args === "" ? name : `${name} ${args}`
90
+ }
91
+
92
+ /**
93
+ * What the output row of a slash command printed, or `undefined` where the row is not an output
94
+ * row. A row that is one and holds nothing answers `""`: the command printed nothing, which is
95
+ * itself the answer the person who sent it gets.
96
+ *
97
+ * Measured on 2.1.310: the row holds one text block wrapped in
98
+ * `<local-command-stdout>…</local-command-stdout>` (`<local-command-stderr>` where the command
99
+ * wrote to stderr), and most commands put their whole output there and nowhere else -
100
+ * `/effort low` resolves `$.command.run` with `{}` and writes
101
+ * `<local-command-stdout>Set effort level to low …</local-command-stdout>` 158 ms later
102
+ * (`/legion/scratch/tmp/infra-1026-spike/logs/mod3-q2-310.jsonl:98-102`).
103
+ */
104
+ export function claudeCommandOutput(content: readonly ModContentBlock[]): string | undefined {
105
+ const text = claudeRowText(content)
106
+ for (const tag of COMMAND_OUTPUT_TAGS) {
107
+ const value = taggedValue(text, tag)
108
+ if (value !== undefined) return value
109
+ }
110
+ return undefined
111
+ }
112
+
113
+ /** Whether a row of the `command` door is Claude Code's bookkeeping of the command rather than
114
+ * anything a person wrote: the caveat it records before the command runs, and the command's own
115
+ * output, which published shows the person raw tags and a `/context` table in the agent's
116
+ * conversation (INFRA-1026). */
117
+ export function isCommandBookkeeping(content: readonly ModContentBlock[]): boolean {
118
+ const text = claudeRowText(content)
119
+ return COMMAND_BOOKKEEPING_TAGS.some((tag) => text.startsWith(tag))
120
+ }
121
+
122
+ /**
123
+ * The row Claude Code writes of where it put a picture's bytes: one text block holding
124
+ * `[Image: source: <absolute path>]` and nothing else.
125
+ *
126
+ * Measured on 2.1.310: a person's message carrying a picture reaches the session as its text and
127
+ * an image block, and Claude Code writes the bytes under the session's own images directory and
128
+ * records a companion row for them - `type: "user"`, `isMeta: true`, `turnCompanion: true`, its
129
+ * one text block
130
+ * `[Image: source: /legion/scratch/tmp/i3-k5-pane/tmp/claude-1000/-legion-scratch-tmp-i3-k5-pane-cwd/f376a3a9-6e75-461b-806b-22dea4e83ddf/images/1.png]`
131
+ * (`/legion/scratch/tmp/i3-k5-pane/config/projects/-legion-scratch-tmp-i3-k5-pane-cwd/f376a3a9-6e75-461b-806b-22dea4e83ddf.jsonl:123`,
132
+ * a Legion pane answering a picture sent from the Agents page). The path is Claude Code saying
133
+ * where the bytes went, not a message anybody wrote, so the row is no part of the conversation.
134
+ */
135
+ const PICTURE_ATTACHMENT_ROW = /^\[Image: source: \/[^\]]+\]$/
136
+
137
+ /** Whether a row is that note of Claude Code's rather than anything a person wrote
138
+ * (`PICTURE_ATTACHMENT_ROW`), so the live view drops it as it drops a command's bookkeeping:
139
+ * drawn, it is a second user row for the one message the person sent (INFRA-1026). */
140
+ export function isPictureAttachmentRow(content: readonly ModContentBlock[]): boolean {
141
+ return PICTURE_ATTACHMENT_ROW.test(claudeRowText(content).trim())
142
+ }
143
+
144
+ /** The tags and the two fixed sentences the wrapper around a mid-turn message is made of
145
+ * (`claudeMidTurnMessage`): the opening tag, the announcement, the person's words, Claude
146
+ * Code's note to the model, the closing tag. */
147
+ const MID_TURN_OPEN = "<system-reminder>"
148
+ const MID_TURN_CLOSE = "</system-reminder>"
149
+ const MID_TURN_ANNOUNCEMENT = "The user sent a new message while you were working:"
150
+ const MID_TURN_NOTE = "This is how Claude Code surfaces messages the user sends mid-turn"
151
+
152
+ /**
153
+ * The words a person typed while a turn was running, read out of the wrapper Claude Code hands
154
+ * them in, or `undefined` where the text is not that wrapper.
155
+ *
156
+ * Measured on 2.1.310: a line typed at the terminal mid-turn reaches the mod as one row by the
157
+ * `delivery` door (`origin.kind: "composer"`, `message.type: "attachment"`,
158
+ * `name: "queued_command"`, `/legion/scratch/tmp/infra-1026-spike/logs/mod2.jsonl:120`) whose
159
+ * single text block is
160
+ *
161
+ * <system-reminder>
162
+ * The user sent a new message while you were working:
163
+ * TYPED-AT-TERMINAL-h1: say the word CEDAR when you can.
164
+ *
165
+ * This is how Claude Code surfaces messages the user sends mid-turn - within the running
166
+ * turn, often alongside the next tool result, rather than as a separate conversation turn.
167
+ * Address the message above as you continue this turn.
168
+ * </system-reminder>
169
+ *
170
+ * (`/legion/scratch/tmp/t-term-h1/config/projects/-legion-scratch-tmp-t-term-h1-cwd/b6411755-7568-4608-93a5-1db3c839e038.jsonl:70`).
171
+ * Drawn whole, that is tags and a note written to the model where the person wrote one sentence,
172
+ * which is the plumbing the view already drops for a command and for a picture's bytes.
173
+ *
174
+ * The words are found by the wrapper's shape and never by line number, since a person's message
175
+ * runs to as many lines, and as many blank ones, as they typed: everything between the
176
+ * announcement and the note is theirs. The note is looked for from the end, so a person who
177
+ * quotes its first words keeps their own. A wrapper holding no words answers `""`, which shows
178
+ * the reader nothing - right, because none of the framing is anybody's words.
179
+ *
180
+ * A `<system-reminder>` of any other kind answers `undefined` and the view draws it as it draws
181
+ * any other text row today, which this reader leaves alone: measured in the same spike, a peer
182
+ * session's message (`logs/mod3-q1q3q5.jsonl:223`) and the
183
+ * `[SYSTEM NOTIFICATION - NOT USER INPUT]` rows (`logs/mod3-q7-int.jsonl:13`) come by this same
184
+ * `delivery` door, and `<total_tokens>` by the `attachment` door the mod does not watch
185
+ * (`logs/mod2.jsonl:121`).
186
+ */
187
+ export function claudeMidTurnMessage(text: string): string | undefined {
188
+ const body = text.trim()
189
+ if (!body.startsWith(MID_TURN_OPEN) || !body.endsWith(MID_TURN_CLOSE)) return undefined
190
+ const inner = body.slice(MID_TURN_OPEN.length, body.length - MID_TURN_CLOSE.length).trimStart()
191
+ if (!inner.startsWith(MID_TURN_ANNOUNCEMENT)) return undefined
192
+ const note = inner.lastIndexOf(MID_TURN_NOTE)
193
+ if (note < MID_TURN_ANNOUNCEMENT.length) return undefined
194
+ return inner.slice(MID_TURN_ANNOUNCEMENT.length, note).trim()
195
+ }