@shendeguize/dsh-agent-sidecar 0.1.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +8062 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.d.ts +396 -0
  7. package/lib/index.js +4166 -0
  8. package/package.json +101 -0
  9. package/src/analysis.ts +782 -0
  10. package/src/bridge.ts +841 -0
  11. package/src/client/analysis/AnalysisPanel.tsx +191 -0
  12. package/src/client/analysis/analysis.module.css +183 -0
  13. package/src/client/analysis-glue.ts +331 -0
  14. package/src/client/api.ts +380 -0
  15. package/src/client/board/Board.tsx +214 -0
  16. package/src/client/board/board.module.css +302 -0
  17. package/src/client/board/logic.ts +556 -0
  18. package/src/client/board/project-view-logic.ts +361 -0
  19. package/src/client/board/project-view.module.css +307 -0
  20. package/src/client/board/project-view.tsx +189 -0
  21. package/src/client/board/strings.ts +112 -0
  22. package/src/client/commands.ts +484 -0
  23. package/src/client/controller.ts +360 -0
  24. package/src/client/css-modules.d.ts +11 -0
  25. package/src/client/detail/SessionDetail.tsx +270 -0
  26. package/src/client/detail/detail.module.css +433 -0
  27. package/src/client/detail/logic.ts +779 -0
  28. package/src/client/detail/strings.ts +98 -0
  29. package/src/client/detail/transport.ts +175 -0
  30. package/src/client/detail-glue.ts +397 -0
  31. package/src/client/detail-view.module.css +79 -0
  32. package/src/client/detail-view.tsx +233 -0
  33. package/src/client/dsh-tools/LineageTree.tsx +210 -0
  34. package/src/client/dsh-tools/SearchPanel.tsx +169 -0
  35. package/src/client/dsh-tools/dsh-tools.module.css +374 -0
  36. package/src/client/dsh-tools/logic.ts +596 -0
  37. package/src/client/dsh-tools/strings.ts +90 -0
  38. package/src/client/index.ts +315 -0
  39. package/src/client/inject/InjectPanel.tsx +482 -0
  40. package/src/client/inject/inject.module.css +446 -0
  41. package/src/client/inject/logic.ts +516 -0
  42. package/src/client/inject/overlay.module.css +22 -0
  43. package/src/client/inject-glue.ts +171 -0
  44. package/src/client/locales/command.ts +48 -0
  45. package/src/client/locales/en.ts +385 -0
  46. package/src/client/locales/index.ts +123 -0
  47. package/src/client/locales/zh.ts +402 -0
  48. package/src/client/m3-transport.ts +151 -0
  49. package/src/client/mount.tsx +307 -0
  50. package/src/client/project-glue.ts +134 -0
  51. package/src/client/search-glue.ts +143 -0
  52. package/src/client/settings-card.module.css +359 -0
  53. package/src/client/settings-card.tsx +565 -0
  54. package/src/client/settings-glue.ts +130 -0
  55. package/src/client/sidebar-tab.tsx +494 -0
  56. package/src/client/sse.ts +366 -0
  57. package/src/client/widget.tsx +80 -0
  58. package/src/config.ts +193 -0
  59. package/src/dsh-inject.ts +240 -0
  60. package/src/fusion.ts +988 -0
  61. package/src/guard.ts +274 -0
  62. package/src/index.ts +950 -0
  63. package/src/inject-gateway.ts +574 -0
  64. package/src/routes.ts +1133 -0
  65. package/src/send-cli.ts +340 -0
  66. package/src/session-store.ts +184 -0
  67. package/src/skills-provider.ts +293 -0
  68. package/src/supervisor.ts +463 -0
