@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.
@@ -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
+ }