@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.
- package/.claude-plugin/plugin.json +2 -2
- package/.omp-plugin/plugin.json +2 -2
- package/AGENTS.md +167 -42
- package/README.md +95 -177
- package/dist/dispatch.js +54 -6
- package/dist/envoy-channel.js +2413 -875
- package/dist/session-hook.js +54 -6
- package/hooks/claude-mod.d.ts +500 -0
- package/hooks/hooks.json +2 -1
- package/hooks/mod-protocol.ts +728 -0
- package/hooks/mod-writer.ts +506 -0
- package/hooks/mod.ts +716 -0
- package/package.json +2 -2
- package/scripts/smoke-clear-rebind.sh +1 -1
- package/scripts/smoke-session-title.sh +1 -1
- package/src/agent-stream-claude.ts +558 -0
- package/src/agent-stream-runner.ts +340 -0
- package/src/bounded-set.ts +54 -0
- package/src/channel-forwarder.ts +1 -1
- package/src/claude-rows.ts +195 -0
- package/src/envoy-channel-server.ts +394 -136
- package/src/envoy-off.ts +6 -1
- package/src/installed-build.ts +26 -0
- package/src/legion-inbox.ts +22 -11
- package/src/mod-link.ts +562 -0
- package/src/mod-session.ts +499 -0
- package/src/replay-state.ts +2 -18
- package/src/state-file.ts +39 -0
- package/src/user-turn-router.ts +496 -0
- package/src/user-turn-state.ts +134 -0
- package/scripts/smoke-channel.sh +0 -178
- package/src/claude-channel.ts +0 -705
|
@@ -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
|
+
}
|
package/src/channel-forwarder.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|