@@ -0,0 +1,574 @@
1
+ /**
2
+ * InjectGateway — the single entry point of the injection write path
3
+ * (design §4.d dual-path injection, §4.f.5 / §5.3 two-phase confirm,
4
+ * §8 threat model: server-issued one-time confirmToken).
5
+ *
6
+ * Unifies the three planes both injection paths must share (ADR-4):
7
+ *
8
+ * - **Confirmation**: two-phase `prepare` → `execute`. `prepare` re-verifies
9
+ * the target live and issues a crypto-random one-time confirmToken
10
+ * (≥128 bit, 60s TTL) bound to requestId + target + mode + message sha256.
11
+ * `execute` refuses missing / expired / reused tokens, and refuses a
12
+ * message whose hash differs from the prepare-time binding (anti-swap).
13
+ * Any execute attempt against a live token voids it, success or not
14
+ * (consume-on-attempt).
15
+ * - **Idempotency**: the first execute result per requestId is cached
16
+ * (5 min TTL) and replayed on repeats. `outcome: 'unknown'` is terminal:
17
+ * a repeated execute returns the cached unknown and NEVER re-fires the
18
+ * executor (S6 — no retry through the gateway).
19
+ * - **Logging**: exactly one entry per prepare/execute carrying ts /
20
+ * requestId / target / mode / phase / result / errorCode / message byte
21
+ * size and sha256 prefix — never the message body or head plaintext
22
+ * (the head preview only travels in the prepare response for the UI).
23
+ *
24
+ * The token gate defends against browser-mediated attackers only; it does
25
+ * not claim to stop a local process that can drive both phases itself
26
+ * (ADR-8 trust posture — same as guard.ts).
27
+ *
28
+ * Pure DI: no cordis/dsh imports. Path executors (dsh in-process,
29
+ * sidecar send CLI) are injected; this module owns the contract, not the
30
+ * transport.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Public types (kept local to this module by design).
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** Injection target: one observed session of one agent. */
42
+ export interface InjectTarget {
43
+ agent: string
44
+ sessionId: string
45
+ }
46
+
47
+ /** Injection mode, aligned with dsh `session.prompt` semantics. */
48
+ export type InjectMode = 'queue' | 'steer'
49
+
50
+ /** Terminal outcome vocabulary shared by both executor paths. */
51
+ export type InjectOutcome = 'delivered' | 'failed' | 'unknown'
52
+
53
+ /** Gateway-issued error vocabulary (executors may add their own codes). */
54
+ export type InjectErrorCode =
55
+ | 'inject_disabled'
56
+ | 'invalid_message'
57
+ | 'target_not_found'
58
+ | 'target_dead'
59
+ | 'too_many_pending'
60
+ | 'token_missing'
61
+ | 'token_expired'
62
+ | 'token_reused'
63
+ | 'token_mismatch'
64
+ | 'unsupported_agent'
65
+ | 'executor_error'
66
+
67
+ /** Result envelope returned by executors and by the gateway itself. */
68
+ export interface InjectResult {
69
+ outcome: InjectOutcome
70
+ /** Gateway code ({@link InjectErrorCode}) or an executor-native code. */
71
+ errorCode?: string
72
+ detail?: string
73
+ /** True when this is a cached first result replayed for a repeat execute. */
74
+ replayed?: boolean
75
+ }
76
+
77
+ /**
78
+ * Live target snapshot produced by the injected `verifyTarget` re-check
79
+ * (backed by store/bridge outside this module). Status is an observed,
80
+ * possibly lagging value (G11).
81
+ */
82
+ export interface TargetStatus {
83
+ agent: string
84
+ sessionId: string
85
+ status: string
86
+ title?: string
87
+ project?: string
88
+ }
89
+
90
+ /** Confirmation plan shown to the user; the message body is never retained. */
91
+ export interface InjectPlan {
92
+ target: InjectTarget
93
+ mode: InjectMode
94
+ /** Live status snapshot captured by `prepare`'s re-verification. */
95
+ targetStatus: TargetStatus
96
+ /** Byte size + head preview for the confirm dialog (head is UI-only). */
97
+ messagePreview: { bytes: number; head: string }
98
+ }
99
+
100
+ /** What the gateway hands an executor once every gate has passed. */
101
+ export interface InjectExecutionRequest {
102
+ target: InjectTarget
103
+ mode: InjectMode
104
+ message: string
105
+ requestId: string
106
+ }
107
+
108
+ /** One injection path (dsh in-process API, or the sidecar send CLI). */
109
+ export interface InjectExecutor {
110
+ kind: 'dsh' | 'send-cli'
111
+ execute(req: InjectExecutionRequest): Promise<InjectResult>
112
+ }
113
+
114
+ /**
115
+ * One audit entry. Deliberately body-free: only byte size and a sha256
116
+ * prefix identify the message (S8; the head preview never lands here).
117
+ */
118
+ export interface InjectLogEntry {
119
+ /** Epoch ms from the injected clock. */
120
+ ts: number
121
+ phase: 'prepare' | 'execute'
122
+ /** Null when a prepare was rejected before a requestId was issued. */
123
+ requestId: string | null
124
+ /** Null when an execute referenced a requestId the gateway never issued. */
125
+ target: InjectTarget | null
126
+ mode: InjectMode | null
127
+ /** prepare: token issued; execute: outcome === 'delivered'. */
128
+ ok: boolean
129
+ outcome?: InjectOutcome
130
+ errorCode?: string
131
+ replayed?: boolean
132
+ /** UTF-8 byte size of the submitted message. */
133
+ messageBytes: number
134
+ /** First 12 hex chars of the message sha256 — never the body itself. */
135
+ messageSha12: string
136
+ }
137
+
138
+ /** `prepare` input. */
139
+ export interface PrepareRequest {
140
+ target: InjectTarget
141
+ mode: InjectMode
142
+ message: string
143
+ }
144
+
145
+ /** `prepare` output: an issued confirmation, or a vocabulary rejection. */
146
+ export type PrepareResult =
147
+ | {
148
+ ok: true
149
+ requestId: string
150
+ confirmToken: string
151
+ plan: InjectPlan
152
+ /** Epoch ms after which the confirmToken is dead. */
153
+ expiresAt: number
154
+ }
155
+ | { ok: false; errorCode: InjectErrorCode; detail?: string }
156
+
157
+ /** `execute` input: the confirmation issued by `prepare` plus the message. */
158
+ export interface ExecuteRequest {
159
+ requestId: string
160
+ confirmToken: string
161
+ message: string
162
+ }
163
+
164
+ /** Dependency seam; everything effectful is injected. */
165
+ export interface InjectGatewayDeps {
166
+ executors: { dsh: InjectExecutor; sendCli: InjectExecutor }
167
+ /** Live target re-check (store/bridge outside); null = target unknown. */
168
+ verifyTarget(target: InjectTarget): Promise<TargetStatus | null>
169
+ /** Reads the `inject.enabled` gate at call time (live setting). */
170
+ allowWrite(): boolean
171
+ /** Sink for every audit entry (also kept in the internal ring). */
172
+ log(entry: InjectLogEntry): void
173
+ /** Clock override for tests; defaults to `Date.now`. */
174
+ now?(): number
175
+ /** requestId source override for tests; defaults to `randomUUID`. */
176
+ randomId?(): string
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Tunables (exported where tests / routes need the exact boundary).
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** Message byte cap, aligned with the sidecar send 16 KiB limit (§4.d). */
184
+ export const MAX_MESSAGE_BYTES = 16 * 1024
185
+ /** confirmToken lifetime (§4.f.5). */
186
+ export const TOKEN_TTL_MS = 60_000
187
+ /** In-flight (issued, unconsumed, unexpired) token cap; beyond it new prepares are refused. */
188
+ export const MAX_PENDING_TOKENS = 32
189
+ /** Idempotency window: how long a first execute result is replayable. */
190
+ export const RESULT_CACHE_TTL_MS = 5 * 60_000
191
+
192
+ /** 16 random bytes = 128 bits, the spec floor for the confirmToken. */
193
+ const TOKEN_BYTES = 16
194
+ /** Head preview cap (chars) for the confirm dialog; UI-only, never logged. */
195
+ const HEAD_PREVIEW_CHARS = 120
196
+ /** Bound of the internal audit ring served by {@link InjectGateway.getRecentLog}. */
197
+ const LOG_RING_LIMIT = 256
198
+ const DEFAULT_LOG_QUERY_LIMIT = 50
199
+ /** Sha256 hex prefix length recorded in logs. */
200
+ const SHA_LOG_CHARS = 12
201
+
202
+ /** External agents reachable through the sidecar `send` CLI path (§4.d). */
203
+ const SEND_CLI_AGENTS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor-cli'])
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Internals.
207
+ // ---------------------------------------------------------------------------
208
+
209
+ interface MessageDigest {
210
+ bytes: number
211
+ sha256: string
212
+ sha12: string
213
+ }
214
+
215
+ function digestMessage(message: string): MessageDigest {
216
+ const sha256 = createHash('sha256').update(message, 'utf8').digest('hex')
217
+ return {
218
+ bytes: Buffer.byteLength(message, 'utf8'),
219
+ sha256,
220
+ sha12: sha256.slice(0, SHA_LOG_CHARS),
221
+ }
222
+ }
223
+
224
+ /** Returns a rejection detail, or null when the message is acceptable. */
225
+ function validateMessage(message: string, bytes: number): string | null {
226
+ if (bytes === 0) return 'message is empty'
227
+ if (message.includes('\u0000')) return 'message contains a NUL byte'
228
+ if (bytes > MAX_MESSAGE_BYTES) {
229
+ return `message is ${bytes} bytes; limit is ${MAX_MESSAGE_BYTES}`
230
+ }
231
+ return null
232
+ }
233
+
234
+ /** Constant-time token comparison (length leak is inherent and harmless). */
235
+ function tokenEquals(expected: string, provided: string): boolean {
236
+ const a = Buffer.from(expected, 'utf8')
237
+ const b = Buffer.from(provided, 'utf8')
238
+ return a.length === b.length && timingSafeEqual(a, b)
239
+ }
240
+
241
+ /** One issued confirmation, alive from prepare until pruned. */
242
+ interface PendingToken {
243
+ token: string
244
+ target: InjectTarget
245
+ mode: InjectMode
246
+ messageSha256: string
247
+ expiresAt: number
248
+ /** Set on the first execute attempt and never cleared (one shot). */
249
+ consumed: boolean
250
+ }
251
+
252
+ interface CachedResult {
253
+ result: InjectResult
254
+ expiresAt: number
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Gateway.
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /** The confirmation / idempotency / logging hub for both injection paths. */
262
+ export class InjectGateway {
263
+ private readonly deps: InjectGatewayDeps
264
+ private readonly now: () => number
265
+ private readonly randomId: () => string
266
+ private readonly pending = new Map<string, PendingToken>()
267
+ private readonly results = new Map<string, CachedResult>()
268
+ private readonly logRing: InjectLogEntry[] = []
269
+
270
+ constructor(deps: InjectGatewayDeps) {
271
+ this.deps = deps
272
+ this.now = deps.now ?? Date.now
273
+ this.randomId = deps.randomId ?? randomUUID
274
+ }
275
+
276
+ /**
277
+ * Phase one: gate, validate, re-verify, then issue a one-time
278
+ * confirmation bound to this exact target + mode + message.
279
+ *
280
+ * Pipeline (order per spec): allowWrite gate → message pre-validation
281
+ * (≤16 KiB by bytes, non-empty, no NUL) → live target re-check →
282
+ * injectable-agent whitelist → capacity check → token issuance.
283
+ */
284
+ async prepare(req: PrepareRequest): Promise<PrepareResult> {
285
+ this.prune(this.now())
286
+ const digest = digestMessage(req.message)
287
+
288
+ if (!this.deps.allowWrite()) {
289
+ return this.rejectPrepare(req, digest, 'inject_disabled')
290
+ }
291
+
292
+ const invalid = validateMessage(req.message, digest.bytes)
293
+ if (invalid !== null) {
294
+ return this.rejectPrepare(req, digest, 'invalid_message', invalid)
295
+ }
296
+
297
+ const target: InjectTarget = { agent: req.target.agent, sessionId: req.target.sessionId }
298
+ const status = await this.deps.verifyTarget(target)
299
+ if (status === null) {
300
+ return this.rejectPrepare(req, digest, 'target_not_found')
301
+ }
302
+ if (status.status === 'dead') {
303
+ return this.rejectPrepare(req, digest, 'target_dead')
304
+ }
305
+
306
+ // Agents with no injection path (copilot / kimi / cursor-ide / …) are
307
+ // refused HERE, before a token is issued: a prepare that can only ever
308
+ // fail at execute would waste a confirm dialog and in-flight capacity
309
+ // on a caller that gets its unsupported_agent verdict one phase late
310
+ // (M2 review F-6). The execute-side check stays as defense in depth.
311
+ if (this.executorFor(target.agent) === null) {
312
+ return this.rejectPrepare(req, digest, 'unsupported_agent')
313
+ }
314
+
315
+ // Re-read the clock after the async re-check so capacity and TTL are
316
+ // decided at actual issuance time.
317
+ const issuedAt = this.now()
318
+ if (this.inFlightCount(issuedAt) >= MAX_PENDING_TOKENS) {
319
+ return this.rejectPrepare(req, digest, 'too_many_pending')
320
+ }
321
+
322
+ const requestId = this.randomId()
323
+ const confirmToken = randomBytes(TOKEN_BYTES).toString('hex')
324
+ const expiresAt = issuedAt + TOKEN_TTL_MS
325
+ this.pending.set(requestId, {
326
+ token: confirmToken,
327
+ target,
328
+ mode: req.mode,
329
+ messageSha256: digest.sha256,
330
+ expiresAt,
331
+ consumed: false,
332
+ })
333
+
334
+ this.record({
335
+ ts: issuedAt,
336
+ phase: 'prepare',
337
+ requestId,
338
+ target: { ...target },
339
+ mode: req.mode,
340
+ ok: true,
341
+ messageBytes: digest.bytes,
342
+ messageSha12: digest.sha12,
343
+ })
344
+
345
+ return {
346
+ ok: true,
347
+ requestId,
348
+ confirmToken,
349
+ plan: {
350
+ target: { ...target },
351
+ mode: req.mode,
352
+ targetStatus: { ...status },
353
+ messagePreview: {
354
+ bytes: digest.bytes,
355
+ head: req.message.slice(0, HEAD_PREVIEW_CHARS),
356
+ },
357
+ },
358
+ expiresAt,
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Phase two: validate the confirmation, dispatch to the path executor,
364
+ * and cache the first result per requestId.
365
+ *
366
+ * Rejection order: cached replay (idempotency wins) → token missing →
367
+ * token reused → token expired → token/message binding mismatch →
368
+ * unsupported agent. Every attempt against a live token consumes it,
369
+ * whatever happens afterwards.
370
+ */
371
+ async execute(req: ExecuteRequest): Promise<InjectResult> {
372
+ const now = this.now()
373
+ this.prune(now)
374
+ const digest = digestMessage(req.message)
375
+ const record = this.pending.get(req.requestId) ?? null
376
+
377
+ const cached = this.results.get(req.requestId)
378
+ if (cached !== undefined) {
379
+ // A replay must still present the original binding (anti-swap holds
380
+ // on the idempotent path too). The binding facts live on the pending
381
+ // record — and the prune() constants do NOT make "cache alive but
382
+ // pending pruned" impossible: the cache expiry stamp is taken AFTER
383
+ // the executor awaited, so an executor that runs past the token's
384
+ // nominal expiry (send-cli worst case ~35s) stretches the cache past
385
+ // the pending prune time (issuedAt + TOKEN_TTL_MS +
386
+ // RESULT_CACHE_TTL_MS). In that window an unverifiable replay must
387
+ // fail closed rather than hand a cached result to a caller that only
388
+ // knows the requestId (M2 review F-5).
389
+ if (record === null) {
390
+ return this.rejectExecute(req.requestId, null, digest, 'token_missing')
391
+ }
392
+ if (
393
+ !tokenEquals(record.token, req.confirmToken) ||
394
+ record.messageSha256 !== digest.sha256
395
+ ) {
396
+ return this.rejectExecute(req.requestId, record, digest, 'token_mismatch')
397
+ }
398
+ const replay: InjectResult = { ...cached.result, replayed: true }
399
+ this.logExecuteResult(req.requestId, record, digest, replay)
400
+ return replay
401
+ }
402
+
403
+ if (!req.confirmToken) {
404
+ // A malformed request (no token at all) is not an "attempt": the
405
+ // issued token, if any, stays live.
406
+ return this.rejectExecute(req.requestId, record, digest, 'token_missing')
407
+ }
408
+ if (record === null) {
409
+ return this.rejectExecute(req.requestId, null, digest, 'token_missing')
410
+ }
411
+ if (record.consumed) {
412
+ return this.rejectExecute(req.requestId, record, digest, 'token_reused')
413
+ }
414
+ if (record.expiresAt <= now) {
415
+ this.pending.delete(req.requestId)
416
+ return this.rejectExecute(req.requestId, record, digest, 'token_expired')
417
+ }
418
+
419
+ // Consume-on-attempt: one execute attempt per issued token, success or
420
+ // not. A later retry needs a fresh prepare (fresh user confirmation).
421
+ record.consumed = true
422
+
423
+ if (!tokenEquals(record.token, req.confirmToken)) {
424
+ return this.rejectExecute(req.requestId, record, digest, 'token_mismatch')
425
+ }
426
+ if (record.messageSha256 !== digest.sha256) {
427
+ return this.rejectExecute(req.requestId, record, digest, 'token_mismatch')
428
+ }
429
+
430
+ const executor = this.executorFor(record.target.agent)
431
+ if (executor === null) {
432
+ return this.rejectExecute(req.requestId, record, digest, 'unsupported_agent')
433
+ }
434
+
435
+ let result: InjectResult
436
+ try {
437
+ result = await executor.execute({
438
+ target: { ...record.target },
439
+ mode: record.mode,
440
+ message: req.message,
441
+ requestId: req.requestId,
442
+ })
443
+ } catch (err) {
444
+ result = {
445
+ outcome: 'failed',
446
+ errorCode: 'executor_error',
447
+ detail: err instanceof Error ? err.message : String(err),
448
+ }
449
+ }
450
+
451
+ // Cache every terminal result — delivered, failed, and unknown alike.
452
+ // 'unknown' is thereby terminal: replays serve this cache and the
453
+ // consumed token blocks any second dispatch forever (S6).
454
+ this.results.set(req.requestId, {
455
+ result: { ...result },
456
+ expiresAt: this.now() + RESULT_CACHE_TTL_MS,
457
+ })
458
+ this.logExecuteResult(req.requestId, record, digest, result)
459
+ return result
460
+ }
461
+
462
+ /** Read-only audit view (newest first), for the M3 detail page. */
463
+ getRecentLog(limit: number = DEFAULT_LOG_QUERY_LIMIT): InjectLogEntry[] {
464
+ const bounded = Math.min(Math.max(Math.floor(limit), 0), this.logRing.length)
465
+ return this.logRing.slice(this.logRing.length - bounded).reverse()
466
+ }
467
+
468
+ // -------------------------------------------------------------------------
469
+
470
+ private executorFor(agent: string): InjectExecutor | null {
471
+ if (agent === 'dsh') return this.deps.executors.dsh
472
+ if (SEND_CLI_AGENTS.has(agent)) return this.deps.executors.sendCli
473
+ return null
474
+ }
475
+
476
+ /** Issued-but-unconsumed-and-unexpired tokens count toward the cap. */
477
+ private inFlightCount(now: number): number {
478
+ let count = 0
479
+ for (const record of this.pending.values()) {
480
+ if (!record.consumed && record.expiresAt > now) count += 1
481
+ }
482
+ return count
483
+ }
484
+
485
+ /**
486
+ * Housekeeping. Pending records outlive their token TTL by the result
487
+ * cache window so that late replays keep their binding check and late
488
+ * reuse attempts still answer `token_reused` (not `token_missing`).
489
+ */
490
+ private prune(now: number): void {
491
+ for (const [id, record] of this.pending) {
492
+ if (record.expiresAt + RESULT_CACHE_TTL_MS <= now) this.pending.delete(id)
493
+ }
494
+ for (const [id, cached] of this.results) {
495
+ if (cached.expiresAt <= now) this.results.delete(id)
496
+ }
497
+ }
498
+
499
+ private rejectPrepare(
500
+ req: PrepareRequest,
501
+ digest: MessageDigest,
502
+ errorCode: InjectErrorCode,
503
+ detail?: string,
504
+ ): PrepareResult {
505
+ this.record({
506
+ ts: this.now(),
507
+ phase: 'prepare',
508
+ requestId: null,
509
+ target: { agent: req.target.agent, sessionId: req.target.sessionId },
510
+ mode: req.mode,
511
+ ok: false,
512
+ errorCode,
513
+ messageBytes: digest.bytes,
514
+ messageSha12: digest.sha12,
515
+ })
516
+ return detail === undefined
517
+ ? { ok: false, errorCode }
518
+ : { ok: false, errorCode, detail }
519
+ }
520
+
521
+ private rejectExecute(
522
+ requestId: string,
523
+ record: PendingToken | null,
524
+ digest: MessageDigest,
525
+ errorCode: InjectErrorCode,
526
+ ): InjectResult {
527
+ this.record({
528
+ ts: this.now(),
529
+ phase: 'execute',
530
+ requestId,
531
+ target: record === null ? null : { ...record.target },
532
+ mode: record === null ? null : record.mode,
533
+ ok: false,
534
+ outcome: 'failed',
535
+ errorCode,
536
+ messageBytes: digest.bytes,
537
+ messageSha12: digest.sha12,
538
+ })
539
+ return { outcome: 'failed', errorCode }
540
+ }
541
+
542
+ private logExecuteResult(
543
+ requestId: string,
544
+ record: PendingToken | null,
545
+ digest: MessageDigest,
546
+ result: InjectResult,
547
+ ): void {
548
+ this.record({
549
+ ts: this.now(),
550
+ phase: 'execute',
551
+ requestId,
552
+ target: record === null ? null : { ...record.target },
553
+ mode: record === null ? null : record.mode,
554
+ ok: result.outcome === 'delivered',
555
+ outcome: result.outcome,
556
+ ...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
557
+ ...(result.replayed !== undefined ? { replayed: result.replayed } : {}),
558
+ messageBytes: digest.bytes,
559
+ messageSha12: digest.sha12,
560
+ })
561
+ }
562
+
563
+ private record(entry: InjectLogEntry): void {
564
+ const frozen: InjectLogEntry = Object.freeze({
565
+ ...entry,
566
+ target: entry.target === null ? null : Object.freeze({ ...entry.target }),
567
+ })
568
+ this.logRing.push(frozen)
569
+ if (this.logRing.length > LOG_RING_LIMIT) {
570
+ this.logRing.splice(0, this.logRing.length - LOG_RING_LIMIT)
571
+ }
572
+ this.deps.log(frozen)
573
+ }
574
+ }