@sjawhar/claude-envoy 1.18.0 → 1.20.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/skills/agent-secrets/SKILL.md +55 -4
- 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,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a message Envoy hands this session becomes, and what points the model at it.
|
|
3
|
+
*
|
|
4
|
+
* The mod's other half is transport: a socket, a queue of events and a poll loop (`./mod.ts`,
|
|
5
|
+
* `createLink`). This half is the rule those two carry between them, and it lives here because
|
|
6
|
+
* it is the rule three review rounds have been about (INFRA-1026 review, rounds nine to
|
|
7
|
+
* eleven): a message is a row of the turn running or the prompt of a new turn, a row no request
|
|
8
|
+
* read is quoted by one prompt when the turn ends, and a write is made once however many times
|
|
9
|
+
* Envoy sends the event. Deciding it needs the turn's events and this plugin's writes in one
|
|
10
|
+
* order, which is the mod and nowhere else.
|
|
11
|
+
*
|
|
12
|
+
* Every write is handed the session it writes into, by the request that asked for it, and this
|
|
13
|
+
* half holds everything else itself. The four closures the transport half passes in are the
|
|
14
|
+
* rest: the host for the one write no request asks for (`pointAt`), the event queue, the clock
|
|
15
|
+
* and the session id.
|
|
16
|
+
*/
|
|
17
|
+
import type {
|
|
18
|
+
PromptSubmitArgs,
|
|
19
|
+
PromptSubmitResult,
|
|
20
|
+
SessionAppendArgs,
|
|
21
|
+
SessionAppendResult,
|
|
22
|
+
} from "claude-code"
|
|
23
|
+
import {
|
|
24
|
+
MOD_PLUGIN_NAME,
|
|
25
|
+
type ModCallArguments,
|
|
26
|
+
type ModCallResults,
|
|
27
|
+
type ModContentBlock,
|
|
28
|
+
type ModEvent,
|
|
29
|
+
type ModUnreadMessage,
|
|
30
|
+
unreadRowPrompt,
|
|
31
|
+
} from "./mod-protocol"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long the writer watches for the row Claude Code writes for a prompt it submitted, to
|
|
35
|
+
* stamp that row for the server (`claimRow`), counted from the answer of the `$.prompt.submit`
|
|
36
|
+
* that carried it.
|
|
37
|
+
*
|
|
38
|
+
* The row is there before the call answers: in all six `$.prompt.submit` calls recorded on
|
|
39
|
+
* 2.1.310 the prompt's own `prompt`-door row landed 15 ms to 26 ms before the call resolved
|
|
40
|
+
* (`/legion/scratch/tmp/infra-1026-spike/logs/mod2.jsonl:144-145`,
|
|
41
|
+
* `logs/mod3-q2-310.jsonl:51-52,86-87`, `logs/mod3-q1q3q5.jsonl:236-239`). So this is a bound
|
|
42
|
+
* on a watch that is normally over before it starts, at about eighty times the largest gap.
|
|
43
|
+
* Counted from the call instead it would bound something else, since that call waits for a
|
|
44
|
+
* turn already running and such waits ran to 12.4 s (`submitPrompt`).
|
|
45
|
+
*/
|
|
46
|
+
const PROMPT_ROW_MS = 2_000
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How long a prompt the engine took has to start its turn before the writer stops holding the
|
|
50
|
+
* rows handed over meanwhile for it.
|
|
51
|
+
*
|
|
52
|
+
* `turn.start` followed the submit's resolution by 8 ms to 11 ms in the same six submits, so a
|
|
53
|
+
* prompt that has started no turn by now starts none: its rows are pointed at rather than left
|
|
54
|
+
* waiting for a `turn.complete` that never comes (INFRA-1026 review, round three, repro
|
|
55
|
+
* `R-C`).
|
|
56
|
+
*/
|
|
57
|
+
const TURN_START_MS = 2_000
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The most Envoy events the writer remembers the row of, in `$.store`.
|
|
61
|
+
*
|
|
62
|
+
* Each record is a key of some seventy bytes and a small object, so the whole of it is about
|
|
63
|
+
* 30 KiB, against the 4 MiB of compact JSON `set` refuses past. The store is one file for every
|
|
64
|
+
* session under one `CLAUDE_CONFIG_DIR`, so this bound is shared by all of them, and the
|
|
65
|
+
* eviction pass (`remember`) drops the oldest record whichever session wrote it. The bound is
|
|
66
|
+
* what keeps that store off the wall and off the cost of a big store: measured on 2.1.310, a
|
|
67
|
+
* 3.9 MiB store cost `get` 7–20 ms, `set` 44 ms and `keys()` up to 16 ms, against about 1 ms
|
|
68
|
+
* each on an empty one (`/legion/scratch/tmp/infra-1026-store/README.md`).
|
|
69
|
+
*/
|
|
70
|
+
const CARD_RECORD_MAX = 256
|
|
71
|
+
|
|
72
|
+
/** What every key of that record starts with, so the eviction pass reads this plugin's own keys
|
|
73
|
+
* and leaves anything else it may store alone. */
|
|
74
|
+
const CARD_KEY_PREFIX = "message:"
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What the writer calls on the session: the two writes a message can be, and this plugin's own
|
|
78
|
+
* store. The transport half holds the rest of `$` and builds each of these as a closure of its
|
|
79
|
+
* own, since `$` may not leave the hook it was given to (`./mod.ts`, `ModHost`).
|
|
80
|
+
*/
|
|
81
|
+
export interface WriterHost {
|
|
82
|
+
readonly append: (args: SessionAppendArgs) => Promise<SessionAppendResult>
|
|
83
|
+
readonly submit: (args: PromptSubmitArgs) => Promise<PromptSubmitResult>
|
|
84
|
+
/** `$.store.get(key)`: this plugin's own key-value store, kept between sessions. */
|
|
85
|
+
readonly storeGet: (key: string) => Promise<unknown>
|
|
86
|
+
readonly storeSet: (key: string, value: unknown) => Promise<void>
|
|
87
|
+
readonly storeDelete: (key: string) => Promise<void>
|
|
88
|
+
/** `$.store.keys()`, in insertion order (measured on 2.1.310). */
|
|
89
|
+
readonly storeKeys: () => Promise<string[]>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One row this mod wrote into a turn, watched until a request reads it or the turn ends. */
|
|
93
|
+
interface WatchedRow {
|
|
94
|
+
/** The turn the row was stored into, and undefined for a row stored while the prompt that
|
|
95
|
+
* starts that turn was still in flight: the turn adopts it when it reports itself. */
|
|
96
|
+
turnId?: string
|
|
97
|
+
/**
|
|
98
|
+
* The requests this session had made when the row was stored: one made after it is the
|
|
99
|
+
* request that read it.
|
|
100
|
+
*
|
|
101
|
+
* Counted once the append has resolved rather than before it: a request that went out while
|
|
102
|
+
* the row was being stored cannot be proven to carry it, and a row quoted though the model
|
|
103
|
+
* had read it costs a repetition, where a row counted as read that never was costs the
|
|
104
|
+
* message (INFRA-1026 review, repro `R-B`).
|
|
105
|
+
*/
|
|
106
|
+
readonly requests: number
|
|
107
|
+
readonly message: ModUnreadMessage
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One prompt this mod submitted whose row has not arrived: the text the row carries, and what
|
|
111
|
+
* that row is to the server, which the row's own hook stamps it with (`claimRow`). */
|
|
112
|
+
interface AwaitedRow {
|
|
113
|
+
readonly text: string
|
|
114
|
+
readonly wrote: "message" | "pointer"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What this mod recorded one Envoy event's write as, in `$.store`: the write it answered the
|
|
118
|
+
* server with, minus the refusals, which are no write and are recorded as none. Values
|
|
119
|
+
* round-trip as `JSON.parse(JSON.stringify(value))` (measured on 2.1.310), so this is plain
|
|
120
|
+
* JSON data. A prompt is recorded with no row of its own, as it is answered with none
|
|
121
|
+
* (`./mod-protocol`, `ModCallResults["session.message"]`): the write happened, so the resend
|
|
122
|
+
* answers from the record rather than writing the message a second time. */
|
|
123
|
+
type RecordedWrite = Exclude<ModCallResults["session.message"], { readonly deny: string }>
|
|
124
|
+
|
|
125
|
+
/** `value` as this mod recorded a write, or undefined where it is anything else: the store is
|
|
126
|
+
* this plugin's own, and a value it did not write is no record of one. */
|
|
127
|
+
function recordedWrite(value: unknown): RecordedWrite | undefined {
|
|
128
|
+
if (typeof value !== "object" || value === null) return undefined
|
|
129
|
+
if (!("as" in value)) return undefined
|
|
130
|
+
const { as } = value
|
|
131
|
+
if (as === "prompt") return { as }
|
|
132
|
+
if (as !== "row") return undefined
|
|
133
|
+
const uuid = "uuid" in value ? value.uuid : undefined
|
|
134
|
+
return typeof uuid === "string" ? { uuid, as } : undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** What the transport half gives the writer: the session, the event queue, the clock and the id
|
|
138
|
+
* the store's keys are scoped by. */
|
|
139
|
+
export interface MessageWriterOptions {
|
|
140
|
+
/**
|
|
141
|
+
* The session now, or undefined before `session.start` and after the link gave up.
|
|
142
|
+
*
|
|
143
|
+
* Read by `pointAt` alone: the prompt at a turn's unread rows is work of the writer's own,
|
|
144
|
+
* at a turn's end with no request in hand. Every other write is made for one request, which
|
|
145
|
+
* carries the session the server's call reached (`./mod.ts`, `invoke`).
|
|
146
|
+
*/
|
|
147
|
+
readonly engine: () => WriterHost | undefined
|
|
148
|
+
/** Queue one event for the server; never throws (`./mod.ts`, `send`). */
|
|
149
|
+
readonly send: (event: ModEvent) => void
|
|
150
|
+
/** Wait, through the host's own clock, and never throw (`./mod.ts`, `sleep`). */
|
|
151
|
+
readonly sleep: (ms: number) => Promise<void>
|
|
152
|
+
/** The id the session answers to now, which a `/clear` moves. */
|
|
153
|
+
readonly sessionId: () => string | undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The writes and the turn's own events, as the hooks drive them through the link. */
|
|
157
|
+
export interface MessageWriter {
|
|
158
|
+
/** Write one message into the session as the session stands now: a row of the turn running,
|
|
159
|
+
* or the prompt of a new turn. */
|
|
160
|
+
readonly writeMessage: (
|
|
161
|
+
engine: WriterHost,
|
|
162
|
+
args: ModCallArguments["session.message"],
|
|
163
|
+
) => Promise<ModCallResults["session.message"]>
|
|
164
|
+
/** Write the message of `eventId` once, however many times Envoy sends it. */
|
|
165
|
+
readonly writeOnce: (
|
|
166
|
+
engine: WriterHost,
|
|
167
|
+
eventId: string,
|
|
168
|
+
args: ModCallArguments["session.message"],
|
|
169
|
+
) => Promise<ModCallResults["session.message"]>
|
|
170
|
+
/** A turn started: it reads every row stored for it, including the ones stored while the
|
|
171
|
+
* prompt that started it was still in flight. */
|
|
172
|
+
readonly turnStarted: (turnId: string) => void
|
|
173
|
+
/** A turn ended: every row of this mod's that no request of it read is carried by one prompt
|
|
174
|
+
* of its own (`unreadRowPrompt`). */
|
|
175
|
+
readonly turnEnded: (turnId: string) => void
|
|
176
|
+
/** A request of the turn is going out: a row stored before this moment is in this request,
|
|
177
|
+
* and one stored after it is in none. */
|
|
178
|
+
readonly stepRequested: () => void
|
|
179
|
+
/** One row of the `prompt` door, claimed for the prompt of this mod's it is the row of, and
|
|
180
|
+
* stamped with what that prompt wrote; undefined for a person's own prompt. */
|
|
181
|
+
readonly claimRow: (content: readonly ModContentBlock[]) => "message" | "pointer" | undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The writer for one session: all of it closure state, as the link it belongs to is. */
|
|
185
|
+
export function createMessageWriter(options: MessageWriterOptions): MessageWriter {
|
|
186
|
+
const { send, sleep } = options
|
|
187
|
+
/** The turn running now, as `turn.start` and `turn.complete` report it. Read in the same tick
|
|
188
|
+
* as the write that follows it, which is what makes "a row of this turn, or the prompt of a
|
|
189
|
+
* new one" a decision and not a guess (INFRA-1026 review, round eleven). */
|
|
190
|
+
let turn: string | undefined
|
|
191
|
+
/**
|
|
192
|
+
* True from the moment this mod hands the engine a prompt until the turn that prompt starts
|
|
193
|
+
* has reported itself, or until the mod gives up on one (`TURN_START_MS`).
|
|
194
|
+
*
|
|
195
|
+
* A message handed over meanwhile is a row of that turn rather than a second prompt: the
|
|
196
|
+
* engine takes one prompt at a time, so a second would sit behind this turn, and the row the
|
|
197
|
+
* turn is about to read says the same thing sooner.
|
|
198
|
+
*/
|
|
199
|
+
let prompting = false
|
|
200
|
+
/** Bumped whenever a turn starts or this mod stops waiting for one, so the guard armed for
|
|
201
|
+
* one prompt never fires against a later one. */
|
|
202
|
+
let promptEpoch = 0
|
|
203
|
+
/** How many requests this session has made, counted at the top of every `turn.step`. */
|
|
204
|
+
let requests = 0
|
|
205
|
+
/** The rows this mod wrote that no request has read, by the uuid Claude Code stored each
|
|
206
|
+
* under. A row leaves it when its turn ends, which is also when the prompt pointing at it
|
|
207
|
+
* goes out, so this holds the rows of the turn running and nothing else. */
|
|
208
|
+
const watched = new Map<string, WatchedRow>()
|
|
209
|
+
/** The prompts this mod submitted whose row has not arrived, oldest first. An entry leaves
|
|
210
|
+
* it when the row arrives, and `PROMPT_ROW_MS` after the engine took the prompt where no
|
|
211
|
+
* row does; a submit still in flight holds its entry, since the row comes just before that
|
|
212
|
+
* call answers (`submitPrompt`). So nothing else bounds this. */
|
|
213
|
+
const awaitedRows: AwaitedRow[] = []
|
|
214
|
+
/** The write running for each Envoy event id, so a copy of that event which arrives while the
|
|
215
|
+
* first is still running waits for it and then finds what it recorded (`writeOnce`). */
|
|
216
|
+
const writing = new Map<string, Promise<void>>()
|
|
217
|
+
|
|
218
|
+
/** The key `$.store` holds one Envoy event's write under: this session's id with the event
|
|
219
|
+
* id. A `/clear` moves the id, and the conversation it moves to is one the card is not in,
|
|
220
|
+
* so its key is a key of its own. */
|
|
221
|
+
const recordKey = (eventId: string): string =>
|
|
222
|
+
`${CARD_KEY_PREFIX}${options.sessionId() ?? ""}:${eventId}`
|
|
223
|
+
|
|
224
|
+
/** Record what `eventId`'s message was written as, and drop the oldest records past
|
|
225
|
+
* `CARD_RECORD_MAX`. `keys()` answers in insertion order, so the oldest are the first of
|
|
226
|
+
* them (the issue's store spike). A store that refuses the write leaves no record and the
|
|
227
|
+
* message stands: a later copy of that event would then be written a second time, which is
|
|
228
|
+
* the one case this rule misses and the one the crash case already covers
|
|
229
|
+
* (`docs/site/src/content/docs/dispatch/envoy.md`). */
|
|
230
|
+
const remember = async (
|
|
231
|
+
engine: WriterHost,
|
|
232
|
+
eventId: string,
|
|
233
|
+
write: RecordedWrite,
|
|
234
|
+
): Promise<void> => {
|
|
235
|
+
try {
|
|
236
|
+
await engine.storeSet(recordKey(eventId), write)
|
|
237
|
+
const keys = (await engine.storeKeys()).filter((key) => key.startsWith(CARD_KEY_PREFIX))
|
|
238
|
+
for (const old of keys.slice(0, keys.length - CARD_RECORD_MAX)) {
|
|
239
|
+
await engine.storeDelete(old)
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// The record is a saving, not the write itself; a store that refuses it costs nothing
|
|
243
|
+
// here and there is nowhere in a session to report it to.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Register the row Claude Code writes for the prompt carrying `text`, so that row is
|
|
248
|
+
* stamped `wrote` for the server (`claimRow`). Registered before the submit goes out,
|
|
249
|
+
* because the row reaches this mod's own `session.append` hook before the call resolves.
|
|
250
|
+
* Giving the entry up is the caller's, and `submitPrompt` times that from the call's
|
|
251
|
+
* answer: the gap `PROMPT_ROW_MS` bounds is the one between the row and that answer, and a
|
|
252
|
+
* submit itself can wait seconds for a turn already running. Nothing waits on the row: the
|
|
253
|
+
* write is answered with the prompt alone (`./mod-protocol`,
|
|
254
|
+
* `ModCallResults["session.message"]`). */
|
|
255
|
+
const awaitRow = (text: string, wrote: "message" | "pointer"): { readonly drop: () => void } => {
|
|
256
|
+
const waiting: AwaitedRow = { text, wrote }
|
|
257
|
+
awaitedRows.push(waiting)
|
|
258
|
+
const drop = (): void => {
|
|
259
|
+
const at = awaitedRows.indexOf(waiting)
|
|
260
|
+
if (at !== -1) awaitedRows.splice(at, 1)
|
|
261
|
+
}
|
|
262
|
+
return { drop }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** One row of the `prompt` door, claimed for the prompt of this mod's it is the row of, if
|
|
266
|
+
* any: the oldest whose text this row carries, since Claude Code writes its own framing in
|
|
267
|
+
* front of a submit that named no `asUser`. Every other prompt row is a person's own. */
|
|
268
|
+
const claimRow = (content: readonly ModContentBlock[]): AwaitedRow["wrote"] | undefined => {
|
|
269
|
+
const text = content
|
|
270
|
+
.map((block) => (typeof block["text"] === "string" ? block["text"] : ""))
|
|
271
|
+
.join("\n")
|
|
272
|
+
const at = awaitedRows.findIndex((waiting) => text.includes(waiting.text))
|
|
273
|
+
const waiting = awaitedRows[at]
|
|
274
|
+
if (waiting === undefined) return undefined
|
|
275
|
+
awaitedRows.splice(at, 1)
|
|
276
|
+
return waiting.wrote
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Take out of the watch every row `leaves` names, and point the model at the messages of
|
|
281
|
+
* those no request read, with the one prompt that quotes them all (`unreadRowPrompt`).
|
|
282
|
+
*
|
|
283
|
+
* The three moments a row leaves the watch pass their own `leaves` and share everything
|
|
284
|
+
* else: the turn that ends, the prompt that started no turn, and the one row the session
|
|
285
|
+
* fell idle under while it was being stored. A row that leaves having been stored since
|
|
286
|
+
* this session's last request is a row nothing read — in the last two moments every row
|
|
287
|
+
* that leaves is such a row, since no request can have gone out meanwhile: a step belongs
|
|
288
|
+
* to a turn, and in both there is no turn.
|
|
289
|
+
*/
|
|
290
|
+
const pointAtUnread = (leaves: (uuid: string, row: WatchedRow) => boolean): void => {
|
|
291
|
+
const unread: ModUnreadMessage[] = []
|
|
292
|
+
for (const [uuid, row] of watched) {
|
|
293
|
+
if (!leaves(uuid, row)) continue
|
|
294
|
+
watched.delete(uuid)
|
|
295
|
+
if (row.requests === requests) unread.push(row.message)
|
|
296
|
+
}
|
|
297
|
+
if (unread.length > 0) pointAt(unread)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Stop waiting for the turn a prompt was to start: it started none, so every row handed over
|
|
301
|
+
* while it was in flight is read by nothing and the model is pointed at it. A submit the
|
|
302
|
+
* engine dropped, one it refused, and one whose turn never came all end here (INFRA-1026
|
|
303
|
+
* review, repro `R-C`). */
|
|
304
|
+
const stopPrompting = (): void => {
|
|
305
|
+
prompting = false
|
|
306
|
+
promptEpoch += 1
|
|
307
|
+
pointAtUnread((_, row) => row.turnId === undefined)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Give the prompt the engine has just taken `TURN_START_MS` to start its turn. */
|
|
311
|
+
const guardTurnStart = (): void => {
|
|
312
|
+
const epoch = promptEpoch
|
|
313
|
+
void (async () => {
|
|
314
|
+
await sleep(TURN_START_MS)
|
|
315
|
+
if (promptEpoch === epoch && prompting) stopPrompting()
|
|
316
|
+
})()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Hand the engine one prompt and answer with the reason it dropped it, where it did.
|
|
321
|
+
*
|
|
322
|
+
* `$.prompt.submit` resolves once the engine has taken the prompt, not at the end of the turn
|
|
323
|
+
* it starts: in all six calls recorded on 2.1.310 the prompt's own row landed 15 ms to 26 ms
|
|
324
|
+
* before the call resolved and `turn.start` 8 ms to 11 ms after it, and the long resolutions
|
|
325
|
+
* — 8.5 s, 11.2 s and 12.4 s — are the wait for a turn that was already running, which ended
|
|
326
|
+
* just before (`/legion/scratch/tmp/infra-1026-spike/logs/mod2.jsonl:136-147`,
|
|
327
|
+
* `logs/mod3-q2-310.jsonl:72-89`, `logs/mod3-q1q3q5.jsonl:163-179`). So what the caller
|
|
328
|
+
* waits for is the prompt entering, never a turn.
|
|
329
|
+
*
|
|
330
|
+
* The row that prompt becomes is the prompt's own and is answered to nobody, so nothing here
|
|
331
|
+
* waits for it: it is watched only to be stamped (`awaitRow`, `./mod-protocol`,
|
|
332
|
+
* `ModCallResults["session.message"]`).
|
|
333
|
+
*/
|
|
334
|
+
const submitPrompt = async (
|
|
335
|
+
engine: WriterHost,
|
|
336
|
+
text: string,
|
|
337
|
+
asUser: boolean,
|
|
338
|
+
wrote: "message" | "pointer",
|
|
339
|
+
): Promise<{ readonly drop?: string }> => {
|
|
340
|
+
// Set before the call and in the same tick as the check that chose this branch: a message
|
|
341
|
+
// handed over from here on is a row of the turn this prompt starts.
|
|
342
|
+
prompting = true
|
|
343
|
+
const row = awaitRow(text, wrote)
|
|
344
|
+
let answer: PromptSubmitResult
|
|
345
|
+
try {
|
|
346
|
+
answer = await engine.submit({ text, ...(asUser ? { asUser: true as const } : {}) })
|
|
347
|
+
} catch (error) {
|
|
348
|
+
row.drop()
|
|
349
|
+
stopPrompting()
|
|
350
|
+
throw error
|
|
351
|
+
}
|
|
352
|
+
if (answer.drop !== undefined) {
|
|
353
|
+
row.drop()
|
|
354
|
+
stopPrompting()
|
|
355
|
+
return { drop: answer.drop }
|
|
356
|
+
}
|
|
357
|
+
// The engine has the prompt, so its row is due: it landed 15 ms to 26 ms before this
|
|
358
|
+
// answer in all six submits recorded on 2.1.310, and the wait for the answer itself was
|
|
359
|
+
// 8.5 s, 11.2 s and 12.4 s where a turn was already running. Timed from the call instead,
|
|
360
|
+
// the window closed on a row still coming and the pointer prompt's row went unstamped,
|
|
361
|
+
// which drew every message that prompt quotes a second time (INFRA-1026 review, round
|
|
362
|
+
// six). Both paths above drop the entry themselves, so nothing is left behind for a
|
|
363
|
+
// prompt that never entered.
|
|
364
|
+
void sleep(PROMPT_ROW_MS).then(row.drop)
|
|
365
|
+
guardTurnStart()
|
|
366
|
+
return {}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Submit the one prompt that points the model at `unread`, quoting each message in full
|
|
370
|
+
* (`unreadRowPrompt`). Its own row is stamped `pointer`, so the live view draws nothing of
|
|
371
|
+
* it: it quotes rows that view has already drawn. */
|
|
372
|
+
const pointAt = (unread: readonly ModUnreadMessage[]): void => {
|
|
373
|
+
const engine = options.engine()
|
|
374
|
+
if (engine === undefined) return
|
|
375
|
+
void submitPrompt(engine, unreadRowPrompt(unread), false, "pointer").catch(() => undefined)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Write one message into the session as the session stands now: a row of the turn running,
|
|
379
|
+
* or the prompt of a new turn. */
|
|
380
|
+
const writeMessage = async (
|
|
381
|
+
engine: WriterHost,
|
|
382
|
+
args: ModCallArguments["session.message"],
|
|
383
|
+
): Promise<ModCallResults["session.message"]> => {
|
|
384
|
+
if (turn === undefined && !prompting) {
|
|
385
|
+
// Nothing is running: an appended row would be read at the turn's next request, and an
|
|
386
|
+
// idle session makes none, so the message is the prompt itself and nothing else is
|
|
387
|
+
// stored for it — a row beside a prompt quoting it put the one message in front of the
|
|
388
|
+
// model twice (INFRA-1026, handoff v6).
|
|
389
|
+
const sent = await submitPrompt(
|
|
390
|
+
engine,
|
|
391
|
+
args.message.text,
|
|
392
|
+
args.message.kind === "person",
|
|
393
|
+
"message",
|
|
394
|
+
)
|
|
395
|
+
// A prompt the engine took is the write, with no row of its own; only one it dropped is
|
|
396
|
+
// a refusal (`./mod-protocol`, `ModCallResults["session.message"]`).
|
|
397
|
+
return sent.drop === undefined ? { as: "prompt" } : { deny: sent.drop, as: "prompt" }
|
|
398
|
+
}
|
|
399
|
+
const answer = await engine.append({ message: { type: "user", content: args.content } })
|
|
400
|
+
if (answer.deny !== undefined) return { deny: answer.deny, as: "row" }
|
|
401
|
+
// Claude Code does not re-enter this mod's own `session.append` hook for a row this mod
|
|
402
|
+
// appended, so the row reaches the server's mapper nowhere else and a card delivered into
|
|
403
|
+
// a running turn was drawn nowhere at all (INFRA-1026, handoff v6). Measured in the issue's
|
|
404
|
+
// spike: two `$.session.append` calls resolved
|
|
405
|
+
// `{message: {type: "user", role: "user", isMeta: true, content}, uuid}` and neither uuid
|
|
406
|
+
// ever arrived at a `session.append` hook
|
|
407
|
+
// (`/legion/scratch/tmp/infra-1026-spike/logs/mod3-q1q3q5.jsonl:36,64`). So the mod reports
|
|
408
|
+
// the row itself, out of what the call answered (`SessionAppendResult`,
|
|
409
|
+
// `./claude-mod.d.ts`).
|
|
410
|
+
//
|
|
411
|
+
// The door and the origin are the engine's to set and the result carries neither: a
|
|
412
|
+
// plugin's own append comes in by `note`, under the calling plugin. `wrote` is what the
|
|
413
|
+
// server reads it by, and it is on the event rather than on this call's answer because the
|
|
414
|
+
// two cross (`./mod-protocol`, `ModEvent`).
|
|
415
|
+
send({
|
|
416
|
+
kind: "append",
|
|
417
|
+
uuid: answer.uuid,
|
|
418
|
+
door: "note",
|
|
419
|
+
origin: { kind: "plugin", name: MOD_PLUGIN_NAME },
|
|
420
|
+
message: answer.message,
|
|
421
|
+
wrote: "message",
|
|
422
|
+
})
|
|
423
|
+
watched.set(answer.uuid, {
|
|
424
|
+
message: args.message,
|
|
425
|
+
requests,
|
|
426
|
+
...(turn === undefined ? {} : { turnId: turn }),
|
|
427
|
+
})
|
|
428
|
+
// Nothing is running any more. Reaching this branch meant something was - a turn, or a
|
|
429
|
+
// prompt of this mod's waiting for one, read in the same tick as the check above - and it
|
|
430
|
+
// ended while the row was being stored, with nothing starting in its place. So no request
|
|
431
|
+
// will read the row, and the model is pointed at it here rather than at a `turn.complete`
|
|
432
|
+
// or a `stopPrompting` that has been and gone: both ran while this row was not in the watch
|
|
433
|
+
// yet. Read off the turn alone, this moment missed the dropped prompt, whose row then sat
|
|
434
|
+
// in the watch with its Envoy frame answered (INFRA-1026 review, round five).
|
|
435
|
+
if (turn === undefined && !prompting) {
|
|
436
|
+
pointAtUnread((uuid) => uuid === answer.uuid)
|
|
437
|
+
}
|
|
438
|
+
return { uuid: answer.uuid, as: "row" }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Write the message of `eventId` once, however many times Envoy sends it.
|
|
443
|
+
*
|
|
444
|
+
* The store has no compare-and-set: two `get`s of an unset key both answered undefined, 0 ms
|
|
445
|
+
* and 2 s apart, in one session and across two (`/legion/scratch/tmp/infra-1026-store`). So a
|
|
446
|
+
* copy that arrives while the first write is still running waits for it here, inside the one
|
|
447
|
+
* process, and reads the record that write left; a copy that arrives after this process is
|
|
448
|
+
* gone — the engine restarted, `claude --resume <id>` — finds that record in the store, which
|
|
449
|
+
* `set` had written to disk before it resolved and which survived a `kill -9` taken at that
|
|
450
|
+
* moment (same spike).
|
|
451
|
+
*/
|
|
452
|
+
const writeOnce = (
|
|
453
|
+
engine: WriterHost,
|
|
454
|
+
eventId: string,
|
|
455
|
+
args: ModCallArguments["session.message"],
|
|
456
|
+
): Promise<ModCallResults["session.message"]> => {
|
|
457
|
+
const settle = Promise.withResolvers<ModCallResults["session.message"]>()
|
|
458
|
+
const behind = writing.get(eventId)
|
|
459
|
+
const run = (behind === undefined ? Promise.resolve() : behind.catch(() => undefined)).then(
|
|
460
|
+
async () => {
|
|
461
|
+
let written: ModCallResults["session.message"]
|
|
462
|
+
try {
|
|
463
|
+
const recorded = recordedWrite(
|
|
464
|
+
await engine.storeGet(recordKey(eventId)).catch(() => undefined),
|
|
465
|
+
)
|
|
466
|
+
// A store that refuses the read answers nothing, so the message is written again:
|
|
467
|
+
// at-least-once is this path's rule, and a message lost is the failure that matters.
|
|
468
|
+
written = recorded ?? (await writeMessage(engine, args))
|
|
469
|
+
if (recorded === undefined && written.deny === undefined) {
|
|
470
|
+
// The write is the record: a prompt the engine took is recorded with no row of its
|
|
471
|
+
// own, which is what keeps the resend from writing it again.
|
|
472
|
+
await remember(engine, eventId, written)
|
|
473
|
+
}
|
|
474
|
+
} catch (error) {
|
|
475
|
+
settle.reject(error)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
settle.resolve(written)
|
|
479
|
+
},
|
|
480
|
+
)
|
|
481
|
+
writing.set(eventId, run)
|
|
482
|
+
void run.finally(() => {
|
|
483
|
+
if (writing.get(eventId) === run) writing.delete(eventId)
|
|
484
|
+
})
|
|
485
|
+
return settle.promise
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
writeMessage,
|
|
490
|
+
writeOnce,
|
|
491
|
+
turnStarted: (turnId: string): void => {
|
|
492
|
+
turn = turnId
|
|
493
|
+
prompting = false
|
|
494
|
+
promptEpoch += 1
|
|
495
|
+
for (const row of watched.values()) row.turnId ??= turnId
|
|
496
|
+
},
|
|
497
|
+
turnEnded: (turnId: string): void => {
|
|
498
|
+
if (turn === turnId) turn = undefined
|
|
499
|
+
pointAtUnread((_, row) => row.turnId === turnId)
|
|
500
|
+
},
|
|
501
|
+
stepRequested: (): void => {
|
|
502
|
+
requests += 1
|
|
503
|
+
},
|
|
504
|
+
claimRow,
|
|
505
|
+
}
|
|
506
|
+
}
|