@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
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjawhar/claude-envoy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
|
-
"description": "Claude Code
|
|
5
|
+
"description": "Claude Code plugin for Legion's Envoy bus",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"files": [
|
|
8
8
|
".claude-plugin/**",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# smoke-clear-rebind.sh — real Claude Code `/clear` handoff smoke: after `/clear`, the
|
|
3
3
|
# registry entry of the new session id must list its own direct subject and never the old one.
|
|
4
|
-
# See README.md "Manual smoke" for prerequisites;
|
|
4
|
+
# See README.md "Manual smoke" for prerequisites; a manual smoke, never a CI step.
|
|
5
5
|
set -euo pipefail
|
|
6
6
|
|
|
7
7
|
readonly envoy_url="${ENVOY_URL:-http://127.0.0.1:9020}"
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
# with a real listener. It does not prove that Claude Code sends the field; that rests on Claude
|
|
21
21
|
# Code's own documentation.
|
|
22
22
|
#
|
|
23
|
-
# Like smoke-
|
|
23
|
+
# Like smoke-clear-rebind.sh, this is a manual smoke and never a CI step.
|
|
24
24
|
#
|
|
25
25
|
# bash packages/claude-envoy/scripts/smoke-session-title.sh
|
|
26
26
|
#
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's session, as the Agents page's live view reads it (INFRA-1026).
|
|
3
|
+
*
|
|
4
|
+
* The mod (`../hooks/mod.ts`) sends what happens in the session over the link's socket as
|
|
5
|
+
* `ModEvent`s; this turns each one into a call on the `AgentStreamPublisher` every Legion host
|
|
6
|
+
* shares (`@legion/pi-shared/agent-stream`), and builds the two frames that describe the session
|
|
7
|
+
* rather than its conversation: its model and thinking level, and its slash commands.
|
|
8
|
+
*
|
|
9
|
+
* It owns one piece of state the publisher cannot own for it. The publisher derives a message's
|
|
10
|
+
* ring slot from the message itself — an assistant message's slot is its timestamp — so every
|
|
11
|
+
* snapshot of one streaming step has to carry the one timestamp that step was first seen at, and
|
|
12
|
+
* two steps must never carry the same one. The allocation below does both: a step's timestamp is
|
|
13
|
+
* kept under `(turnId, index)` for as long as the session lasts, and each new one is at least one
|
|
14
|
+
* millisecond past the last handed out, so two steps that start inside one millisecond still land
|
|
15
|
+
* in two slots.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here opens a socket or knows about NATS: a subject is a string its caller passes in.
|
|
18
|
+
*/
|
|
19
|
+
import { type AgentStreamCommand, isTypableCommandName } from "@legion/contracts"
|
|
20
|
+
import {
|
|
21
|
+
type AgentStreamPublisher,
|
|
22
|
+
type AgentStreamSettingsState,
|
|
23
|
+
modelReference,
|
|
24
|
+
} from "@legion/pi-shared/agent-stream"
|
|
25
|
+
import type {
|
|
26
|
+
ModAppendMessage,
|
|
27
|
+
ModCommandInfo,
|
|
28
|
+
ModConfigRow,
|
|
29
|
+
ModContentBlock,
|
|
30
|
+
ModEffort,
|
|
31
|
+
ModEvent,
|
|
32
|
+
} from "../hooks/mod-protocol"
|
|
33
|
+
import {
|
|
34
|
+
claudeEchoedCommandLine,
|
|
35
|
+
claudeMidTurnMessage,
|
|
36
|
+
claudeRowText,
|
|
37
|
+
isCommandBookkeeping,
|
|
38
|
+
isPictureAttachmentRow,
|
|
39
|
+
} from "./claude-rows"
|
|
40
|
+
|
|
41
|
+
/** The provider every model of Claude Code's is named under, so a frame carries the same
|
|
42
|
+
* `provider/id` reference an Oh My Pi session's does (`anthropic/claude-sonnet-5`). */
|
|
43
|
+
export const CLAUDE_PROVIDER = "anthropic"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a reasoning part says when the model's thinking arrived with no text.
|
|
47
|
+
*
|
|
48
|
+
* Claude Code sends the thinking block's signature and no words unless the session asked for
|
|
49
|
+
* summarized thinking, so publishing the block as it stands would show the viewer an empty
|
|
50
|
+
* reasoning box. Measured on 2.1.310: every `thinking` block of a plain session carries
|
|
51
|
+
* `"thinking": ""`, and a session started with `--thinking-display summarized` carries the
|
|
52
|
+
* summary. A terminal session turns the summaries on with the `showThinkingSummaries` setting; a
|
|
53
|
+
* Legion pane gets them from the launch flag the daemon passes, which no reader of this view can
|
|
54
|
+
* change, so the setting is what the sentence names.
|
|
55
|
+
*
|
|
56
|
+
* One sentence, published once per step however many empty blocks the step holds: a browser test
|
|
57
|
+
* and the Dispatch documentation both quote it.
|
|
58
|
+
*/
|
|
59
|
+
export const CLAUDE_THINKING_HIDDEN =
|
|
60
|
+
"Claude Code hides what the model thought here: it sends the thinking text only when the terminal session turns on the showThinkingSummaries setting."
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The effort levels `/effort` offers, in the order Claude Code lists them (`ModEffort`). Claude
|
|
64
|
+
* Code reports a request's effort only on its `turn.step`, so this list is what a viewer can pick
|
|
65
|
+
* from and the step's own `effort` is which one is in force.
|
|
66
|
+
*/
|
|
67
|
+
export const CLAUDE_EFFORT_LEVELS = [
|
|
68
|
+
"low",
|
|
69
|
+
"medium",
|
|
70
|
+
"high",
|
|
71
|
+
"xhigh",
|
|
72
|
+
"max",
|
|
73
|
+
] as const satisfies readonly ModEffort[]
|
|
74
|
+
|
|
75
|
+
/** One content part as the publisher reads one (`conversationParts` in
|
|
76
|
+
* `@legion/pi-shared/agent-stream`): a `text`, a `thinking`, a `toolCall`, or any other `type`,
|
|
77
|
+
* which the publisher renders as the literal `[<type>]`. */
|
|
78
|
+
interface ClaudeStreamPart {
|
|
79
|
+
readonly type: string
|
|
80
|
+
readonly text?: string
|
|
81
|
+
readonly thinking?: string
|
|
82
|
+
readonly id?: string
|
|
83
|
+
readonly name?: string
|
|
84
|
+
readonly arguments?: unknown
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One message as the publisher reads one (`readMessage`): `role` and `timestamp` are what it
|
|
88
|
+
* needs to keep the message at all, and the rest is what each role carries. */
|
|
89
|
+
interface ClaudeStreamMessage {
|
|
90
|
+
readonly role: "assistant" | "user" | "toolResult"
|
|
91
|
+
readonly timestamp: number
|
|
92
|
+
readonly content: string | readonly ClaudeStreamPart[]
|
|
93
|
+
readonly provider?: string
|
|
94
|
+
readonly model?: string
|
|
95
|
+
readonly tag?: string
|
|
96
|
+
readonly toolCallId?: string
|
|
97
|
+
readonly toolName?: string
|
|
98
|
+
readonly isError?: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The effort the last `turn.step` of the session ran under, and the model it ran on. Claude Code
|
|
102
|
+
* names no effort for a model that has none, so the absent `effort` is the fact that there is no
|
|
103
|
+
* thinking level to show. */
|
|
104
|
+
interface ObservedEffort {
|
|
105
|
+
readonly model: string
|
|
106
|
+
readonly effort: ModEffort | undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ClaudeAgentStreamDeps {
|
|
110
|
+
readonly publisher: AgentStreamPublisher
|
|
111
|
+
/** The clock a step's timestamp is allocated from, and every other message is stamped with. */
|
|
112
|
+
readonly now: () => number
|
|
113
|
+
/**
|
|
114
|
+
* Whether this session is a Legion pane rather than a terminal one. A pane is seeded with one
|
|
115
|
+
* command before anything else happens in it (`PANE_SEEDED_COMMAND`), which is the daemon
|
|
116
|
+
* naming the pane rather than a person writing to the agent, so the view draws neither the
|
|
117
|
+
* prompt that carried it nor the echo Claude Code wrote of it.
|
|
118
|
+
*/
|
|
119
|
+
readonly isPane: boolean
|
|
120
|
+
/**
|
|
121
|
+
* The Dispatch message a row of the conversation is, where it is one
|
|
122
|
+
* (`./user-turn-state`). A person's own message would otherwise reach the page twice - as the
|
|
123
|
+
* message Dispatch stored and as the row the session took it as - and the row's tag is how the
|
|
124
|
+
* page knows the two are one message (`conversation.ts` in the Agents page). A row of the
|
|
125
|
+
* session's own keeps its own id as its tag, as every row did before.
|
|
126
|
+
*/
|
|
127
|
+
readonly tagFor?: (row: string) => string | undefined
|
|
128
|
+
/**
|
|
129
|
+
* Whether a row of the `command` door is the echo row a `$.command.run` of this session's
|
|
130
|
+
* claimed (`./mod-session`, `runsCommandRow`), asked by the row's own id. Claude Code echoes
|
|
131
|
+
* the command it ran rather than the one that was asked for - `/cost` ran as `/usage`, and
|
|
132
|
+
* `/model anthropic/haiku` as `/model haiku`, measured live on 2.1.310 - so the echo row
|
|
133
|
+
* drawn is a second message the person never wrote, beside Dispatch's stored copy of the one
|
|
134
|
+
* they did send (INFRA-1026). Absent where no run of this session's can exist, and every
|
|
135
|
+
* command row is a person's own.
|
|
136
|
+
*/
|
|
137
|
+
readonly runsCommandRow?: (row: string) => boolean
|
|
138
|
+
/**
|
|
139
|
+
* Whether a row of the `prompt` door is the row Claude Code wrote for this session's prompt
|
|
140
|
+
* at the messages a turn never read (`./mod-session`, `runsUnreadPrompt`), asked by the row's
|
|
141
|
+
* own id. That prompt quotes rows this view has already drawn, so drawn it shows a viewer
|
|
142
|
+
* each of those messages twice (INFRA-1026). A card submitted to an idle session is the other
|
|
143
|
+
* prompt this plugin submits without `asUser`, and it is drawn: the card reaches the
|
|
144
|
+
* conversation nowhere else. Absent where no prompt of this session's can exist, and every
|
|
145
|
+
* prompt row is a person's own.
|
|
146
|
+
*/
|
|
147
|
+
readonly runsUnreadPrompt?: (row: string) => boolean
|
|
148
|
+
/**
|
|
149
|
+
* Whether a row is one this session appended into the conversation (`./mod-session`,
|
|
150
|
+
* `appendedRow`), asked by the row's own id: the card a running turn is handed, and a
|
|
151
|
+
* person's own message handed to one. Claude Code marks every appended row `isMeta`, which
|
|
152
|
+
* this view draws nothing of, so this is what says which `isMeta` row is a message of the
|
|
153
|
+
* conversation rather than the session talking to the model. Absent where this session
|
|
154
|
+
* appends nothing, and no `isMeta` row is drawn.
|
|
155
|
+
*/
|
|
156
|
+
readonly appendedRow?: (row: string) => boolean
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ClaudeAgentStream {
|
|
160
|
+
/** Record what `event` says on `subject`, which is where the publisher sends its frames. An
|
|
161
|
+
* event that is no message of the conversation records nothing. */
|
|
162
|
+
readonly record: (subject: string, event: ModEvent) => void
|
|
163
|
+
/**
|
|
164
|
+
* The settings frame's state for the session's `model` (`$.session.model()`'s answer) and the
|
|
165
|
+
* `/config` rows `$.config.list()` answered with. Unreported until the rows have been read:
|
|
166
|
+
* they are the only list of the models a pick may name.
|
|
167
|
+
*/
|
|
168
|
+
readonly settings: (
|
|
169
|
+
model: string | undefined,
|
|
170
|
+
rows: readonly ModConfigRow[] | undefined,
|
|
171
|
+
) => AgentStreamSettingsState
|
|
172
|
+
/**
|
|
173
|
+
* The effort a person's pick put in force. Claude Code reports an effort only on a request's
|
|
174
|
+
* own `turn.step`, so until the session's next turn its report is the level before the pick;
|
|
175
|
+
* the pick itself is what set the effort the mod puts on every later request that names an
|
|
176
|
+
* effort of its own (`../hooks/mod`, `turn.step`), so from the moment it lands the settings
|
|
177
|
+
* frame says so and keeps saying so until another pick changes it or a step reports that the
|
|
178
|
+
* request ran at another level, or at none - which is what a model with no effort to set does.
|
|
179
|
+
*/
|
|
180
|
+
readonly pickedEffort: (effort: ModEffort) => void
|
|
181
|
+
/** Forget the conversation's timestamps and the effort read off it: the session id moved, and
|
|
182
|
+
* the publisher was reset with it. */
|
|
183
|
+
readonly reset: () => void
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One block of a row as a part the publisher reads. A kind it does not know crosses as its type
|
|
187
|
+
* alone, which the viewer shows as `[image]` and the like. */
|
|
188
|
+
function partOf(block: ModContentBlock): ClaudeStreamPart {
|
|
189
|
+
const text = block["text"]
|
|
190
|
+
if (block.type === "text" && typeof text === "string") {
|
|
191
|
+
// A line a person typed while a turn was running reaches the view inside framing Claude Code
|
|
192
|
+
// wrote for the model, and the view shows the person's words alone (`./claude-rows`,
|
|
193
|
+
// `claudeMidTurnMessage`), as it shows a command without the tags it was recorded in. Read
|
|
194
|
+
// here rather than in `recordAppend` so that a row carrying this block beside others keeps
|
|
195
|
+
// every other block exactly as it crosses today.
|
|
196
|
+
return { type: "text", text: claudeMidTurnMessage(text) ?? text }
|
|
197
|
+
}
|
|
198
|
+
const thinking = block["thinking"]
|
|
199
|
+
if (block.type === "thinking" && typeof thinking === "string") {
|
|
200
|
+
return { type: "thinking", thinking }
|
|
201
|
+
}
|
|
202
|
+
const id = block["id"]
|
|
203
|
+
if (block.type === "tool_use" && typeof id === "string") {
|
|
204
|
+
const name = block["name"]
|
|
205
|
+
return {
|
|
206
|
+
arguments: block["input"],
|
|
207
|
+
id,
|
|
208
|
+
name: typeof name === "string" ? name : "tool",
|
|
209
|
+
type: "toolCall",
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { type: block.type }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whether a part puts anything in front of a reader: text or thinking with characters in it, a
|
|
216
|
+
* tool call, or a kind the publisher names in brackets (`[image]`). An empty text part is the
|
|
217
|
+
* one that shows nothing, since `conversationParts` keeps it as the empty string it is. */
|
|
218
|
+
function shows(part: ClaudeStreamPart): boolean {
|
|
219
|
+
if (part.type === "text") return part.text !== ""
|
|
220
|
+
if (part.type === "thinking") return part.thinking !== ""
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Under which role a row of the conversation is shown. Claude Code names the role on the rows
|
|
225
|
+
* that have one and leaves it off the rest, whose `type` says it instead; anything else is the
|
|
226
|
+
* session talking to the model, which the view shows on the person's side. */
|
|
227
|
+
function roleOf(message: ModAppendMessage): "assistant" | "user" {
|
|
228
|
+
if (message.role !== undefined) return message.role
|
|
229
|
+
return message.type === "assistant" ? "assistant" : "user"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The command the daemon's bridge seeds a fresh Legion pane with before anything else
|
|
233
|
+
* (`packages/daemon/internal/claudebridge/bridge.go:210-213`), which names the pane. */
|
|
234
|
+
const PANE_SEEDED_COMMAND = "/rename"
|
|
235
|
+
|
|
236
|
+
/** Whether a line is the command a pane is seeded with, with or without the title it names. */
|
|
237
|
+
function isPaneSeededRename(line: string): boolean {
|
|
238
|
+
return line === PANE_SEEDED_COMMAND || line.startsWith(`${PANE_SEEDED_COMMAND} `)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A streamed tool call's arguments as the publisher should render them. They arrive as JSON text
|
|
243
|
+
* a piece at a time, so a snapshot taken mid-block does not parse: the parsed value is handed
|
|
244
|
+
* over once there is one, and until then the text as it arrived, which is what the viewer shows
|
|
245
|
+
* while the call is still being written.
|
|
246
|
+
*/
|
|
247
|
+
function callArguments(args: string): unknown {
|
|
248
|
+
if (args === "") return undefined
|
|
249
|
+
try {
|
|
250
|
+
return JSON.parse(args)
|
|
251
|
+
} catch {
|
|
252
|
+
return args
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The models a person's pick may send back, from the `/config` rows.
|
|
258
|
+
*
|
|
259
|
+
* The `model` row's `options` are carried and its `value` is not. Measured on 2.1.310: the value
|
|
260
|
+
* is the model's own name while the session names one (`claude-sonnet-4-5`), and the row's label
|
|
261
|
+
* for the default while it does not (`Default (recommended)`), which is no name any pick can
|
|
262
|
+
* send. The options are the row's own choices, so every one of them is a value `/config` takes —
|
|
263
|
+
* a shorter list of picks that all run beats a longer one holding a pick that cannot.
|
|
264
|
+
*/
|
|
265
|
+
function listedModels(rows: readonly ModConfigRow[]): string[] {
|
|
266
|
+
const row = rows.find((candidate) => candidate.key === "model")
|
|
267
|
+
const options = row?.options ?? []
|
|
268
|
+
// A nameless option is no reference a pick could send back, and `modelReference` is the rule
|
|
269
|
+
// for what a `provider/id` is.
|
|
270
|
+
return options.flatMap((option) => modelReference(CLAUDE_PROVIDER, option) ?? [])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Claude Code's slash commands as the viewer offers them: the name without its slash, and the
|
|
275
|
+
* source mapped onto the four the frame knows. A name a person could not type is dropped
|
|
276
|
+
* (`isTypableCommandName`), and nothing is marked `terminalOnly`: Claude Code says of no command
|
|
277
|
+
* whether running it opens a dialog, so marking any would be a guess.
|
|
278
|
+
*/
|
|
279
|
+
export function claudeAgentStreamCommands(
|
|
280
|
+
commands: readonly ModCommandInfo[],
|
|
281
|
+
): AgentStreamCommand[] {
|
|
282
|
+
const listed: AgentStreamCommand[] = []
|
|
283
|
+
for (const command of commands) {
|
|
284
|
+
const name = command.name.startsWith("/") ? command.name.slice(1) : command.name
|
|
285
|
+
if (!isTypableCommandName(name)) continue
|
|
286
|
+
listed.push({
|
|
287
|
+
name,
|
|
288
|
+
source:
|
|
289
|
+
command.source === "builtin"
|
|
290
|
+
? "builtin"
|
|
291
|
+
: command.source === "plugin"
|
|
292
|
+
? "extension"
|
|
293
|
+
: "prompt",
|
|
294
|
+
...(command.description === "" ? {} : { description: command.description }),
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
return listed
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function createClaudeAgentStream(deps: ClaudeAgentStreamDeps): ClaudeAgentStream {
|
|
301
|
+
const { isPane, publisher, now } = deps
|
|
302
|
+
/** The timestamp each `(turnId, index)` step was first seen at, which is its ring slot. */
|
|
303
|
+
const stepAt = new Map<string, number>()
|
|
304
|
+
/** The last timestamp handed to a step, so the next one is strictly past it. */
|
|
305
|
+
let allocated = 0
|
|
306
|
+
let observed: ObservedEffort | undefined
|
|
307
|
+
/** The effort the last pick put in force, which is the effort the mod puts on every later
|
|
308
|
+
* request that names an effort of its own, until a step disagrees or another pick changes it. */
|
|
309
|
+
let picked: ModEffort | undefined
|
|
310
|
+
|
|
311
|
+
/** The timestamp this step keeps for every snapshot of it. */
|
|
312
|
+
const timestampFor = (turnId: string, index: number): number => {
|
|
313
|
+
const key = `${turnId}\u0000${index}`
|
|
314
|
+
const kept = stepAt.get(key)
|
|
315
|
+
if (kept !== undefined) return kept
|
|
316
|
+
const at = Math.max(now(), allocated + 1)
|
|
317
|
+
allocated = at
|
|
318
|
+
stepAt.set(key, at)
|
|
319
|
+
return at
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const record = (
|
|
323
|
+
subject: string,
|
|
324
|
+
message: ClaudeStreamMessage,
|
|
325
|
+
streaming: boolean,
|
|
326
|
+
/** The Dispatch message this row delivered, where it is one. */
|
|
327
|
+
dispatchMessageId?: string,
|
|
328
|
+
): void => {
|
|
329
|
+
publisher.record(subject, message, streaming, dispatchMessageId)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** One row of the conversation as the viewer draws it: never streaming, since a row arrives
|
|
333
|
+
* whole, keyed into its own slot by the row's id, and tagged with the Dispatch message it
|
|
334
|
+
* delivered where it is one. */
|
|
335
|
+
const recordRow = (
|
|
336
|
+
subject: string,
|
|
337
|
+
uuid: string,
|
|
338
|
+
role: "assistant" | "user",
|
|
339
|
+
content: readonly ClaudeStreamPart[],
|
|
340
|
+
): void => {
|
|
341
|
+
record(subject, { content, role, tag: uuid, timestamp: now() }, false, deps.tagFor?.(uuid))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** One `turn.step` snapshot as the assistant message of that step. */
|
|
345
|
+
const recordStep = (subject: string, event: Extract<ModEvent, { kind: "step" }>): void => {
|
|
346
|
+
// The step reports the effort its own request carried, which is the truth about that
|
|
347
|
+
// request: a pick stands only while the steps agree with it. A step whose own effort is
|
|
348
|
+
// another level, or none at all, ends the pick and leaves the step itself to answer -
|
|
349
|
+
// measured live on 2.1.310, a pick of `low` with `claude-haiku-4-5` in force left the header
|
|
350
|
+
// reading `low` while the session's next request carried no effort at all (INFRA-1026).
|
|
351
|
+
if (event.effort !== picked) picked = undefined
|
|
352
|
+
observed = { model: event.model, effort: event.effort }
|
|
353
|
+
const content: ClaudeStreamPart[] = []
|
|
354
|
+
let hiddenThinkingShown = false
|
|
355
|
+
for (const part of event.parts) {
|
|
356
|
+
if (part.type === "text") {
|
|
357
|
+
content.push({ text: part.text, type: "text" })
|
|
358
|
+
} else if (part.type === "reasoning") {
|
|
359
|
+
if (part.text !== "") {
|
|
360
|
+
content.push({ thinking: part.text, type: "thinking" })
|
|
361
|
+
} else if (!hiddenThinkingShown) {
|
|
362
|
+
hiddenThinkingShown = true
|
|
363
|
+
content.push({ thinking: CLAUDE_THINKING_HIDDEN, type: "thinking" })
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
content.push({
|
|
367
|
+
arguments: callArguments(part.args),
|
|
368
|
+
id: part.id,
|
|
369
|
+
name: part.name,
|
|
370
|
+
type: "toolCall",
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
record(
|
|
375
|
+
subject,
|
|
376
|
+
{
|
|
377
|
+
content,
|
|
378
|
+
model: event.model,
|
|
379
|
+
provider: CLAUDE_PROVIDER,
|
|
380
|
+
role: "assistant",
|
|
381
|
+
timestamp: timestampFor(event.turnId, event.index),
|
|
382
|
+
},
|
|
383
|
+
!event.done,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** One row of the conversation, by the door it came in. */
|
|
388
|
+
const recordAppend = (subject: string, event: Extract<ModEvent, { kind: "append" }>): void => {
|
|
389
|
+
const { door, message, origin, uuid } = event
|
|
390
|
+
// The step that produced the answer already carried it, and the publisher would key this row
|
|
391
|
+
// into a slot of its own: publishing both shows the viewer the same answer twice.
|
|
392
|
+
if (door === "response") return
|
|
393
|
+
// A row Claude Code marks `isMeta` is one the person does not see as typed
|
|
394
|
+
// (`ModAppendMessage.isMeta`), so drawing it attributes to a person something they never
|
|
395
|
+
// wrote - unless this session appended the row itself, which is the one `isMeta` row the
|
|
396
|
+
// page must show. Measured on live Claude Code 2.1.310 (INFRA-1026):
|
|
397
|
+
//
|
|
398
|
+
// - a row `$.session.append` writes carries `isMeta: true` (the engine sets it,
|
|
399
|
+
// `hooks/claude-mod.d.ts`, `SessionAppendArgs`): the card this server delivers into a
|
|
400
|
+
// running turn is such a row, and it is all such a card ever becomes, so dropped it
|
|
401
|
+
// leaves the viewer an answer to a message they never saw. It reaches this mapper
|
|
402
|
+
// because the mod reports the rows it appends itself - Claude Code does not re-enter a
|
|
403
|
+
// mod's own `session.append` hook for them (`hooks/mod.ts`) - and `appendedRow` names
|
|
404
|
+
// it by the uuid the session claimed from the stamp the mod put on that report
|
|
405
|
+
// (`ModEvent`'s `wrote`, `./mod-session`). A rule
|
|
406
|
+
// keeping every `isMeta` row of a `plugin` origin cannot stand in for the claim: the
|
|
407
|
+
// engine gives another installed mod's appended rows `{"kind":"plugin","name":"<that
|
|
408
|
+
// mod>"}` (`/legion/scratch/tmp/infra-1026-spike/logs`, where `spike-mod2` and
|
|
409
|
+
// `spike-mod3p` name themselves), so it drew that mod's model-facing context as the
|
|
410
|
+
// person's own words (INFRA-1026 review, round ten);
|
|
411
|
+
// - a `/rename` typed at a terminal left, behind its echo and its
|
|
412
|
+
// `<local-command-stdout>` row, a `type: "user"` row carrying `isMeta: true` and
|
|
413
|
+
// `origin.kind` `engine` whose whole text is `<system-reminder> The user named this
|
|
414
|
+
// session "…". …</system-reminder>`, which the live view drew as the person's own
|
|
415
|
+
// message - model-facing XML under their name;
|
|
416
|
+
// - the `<local-command-caveat>` row of a command run carries `isMeta: true`, and no
|
|
417
|
+
// session appended it: this rule is what the view draws nothing of it by, and the
|
|
418
|
+
// bookkeeping rule of the `command` door below covers the same row where a build
|
|
419
|
+
// leaves the mark off (`./claude-rows`, `isCommandBookkeeping`);
|
|
420
|
+
// - a line the person types while a turn runs is not marked at all: it arrives
|
|
421
|
+
// `origin composer`, `type: "attachment"`, `name: "queued_command"` and no `isMeta`,
|
|
422
|
+
// so it reaches the reader this rule leaves untouched (`./claude-rows`,
|
|
423
|
+
// `claudeMidTurnMessage`).
|
|
424
|
+
if (message.isMeta === true && deps.appendedRow?.(uuid) !== true) return
|
|
425
|
+
// Claude Code's own note of where it put a picture's bytes, which it writes beside the
|
|
426
|
+
// message that carried the picture (`./claude-rows`, `isPictureAttachmentRow`). Nobody wrote
|
|
427
|
+
// it, and published it draws a second user row for the one message the person sent: measured
|
|
428
|
+
// on 2.1.310 in a Legion pane, where a picture sent from the Agents page drew the person's
|
|
429
|
+
// row and this one beside it (INFRA-1026). Checked for every door, since the row is a
|
|
430
|
+
// companion of the prompt that carried the picture and no door of its own.
|
|
431
|
+
if (isPictureAttachmentRow(message.content)) return
|
|
432
|
+
if (door === "prompt") {
|
|
433
|
+
// The prompt at the rows a turn never read is drawn nowhere: it quotes rows this view has
|
|
434
|
+
// already drawn and would show a viewer each of those messages twice
|
|
435
|
+
// (`../hooks/mod-protocol`, `unreadRowPrompt`). The session claims the row Claude Code
|
|
436
|
+
// wrote for it by uuid, as a command run claims its echo row (`runsUnreadPrompt`). Read
|
|
437
|
+
// instead off the prompt's
|
|
438
|
+
// wording, the rule dropped any card carrying those words - a review of this code, or a
|
|
439
|
+
// message quoting one - which reaches the model and is drawn for nobody (INFRA-1026
|
|
440
|
+
// review, round ten). Every other prompt row is drawn: a person's own, and the card an
|
|
441
|
+
// idle session is handed, which is that card and reaches the conversation nowhere else.
|
|
442
|
+
if (deps.runsUnreadPrompt?.(uuid) === true) return
|
|
443
|
+
// The command a pane is seeded with arrives by this door, written to the session's input
|
|
444
|
+
// by the daemon's bridge before anything else happens in the pane: it names the pane, and
|
|
445
|
+
// no person sent it.
|
|
446
|
+
if (isPane && isPaneSeededRename(claudeRowText(message.content))) return
|
|
447
|
+
// Each block crosses as the part it is, as it does by every other door: the person's text
|
|
448
|
+
// stays a part of its own and a picture becomes the `[image]` part the publisher names it
|
|
449
|
+
// (`conversationParts` in `@legion/pi-shared/agent-stream`). Flattened into one
|
|
450
|
+
// "Look at this\n[image]" part the row says what no stored message says, and the Agents
|
|
451
|
+
// page shows the person's message twice (INFRA-1026).
|
|
452
|
+
const content = message.content.map(partOf)
|
|
453
|
+
if (!content.some(shows)) return
|
|
454
|
+
recordRow(subject, uuid, "user", content)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
if (door === "tool-result") {
|
|
458
|
+
for (const block of message.content) {
|
|
459
|
+
const toolCallId = block["tool_use_id"]
|
|
460
|
+
// A tool result's slot is its call's id; a block naming none joins no call, and the
|
|
461
|
+
// publisher would drop it.
|
|
462
|
+
if (typeof toolCallId !== "string") continue
|
|
463
|
+
record(
|
|
464
|
+
subject,
|
|
465
|
+
{
|
|
466
|
+
content: claudeRowText(block["content"]),
|
|
467
|
+
isError: block["is_error"] === true,
|
|
468
|
+
role: "toolResult",
|
|
469
|
+
timestamp: now(),
|
|
470
|
+
toolCallId,
|
|
471
|
+
...(origin.tool === undefined ? {} : { toolName: origin.tool }),
|
|
472
|
+
},
|
|
473
|
+
false,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (door === "command") {
|
|
479
|
+
// A command a person ran writes three rows by this door and only one of them is a message
|
|
480
|
+
// (`./claude-rows`, `COMMAND_BOOKKEEPING_TAGS`): the echo of what was typed, which the
|
|
481
|
+
// view draws as the plain command line the person would have typed - `/clear`,
|
|
482
|
+
// `/effort low` - rather than as the tags Claude Code wrote it in. A row that is neither
|
|
483
|
+
// the echo nor the bookkeeping goes the way every other row does, below.
|
|
484
|
+
const line = claudeEchoedCommandLine(message.content)
|
|
485
|
+
if (line !== undefined) {
|
|
486
|
+
if (isPane && isPaneSeededRename(line)) return
|
|
487
|
+
// The echo of a command this session ran for a person is not drawn at all: it is the
|
|
488
|
+
// command Claude Code ran rather than the one the person sent, and their own line
|
|
489
|
+
// reaches the page as the message Dispatch stored, which the Agents page shows beside
|
|
490
|
+
// this stream. Drawn, a `/cost` the person sent put `/cost` and `/usage` in front of
|
|
491
|
+
// them as two messages of theirs, measured live on 2.1.310 (INFRA-1026).
|
|
492
|
+
if (deps.runsCommandRow?.(uuid) === true) return
|
|
493
|
+
recordRow(subject, uuid, "user", [{ text: line, type: "text" }])
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
if (isCommandBookkeeping(message.content)) return
|
|
497
|
+
}
|
|
498
|
+
const content = message.content.map(partOf)
|
|
499
|
+
// A row that shows a reader nothing is the session's bookkeeping, not its conversation, so
|
|
500
|
+
// nothing is recorded for it: the publisher would key it into a slot of its own and the view
|
|
501
|
+
// would draw an empty bubble. Claude Code's engine writes one such row per turn, by the
|
|
502
|
+
// `notice` door (`type: "system"`, `name: "turn_duration"`, no content blocks, measured on
|
|
503
|
+
// 2.1.310), which is one empty bubble per turn of the conversation. A tool result with no
|
|
504
|
+
// output is a result rather than a message and left alone: it went out above, by its call's
|
|
505
|
+
// id.
|
|
506
|
+
if (!content.some(shows)) return
|
|
507
|
+
recordRow(subject, uuid, roleOf(message), content)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
record(subject: string, event: ModEvent): void {
|
|
512
|
+
if (event.kind === "step") recordStep(subject, event)
|
|
513
|
+
else if (event.kind === "append") recordAppend(subject, event)
|
|
514
|
+
// Every other event says something about the session rather than its conversation: its id,
|
|
515
|
+
// its model, a turn's boundaries, a request going out. The server reads those; the view
|
|
516
|
+
// shows no message for them.
|
|
517
|
+
},
|
|
518
|
+
pickedEffort(effort: ModEffort): void {
|
|
519
|
+
picked = effort
|
|
520
|
+
},
|
|
521
|
+
settings(
|
|
522
|
+
model: string | undefined,
|
|
523
|
+
rows: readonly ModConfigRow[] | undefined,
|
|
524
|
+
): AgentStreamSettingsState {
|
|
525
|
+
if (rows === undefined) return { reported: false }
|
|
526
|
+
const reference = modelReference(CLAUDE_PROVIDER, model)
|
|
527
|
+
return {
|
|
528
|
+
models: listedModels(rows),
|
|
529
|
+
reported: true,
|
|
530
|
+
// Claude Code reports the effort on each request's `turn.step` and nowhere else, so a
|
|
531
|
+
// pick is the only other thing that knows one: it set the effort the mod puts on every
|
|
532
|
+
// later request that names an effort of its own, so from the moment it lands it is the
|
|
533
|
+
// level in force, and it survives a model pick whose later step reports that same
|
|
534
|
+
// effort. A step that reports another level, or none at all - a model Claude Code names
|
|
535
|
+
// no effort for takes none whatever the pick set - ends the pick and answers for itself
|
|
536
|
+
// (`recordStep`). Before this session's first request and before any pick there is a
|
|
537
|
+
// selector and no level to show; a step that named no effort is a model with no thinking
|
|
538
|
+
// to adjust. A step of another model is no answer about this one.
|
|
539
|
+
thinking:
|
|
540
|
+
picked !== undefined
|
|
541
|
+
? { level: String(picked), levels: CLAUDE_EFFORT_LEVELS }
|
|
542
|
+
: observed !== undefined && observed.model === model
|
|
543
|
+
? observed.effort === undefined
|
|
544
|
+
? null
|
|
545
|
+
: { level: String(observed.effort), levels: CLAUDE_EFFORT_LEVELS }
|
|
546
|
+
: { level: null, levels: CLAUDE_EFFORT_LEVELS },
|
|
547
|
+
// Absent when the session has no model selected, which is `modelReference`'s own answer
|
|
548
|
+
// for a name that is empty (`AgentStreamSettingsReported.model`).
|
|
549
|
+
...(reference === undefined ? {} : { model: reference }),
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
reset(): void {
|
|
553
|
+
stepAt.clear()
|
|
554
|
+
observed = undefined
|
|
555
|
+
picked = undefined
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
}
|