@wangmo-bit/ds-link 0.0.0-stage → 0.8.18

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/TESTING.md +59 -0
  4. package/THIRD_PARTY_NOTICES.md +313 -0
  5. package/assets/icon.svg +7 -0
  6. package/cordis.patch.yml +3 -0
  7. package/dist/client.js +6012 -0
  8. package/package.json +102 -4
  9. package/src/core/constants.js +188 -0
  10. package/src/core/downgrade.js +80 -0
  11. package/src/core/engine.js +4669 -0
  12. package/src/core/errors.js +51 -0
  13. package/src/core/ledger.js +1174 -0
  14. package/src/core/migration.js +741 -0
  15. package/src/core/render.js +61 -0
  16. package/src/core/settings.js +147 -0
  17. package/src/core/validation.js +454 -0
  18. package/src/host/adapter.js +1152 -0
  19. package/src/host/connection.js +113 -0
  20. package/src/host/entry-0.2.0.js +13 -0
  21. package/src/host/entry-0.3.0.js +13 -0
  22. package/src/host/entry-0.4.0.js +13 -0
  23. package/src/host/entry-0.8.10.js +13 -0
  24. package/src/host/entry-0.8.11.js +13 -0
  25. package/src/host/entry-0.8.12.js +13 -0
  26. package/src/host/entry-0.8.13.js +13 -0
  27. package/src/host/entry-0.8.14.js +13 -0
  28. package/src/host/entry-0.8.15.js +13 -0
  29. package/src/host/entry-0.8.16-pre.1.js +13 -0
  30. package/src/host/entry-0.8.16-pre.2.js +13 -0
  31. package/src/host/entry-0.8.16-pre.3.js +13 -0
  32. package/src/host/entry-0.8.16-pre.4.js +13 -0
  33. package/src/host/entry-0.8.16-pre.5.js +13 -0
  34. package/src/host/entry-0.8.18.js +13 -0
  35. package/src/host/entry-0.8.5.js +13 -0
  36. package/src/host/entry-0.8.6.js +13 -0
  37. package/src/host/entry-0.8.7.js +13 -0
  38. package/src/host/entry-0.8.8.js +13 -0
  39. package/src/host/entry-0.8.9.js +13 -0
  40. package/src/host/index.js +74 -0
  41. package/src/host/plugin.js +403 -0
  42. package/src/host/prompt.js +22 -0
  43. package/src/host/tools.js +312 -0
@@ -0,0 +1,4669 @@
1
+ /**
2
+ * Host-independent v0.3 delivery state machine and owner control plane.
3
+ *
4
+ * The engine owns every durable transition. Host code supplies an adapter for
5
+ * eligibility/discovery, message construction, steer+flush, receipt observation,
6
+ * cold reads, native inbox transfer, and history. The engine never calls
7
+ * `sessionController.prompt`, never cancels a recipient's work, never captures
8
+ * a recipient's last assistant message, and never retries an unknown delivery.
9
+ *
10
+ * A receipt only proves the host committed a matching user/message event into
11
+ * the target's context. It never claims the model read, understood, or acted
12
+ * on it. There are no business quotas here: only message format, the two byte
13
+ * storage envelopes, identity/eligibility, pause/disable, and persisted
14
+ * unknown/terminal state can refuse work.
15
+ */
16
+
17
+ import { randomUUID } from 'node:crypto'
18
+
19
+ import {
20
+ API_VERSION,
21
+ AUTO_HELD_REASONS,
22
+ CONTROL_ACTIONS,
23
+ CREATE_TERMINAL_PHASES,
24
+ LEGACY_SETTINGS_KEYS,
25
+ LIMITS,
26
+ PLUGIN_VERSION,
27
+ SNAPSHOT_FILTERS,
28
+ } from './constants.js'
29
+ import { fail, errorCode } from './errors.js'
30
+ import { skipMutation } from './ledger.js'
31
+ import { publicMigration, sha256 } from './migration.js'
32
+ import { renderInboundText } from './render.js'
33
+ import { applySettingsPatch } from './settings.js'
34
+ import {
35
+ absolutePath,
36
+ assertAllowedKeys,
37
+ assertCallBytes,
38
+ assertPlainObject,
39
+ assertProjectSegment,
40
+ boundedInteger,
41
+ callFingerprint,
42
+ createFingerprint,
43
+ fingerprintMatches,
44
+ firstLineExcerpt,
45
+ sealFingerprint,
46
+ isPlainObject,
47
+ mintOperationId,
48
+ normalizeContent,
49
+ normalizeSubject,
50
+ normalizeTargets,
51
+ normalizeTask,
52
+ optionalString,
53
+ requiredString,
54
+ utf8Length,
55
+ validateOperationId,
56
+ } from './validation.js'
57
+
58
+ const NOOP_LOGGER = Object.freeze({
59
+ debug() {},
60
+ info() {},
61
+ warn() {},
62
+ error() {},
63
+ })
64
+
65
+ const DAY_MS = 24 * 60 * 60 * 1000
66
+ const ACTIVE_QUEUE_STATUSES = new Set(['pending', 'held', 'dispatching'])
67
+ const RECEIPT_STATUSES = new Set(['accepted', 'dispatching', 'unknown'])
68
+ const SOURCE_KEYS = Object.freeze(['kind', 'form', 'senderSessionId'])
69
+
70
+ /**
71
+ * Grant the verified inbound business text on an already-emitted card. Body
72
+ * access needs the CURRENT requested id to equal the durable host id and the
73
+ * viewer to be the recipient; a plugin-id request never grants by itself, and
74
+ * an existing card is only upgraded — never duplicated — so the result is
75
+ * independent of the order the ids arrive in.
76
+ * @param {Record<string, any>} entry
77
+ * @param {Record<string, any>} message
78
+ * @param {string} id
79
+ * @param {string} sessionId
80
+ * @param {boolean} includeBody
81
+ */
82
+ function grantInboundBody(entry, message, id, sessionId, includeBody) {
83
+ if (!includeBody || 'body' in entry) return
84
+ if (typeof message.durableMessageId !== 'string' || message.durableMessageId.length === 0) return
85
+ if (id !== message.durableMessageId || sessionId !== message.to) return
86
+ // An unknown delivery is not verified evidence: never present its body as
87
+ // confirmed text. The status metadata stays so the viewer can fall back to
88
+ // the conservative original-message / technical-details path.
89
+ if (message.status === 'unknown') return
90
+ if (message.redactedAt !== undefined && message.redactedAt !== null) {
91
+ entry.body = null
92
+ entry.bodyState = 'redacted'
93
+ return
94
+ }
95
+ entry.body = typeof message.content?.text === 'string' ? message.content.text : ''
96
+ entry.bodyState = 'available'
97
+ }
98
+
99
+ /**
100
+ * Display subject for one durable message: the sender-provided subject when
101
+ * present, otherwise a deterministic first-line excerpt of the body. No model
102
+ * call and no trust decision: the wire `subject:` header stays untrusted.
103
+ * @param {Record<string, any>} message
104
+ * @returns {string}
105
+ */
106
+ function displaySubject(message) {
107
+ if (typeof message.subject === 'string' && message.subject.length > 0) return message.subject
108
+ return firstLineExcerpt(message.content?.text, LIMITS.SUBJECT_EXCERPT_CHARS)
109
+ }
110
+
111
+ /**
112
+ * Host rejections that are proven to happen before the host starts creating
113
+ * a session. Anything outside this set may have created a session, so the
114
+ * journal stops as `uncertain` instead of guessing.
115
+ */
116
+ const PRE_CREATE_FAILURE_CODES = new Set(['workspace/not-found', 'gateway/bad-request'])
117
+
118
+ /**
119
+ * @param {unknown} error
120
+ * @param {string} [messageOverride]
121
+ * @returns {{ code: string, message: string }}
122
+ */
123
+ function createErrorView(error, messageOverride) {
124
+ const message =
125
+ messageOverride ??
126
+ (typeof error?.message === 'string' && error.message.length > 0
127
+ ? error.message
128
+ : 'the operation failed before its result could be confirmed')
129
+ return { code: errorCode(error), message: message.slice(0, 400) }
130
+ }
131
+
132
+ /**
133
+ * Pure, side-effect-free parsing of one create call: strict keys, exactly one
134
+ * location, a bounded single-line subject, and a deterministic session title.
135
+ * @param {Record<string, unknown>} request
136
+ * @returns {{ from: string, operationId: string, task: string, subject: string | undefined, resolvedTitle: string, location: Record<string, any>, fingerprint: string }}
137
+ */
138
+ function parseCreateRequest(request) {
139
+ const object = assertPlainObject(request, 'create request')
140
+ assertAllowedKeys(
141
+ object,
142
+ ['from', 'operationId', 'task', 'workspaceId', 'newProject', 'existingProject', 'title', 'subject'],
143
+ 'create request',
144
+ )
145
+ const from = requiredString(object.from, 'from', { min: 1, max: 256 })
146
+ const operationId = requiredString(object.operationId, 'operationId', { max: 128 })
147
+ const task = normalizeTask(object.task)
148
+ const subject = normalizeSubject(object.subject)
149
+ // The first message shares the existing 64 KiB content envelope: the task
150
+ // body plus the optional subject must fit together, checked before any
151
+ // journal or filesystem effect.
152
+ const contentBytes = utf8Length(JSON.stringify({ text: task, references: [], paths: [] }))
153
+ if (contentBytes > LIMITS.MAX_CONTENT_BYTES) {
154
+ throw fail(
155
+ 'invalid_argument',
156
+ `task content exceeds ${LIMITS.MAX_CONTENT_BYTES} serialized bytes; shorten the first task`,
157
+ { contentBytes, maxBytes: LIMITS.MAX_CONTENT_BYTES },
158
+ )
159
+ }
160
+ assertSubjectEnvelope(subject, contentBytes)
161
+ const title = optionalString(object.title, 'title', { max: 500 })
162
+ const hasWorkspace = object.workspaceId !== undefined && object.workspaceId !== null
163
+ const hasProject = object.newProject !== undefined && object.newProject !== null
164
+ const hasExisting = object.existingProject !== undefined && object.existingProject !== null
165
+ const locationCount = Number(hasWorkspace) + Number(hasProject) + Number(hasExisting)
166
+ if (locationCount !== 1) {
167
+ throw fail('invalid_argument', 'provide exactly one of workspaceId, newProject, or existingProject')
168
+ }
169
+ let location
170
+ if (hasWorkspace) {
171
+ location = {
172
+ kind: 'workspace',
173
+ workspaceId: requiredString(object.workspaceId, 'workspaceId', { min: 1, max: 256 }),
174
+ parentDir: null,
175
+ directoryName: null,
176
+ workspacePath: null,
177
+ workspaceTitle: null,
178
+ }
179
+ } else if (hasExisting) {
180
+ const existing = assertPlainObject(object.existingProject, 'existingProject')
181
+ assertAllowedKeys(existing, ['path', 'title'], 'existingProject arguments')
182
+ location = {
183
+ kind: 'existing_project',
184
+ workspaceId: null,
185
+ parentDir: null,
186
+ directoryName: null,
187
+ workspacePath: absolutePath(existing.path, 'existingProject.path'),
188
+ workspaceTitle: optionalString(existing.title, 'existingProject.title', { max: 500 }) ?? null,
189
+ }
190
+ } else {
191
+ const project = assertPlainObject(object.newProject, 'newProject')
192
+ assertAllowedKeys(project, ['parentDir', 'directoryName', 'title'], 'newProject arguments')
193
+ location = {
194
+ kind: 'new_project',
195
+ workspaceId: null,
196
+ parentDir: absolutePath(project.parentDir, 'newProject.parentDir'),
197
+ directoryName: assertProjectSegment(project.directoryName, 'newProject.directoryName'),
198
+ workspacePath: null,
199
+ workspaceTitle: optionalString(project.title, 'newProject.title', { max: 500 }) ?? null,
200
+ }
201
+ }
202
+ const resolvedTitle = title ?? firstLineExcerpt(task, LIMITS.TITLE_EXCERPT_CHARS)
203
+ const fingerprint = createFingerprint({
204
+ task,
205
+ targets: [from],
206
+ workspaceId: location.workspaceId,
207
+ parentDir: location.parentDir,
208
+ directoryName: location.directoryName,
209
+ workspacePath: location.workspacePath,
210
+ workspaceTitle: location.workspaceTitle,
211
+ title: resolvedTitle,
212
+ ...(subject === undefined ? {} : { subject }),
213
+ })
214
+ return { from, operationId, task, subject, resolvedTitle, location, fingerprint }
215
+ }
216
+
217
+ /**
218
+ * The optional subject shares the existing 64 KiB content envelope: the
219
+ * message's own contentBytes stay byte-identical to 0.3, and the subject
220
+ * bytes are added only for this call's budget check.
221
+ * @param {string | undefined} subject
222
+ * @param {number} contentBytes
223
+ */
224
+ function assertSubjectEnvelope(subject, contentBytes) {
225
+ if (subject === undefined) return
226
+ const subjectBytes = utf8Length(JSON.stringify(subject))
227
+ if (contentBytes + subjectBytes > LIMITS.MAX_CONTENT_BYTES) {
228
+ throw fail(
229
+ 'invalid_argument',
230
+ `content and subject exceed ${LIMITS.MAX_CONTENT_BYTES} serialized bytes; shorten the subject or body`,
231
+ { contentBytes, subjectBytes, maxBytes: LIMITS.MAX_CONTENT_BYTES },
232
+ )
233
+ }
234
+ }
235
+
236
+ /** @param {'context'|'legacy-accepted'} policy */
237
+ function createReceipt(policy) {
238
+ return {
239
+ policy,
240
+ state: policy === 'context' ? 'not_sent' : 'uncertain',
241
+ receivedAt: null,
242
+ receivedSeq: null,
243
+ timeoutMs: null,
244
+ transfer: null,
245
+ }
246
+ }
247
+
248
+ /**
249
+ * @param {unknown} source
250
+ * @param {string} senderSessionId
251
+ * @returns {boolean}
252
+ */
253
+ function isStandardSource(source, senderSessionId) {
254
+ if (!isPlainObject(source)) return false
255
+ const keys = Object.keys(source)
256
+ if (keys.length !== SOURCE_KEYS.length) return false
257
+ return (
258
+ keys.every((key) => SOURCE_KEYS.includes(key)) &&
259
+ source.kind === 'agent-message' &&
260
+ source.form === 'relay' &&
261
+ source.senderSessionId === senderSessionId
262
+ )
263
+ }
264
+
265
+ /** @param {Record<string, any>} hostMessage @returns {string} */
266
+ function hostMessageDigest(hostMessage) {
267
+ return sha256(Buffer.from(JSON.stringify(hostMessage), 'utf8'))
268
+ }
269
+
270
+ /** @param {unknown} content @returns {string} */
271
+ function textOfHostMessage(content) {
272
+ if (!Array.isArray(content)) return ''
273
+ return content
274
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
275
+ .map((block) => block.text)
276
+ .join('\n')
277
+ }
278
+
279
+ /**
280
+ * The native row may only be moved when it is exactly the durable message this
281
+ * ledger recorded: same host id, standard source sender, and rendered body.
282
+ * @param {Record<string, any>} message
283
+ * @param {Record<string, any>} native
284
+ * @returns {boolean}
285
+ */
286
+ function hostMessageMatchesLedger(message, native) {
287
+ if (native?.id !== message.durableMessageId) return false
288
+ if (!isStandardSource(native.source, message.from)) return false
289
+ return textOfHostMessage(native.content) === renderInboundText(message)
290
+ }
291
+
292
+ /**
293
+ * Deadline arithmetic stays inside the JavaScript Date range; an unrepresentable
294
+ * deadline is left absent instead of being clamped or fabricated.
295
+ * @param {number} receivedAt
296
+ * @param {number|null} timeoutMs
297
+ * @returns {number | undefined}
298
+ */
299
+ function representableDeadline(receivedAt, timeoutMs) {
300
+ if (!Number.isSafeInteger(receivedAt) || receivedAt < 0) return undefined
301
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) return undefined
302
+ const deadline = receivedAt + timeoutMs
303
+ if (!Number.isSafeInteger(deadline) || deadline > LIMITS.MAX_DATE_MS) return undefined
304
+ return deadline
305
+ }
306
+
307
+ /** @param {Record<string, any>} state @param {string} id */
308
+ function findMessage(state, id) {
309
+ return state.messages.find((message) => message?.id === id)
310
+ }
311
+
312
+ /** @param {Record<string, any>} state @param {string} chainId */
313
+ function chainMessages(state, chainId) {
314
+ return state.messages.filter((message) => message?.chainId === chainId)
315
+ }
316
+
317
+ /** @param {Record<string, any>} message */
318
+ function contentBytesOf(message) {
319
+ if (Number.isSafeInteger(message.contentBytes) && message.contentBytes >= 0) return message.contentBytes
320
+ try {
321
+ return Buffer.byteLength(JSON.stringify(message.content ?? {}), 'utf8')
322
+ } catch {
323
+ return 0
324
+ }
325
+ }
326
+
327
+ /** @param {Record<string, any>} state */
328
+ /** @param {string | null | undefined} subject @returns {number} */
329
+ function subjectPayloadBytes(subject) {
330
+ return typeof subject === 'string' && subject.length > 0 ? Buffer.byteLength(JSON.stringify(subject), 'utf8') : 0
331
+ }
332
+
333
+ /**
334
+ * Bytes one create journal still contributes to the 16 MiB undelivered
335
+ * envelope. A journal is deduplicated only when its matching message is
336
+ * ACTIVE and already counted on the message side; a failed/unknown/missing
337
+ * match with a retained body keeps counting so neither side can silently
338
+ * drop the payload.
339
+ * @param {Record<string, any>} state
340
+ * @param {Record<string, any> | undefined} journal
341
+ * @param {Set<string>} [operationIds]
342
+ * @returns {number}
343
+ */
344
+ function createJournalPayloadBytes(state, journal, operationIds) {
345
+ if (!isPlainObject(journal)) return 0
346
+ if (journal.bodyStripped === true) return 0
347
+ if (typeof journal.task !== 'string' || journal.task.length === 0) return 0
348
+ let match
349
+ if (typeof journal.firstMessageId === 'string' && journal.firstMessageId.length > 0) {
350
+ match = state.messages.find((candidate) => candidate.id === journal.firstMessageId)
351
+ } else {
352
+ const index = operationIds ?? new Set(state.messages.map((candidate) => candidate.operationId))
353
+ match =
354
+ typeof journal.sendOperationId === 'string' && index.has(journal.sendOperationId)
355
+ ? state.messages.find((candidate) => candidate.operationId === journal.sendOperationId)
356
+ : undefined
357
+ }
358
+ if (match !== undefined && ACTIVE_QUEUE_STATUSES.has(match.status)) return 0
359
+ return Buffer.byteLength(journal.task, 'utf8') + subjectPayloadBytes(journal.subject)
360
+ }
361
+
362
+ function queuedBytesOf(state) {
363
+ let bytes = 0
364
+ for (const message of state.messages) {
365
+ if (ACTIVE_QUEUE_STATUSES.has(message.status)) {
366
+ bytes += contentBytesOf(message) + subjectPayloadBytes(message.subject)
367
+ }
368
+ }
369
+ if (isPlainObject(state.creates)) {
370
+ const operationIds = new Set(state.messages.map((message) => message.operationId))
371
+ for (const journal of Object.values(state.creates)) {
372
+ bytes += createJournalPayloadBytes(state, journal, operationIds)
373
+ }
374
+ }
375
+ return bytes
376
+ }
377
+
378
+ /**
379
+ * Locate the create journal that owns this internal first-message
380
+ * reservation (same caller + persisted sendOperationId).
381
+ * @param {Record<string, any>} state
382
+ * @param {string} from
383
+ * @param {string} operationId
384
+ * @returns {Record<string, any> | undefined}
385
+ */
386
+ function findCreateSendReservation(state, from, operationId) {
387
+ return Object.values(state.creates ?? {}).find(
388
+ (journal) => journal?.from === from && journal?.sendOperationId === operationId,
389
+ )
390
+ }
391
+
392
+ /**
393
+ * Exact reservation match: same caller, the single actual session target,
394
+ * the stored task and subject, a plain request with no parent, references,
395
+ * or paths. Anything else must be rejected as an operationId conflict and
396
+ * may never consume the reservation.
397
+ * @param {Record<string, any> | undefined} journal
398
+ * @param {{ kind: string, targets: readonly string[], text: string, references: readonly any[], paths: readonly string[], subject: string | null, parentMessageId: string | null }} request
399
+ * @returns {boolean}
400
+ */
401
+ function createReservationExact(journal, request) {
402
+ if (!isPlainObject(journal)) return false
403
+ return (
404
+ request.kind === 'request' &&
405
+ request.parentMessageId === null &&
406
+ request.targets.length === 1 &&
407
+ request.targets[0] === journal.sessionId &&
408
+ request.text === journal.task &&
409
+ (request.subject ?? null) === (journal.subject ?? null) &&
410
+ request.references.length === 0 &&
411
+ request.paths.length === 0
412
+ )
413
+ }
414
+
415
+ /**
416
+ * Parse the mint timestamp of a `slop:<ms>:<uuid>` operationId.
417
+ * @param {unknown} operationId
418
+ * @returns {number | null}
419
+ */
420
+ function parseOperationMintTime(operationId) {
421
+ const match = /^slop:(\d+):/.exec(typeof operationId === 'string' ? operationId : '')
422
+ if (match === null) return null
423
+ const at = Number(match[1])
424
+ return Number.isSafeInteger(at) && at >= 0 ? at : null
425
+ }
426
+
427
+ /** @param {Record<string, any>} message */
428
+ function isAutomaticHeld(message) {
429
+ return message.status === 'held' && (message.heldOverflow === true || AUTO_HELD_REASONS.includes(message.heldReason))
430
+ }
431
+
432
+ /**
433
+ * @param {Record<string, any>} state
434
+ * @param {Record<string, any>} message
435
+ * @returns {string[]}
436
+ */
437
+ function dispatchBlockers(state, message) {
438
+ const blockers = []
439
+ if (!state.settings.enabled) blockers.push('disabled')
440
+ if (state.settings.paused) blockers.push('paused')
441
+ const chain = state.chains[message.chainId]
442
+ if (chain === undefined) blockers.push('chain_missing')
443
+ else if (chain.state === 'paused') blockers.push('chain_paused')
444
+ return blockers
445
+ }
446
+
447
+ /**
448
+ * Translate one adapter delivery outcome into a durable status. Only a
449
+ * synchronous pre-insertion failure may become `failed`; every ambiguous
450
+ * outcome after followup starts is `unknown`.
451
+ * @param {{ stage: string, blocked?: any, error?: any }} outcome
452
+ * @returns {{ status: 'accepted'|'failed'|'unknown'|'held'|'pending'|'cancelled', code?: string, message?: string }}
453
+ */
454
+ function classifyDeliveryOutcome(outcome) {
455
+ if (outcome.stage === 'accepted') return { status: 'accepted' }
456
+ if (outcome.stage === 'blocked') {
457
+ const action = outcome.blocked?.action
458
+ if (action === 'held' || action === 'pending' || action === 'cancelled' || action === 'failed') {
459
+ return {
460
+ status: action,
461
+ code: outcome.blocked.code ?? outcome.blocked.reason ?? 'blocked',
462
+ message: outcome.blocked.message ?? 'delivery blocked before host insertion',
463
+ }
464
+ }
465
+ return { status: 'unknown', code: 'blocked_unknown', message: 'delivery gate returned an unknown action' }
466
+ }
467
+ if (outcome.stage === 'resolve') {
468
+ return {
469
+ status: 'failed',
470
+ code: outcome.error?.code ?? 'resolve_failed',
471
+ message: 'target agent could not be resolved; nothing was inserted',
472
+ }
473
+ }
474
+ if (outcome.stage === 'followup') {
475
+ return {
476
+ status: 'unknown',
477
+ code: outcome.error?.code ?? 'followup_failed',
478
+ message: 'followup may have inserted the message; not retried automatically',
479
+ }
480
+ }
481
+ if (outcome.stage === 'flush') {
482
+ return {
483
+ status: 'unknown',
484
+ code: 'flush_failed',
485
+ message: 'host durability flush did not confirm the insertion; not retried automatically',
486
+ }
487
+ }
488
+ return { status: 'unknown', code: 'delivery_unknown', message: 'delivery outcome is unknown' }
489
+ }
490
+
491
+ export class SessionLinkEngine {
492
+ /**
493
+ * @param {{
494
+ * ledger: import('./ledger.js').Ledger,
495
+ * adapter: Record<string, any>,
496
+ * clock?: () => number,
497
+ * uuid?: () => string,
498
+ * logger?: Record<string, (...args: any[]) => void>,
499
+ * maxQueuedBytes?: number,
500
+ * }} options
501
+ */
502
+ constructor(options) {
503
+ if (options?.ledger === undefined) throw fail('internal', 'engine requires a ledger')
504
+ if (options?.adapter === undefined) throw fail('internal', 'engine requires a host adapter')
505
+ this.ledger = options.ledger
506
+ this.adapter = options.adapter
507
+ this.clock = options.clock ?? Date.now
508
+ this.uuid = options.uuid ?? randomUUID
509
+ this.logger = options.logger ?? NOOP_LOGGER
510
+ this.maxQueuedBytes = options.maxQueuedBytes ?? LIMITS.MAX_QUEUED_BYTES
511
+ if (!Number.isSafeInteger(this.maxQueuedBytes) || this.maxQueuedBytes <= 0) {
512
+ throw fail('invalid_argument', 'maxQueuedBytes must be a positive safe integer')
513
+ }
514
+ this.#slots = 0
515
+ this.#waiters = []
516
+ this.#tasks = new Set()
517
+ this.#starting = new Set()
518
+ this.#attempts = new Set()
519
+ this.#disposed = false
520
+ this.#disposePromise = undefined
521
+ this.#pumpScheduled = false
522
+ this.#receiptIndex = new Map()
523
+ this.#receiptByTarget = new Map()
524
+ this.#receiptRetryTargets = new Set()
525
+ this.#transferScheduled = false
526
+ this.#transferRunning = false
527
+ this.#createsInFlight = new Map()
528
+ this.#rebuildReceiptIndex(this.ledger.read())
529
+ this.#installReceiptObserver()
530
+ }
531
+
532
+ #slots
533
+ #waiters
534
+ #tasks
535
+ #starting
536
+ #attempts
537
+ #disposed
538
+ #disposePromise
539
+ #pumpScheduled
540
+ #receiptDispose
541
+ #receiptPoisoned
542
+ #receiptIndex
543
+ #receiptByTarget
544
+ #receiptRetryTargets
545
+ #transferScheduled
546
+ #transferRunning
547
+ #createsInFlight
548
+ #lastCleanup = { timedOut: 0, redacted: 0, prunedMessages: 0, prunedOperations: 0, prunedChains: 0, prunedCreates: 0 }
549
+
550
+ // -------------------------------------------------------------------------
551
+ // Operation tracking and storage gate
552
+ // -------------------------------------------------------------------------
553
+
554
+ /** @template T @param {() => Promise<T>} task @returns {Promise<T>} */
555
+ #trackOperation(task) {
556
+ if (this.#disposed) throw fail('disposed', 'session-link is shutting down')
557
+ if (!this.ledger.writable) throw this.#storageError()
558
+ const promise = (async () => task())()
559
+ this.#track(promise)
560
+ return promise
561
+ }
562
+
563
+ /** @template T @param {Promise<T>} promise @returns {Promise<T>} */
564
+ #track(promise) {
565
+ this.#tasks.add(promise)
566
+ promise.then(
567
+ () => this.#tasks.delete(promise),
568
+ () => this.#tasks.delete(promise),
569
+ )
570
+ return promise
571
+ }
572
+
573
+ /** @returns {never} */
574
+ #storageError() {
575
+ return fail('storage_error', 'session-link storage is in storage_error; safe reopen required', {
576
+ ...(this.ledger.storageError ?? {}),
577
+ })
578
+ }
579
+
580
+ // -------------------------------------------------------------------------
581
+ // Receipt observation
582
+ // -------------------------------------------------------------------------
583
+
584
+ /** @param {Record<string, any>} state */
585
+ #rebuildReceiptIndex(state) {
586
+ this.#receiptIndex.clear()
587
+ this.#receiptByTarget.clear()
588
+ for (const message of state.messages) {
589
+ this.#indexReceiptMessage(message)
590
+ }
591
+ }
592
+
593
+ /** @param {Record<string, any>} message */
594
+ #indexReceiptMessage(message) {
595
+ if (typeof message?.durableMessageId !== 'string' || message.durableMessageId.length === 0) return
596
+ const key = `${message.to}|${message.durableMessageId}`
597
+ if (!this.#receiptIndex.has(key)) this.#receiptIndex.set(key, message.id)
598
+ if (message.receipt?.state === 'received') return
599
+ let ids = this.#receiptByTarget.get(message.to)
600
+ if (ids === undefined) {
601
+ ids = new Set()
602
+ this.#receiptByTarget.set(message.to, ids)
603
+ }
604
+ ids.add(message.id)
605
+ }
606
+
607
+ #installReceiptObserver() {
608
+ if (typeof this.adapter.installReceiptObserver !== 'function') {
609
+ this.#receiptPoisoned = { code: 'receipt_observer_unavailable', at: this.clock() }
610
+ this.logger.warn('session-link receipt observer is unavailable; live receipts cannot be confirmed')
611
+ return
612
+ }
613
+ try {
614
+ const dispose = this.adapter.installReceiptObserver((event) => this.#onReceiptEvent(event))
615
+ this.#receiptDispose = typeof dispose === 'function' ? dispose : undefined
616
+ } catch (error) {
617
+ this.#poisonReceipt(error)
618
+ }
619
+ }
620
+
621
+ /** @param {unknown} event @returns {Promise<void>|undefined} */
622
+ #onReceiptEvent(event) {
623
+ if (this.#disposed || this.#receiptPoisoned !== undefined) return undefined
624
+ try {
625
+ const observed = this.#handleReceiptEvent(event).catch((error) => {
626
+ this.#poisonReceipt(error)
627
+ })
628
+ this.#track(observed)
629
+ // The Host adapter ignores this return value; test adapters may await it
630
+ // to keep an injected receipt deterministic against the same clock.
631
+ return observed
632
+ } catch (error) {
633
+ this.#poisonReceipt(error)
634
+ return undefined
635
+ }
636
+ }
637
+
638
+ /** @param {unknown} error */
639
+ #poisonReceipt(error) {
640
+ if (this.#receiptPoisoned !== undefined) return
641
+ this.#receiptPoisoned = { code: errorCode(error), at: this.clock() }
642
+ this.logger.error(`session-link receipt observation stopped: ${errorCode(error)}`)
643
+ }
644
+
645
+ /** @param {unknown} event @returns {Promise<void>} */
646
+ async #handleReceiptEvent(event) {
647
+ if (this.#disposed || !isPlainObject(event)) return
648
+ if (event.kind === 'message') {
649
+ await this.#observeMessage(event)
650
+ return
651
+ }
652
+ if (event.kind === 'inbox') await this.#observeInbox(event)
653
+ }
654
+
655
+ /** @param {Record<string, any>} event */
656
+ async #observeMessage(event) {
657
+ const sessionId = event.sessionId
658
+ const durableMessageId = event.messageId
659
+ const senderSessionId = event.senderSessionId
660
+ if (
661
+ typeof sessionId !== 'string' ||
662
+ typeof durableMessageId !== 'string' ||
663
+ typeof senderSessionId !== 'string' ||
664
+ !isStandardSource(event.source, senderSessionId) ||
665
+ !Number.isSafeInteger(event.seq) ||
666
+ !Number.isSafeInteger(event.time) ||
667
+ event.seq < 0 ||
668
+ event.time < 0
669
+ ) {
670
+ return
671
+ }
672
+ const ledgerMessageId = this.#receiptIndex.get(`${sessionId}|${durableMessageId}`)
673
+ if (ledgerMessageId === undefined) return
674
+ const before = this.ledger.read()
675
+ const message = findMessage(before, ledgerMessageId)
676
+ if (
677
+ message === undefined ||
678
+ message.to !== sessionId ||
679
+ message.durableMessageId !== durableMessageId ||
680
+ message.from !== senderSessionId ||
681
+ message.receipt === undefined ||
682
+ message.receipt.state === 'received' ||
683
+ !RECEIPT_STATUSES.has(message.status)
684
+ ) {
685
+ return
686
+ }
687
+ let flushed = false
688
+ try {
689
+ flushed = (await this.adapter.flushTarget(sessionId)) === true
690
+ } catch {
691
+ flushed = false
692
+ }
693
+ if (this.#disposed || this.#receiptPoisoned !== undefined) return
694
+ if (!flushed) {
695
+ this.#receiptRetryTargets.add(sessionId)
696
+ return
697
+ }
698
+ await this.#mutate((state) => {
699
+ const current = findMessage(state, ledgerMessageId)
700
+ if (
701
+ current === undefined ||
702
+ current.to !== sessionId ||
703
+ current.durableMessageId !== durableMessageId ||
704
+ current.from !== senderSessionId ||
705
+ current.receipt === undefined ||
706
+ current.receipt.state === 'received' ||
707
+ !RECEIPT_STATUSES.has(current.status)
708
+ ) {
709
+ return skipMutation(undefined)
710
+ }
711
+ return this.#markReceivedInState(state, current, event.time, event.seq, this.clock()) ? true : skipMutation(undefined)
712
+ })
713
+ this.#receiptRetryTargets.delete(sessionId)
714
+ }
715
+
716
+ /**
717
+ * @param {Record<string, any>} state
718
+ * @param {Record<string, any>} message
719
+ * @param {number} receivedAt
720
+ * @param {number} receivedSeq
721
+ * @param {number} now
722
+ * @returns {boolean}
723
+ */
724
+ #markReceivedInState(state, message, receivedAt, receivedSeq, now) {
725
+ const receipt = message.receipt
726
+ if (receipt === undefined || receipt.state === 'received') return false
727
+ receipt.state = 'received'
728
+ receipt.receivedAt = receivedAt
729
+ receipt.receivedSeq = receivedSeq
730
+ if (receipt.policy === 'context' && message.status === 'accepted') {
731
+ if (message.kind === 'request') {
732
+ this.#applyDeadlineFromReceipt(state, message)
733
+ } else if (message.kind === 'notification' || message.kind === 'reply') {
734
+ message.completedAt = receivedAt
735
+ }
736
+ }
737
+ const journal = state.transfers?.[message.id]
738
+ if (journal !== undefined && journal.state === 'prepared') {
739
+ journal.state = 'moved'
740
+ journal.at = now
741
+ delete journal.error
742
+ receipt.transfer = { state: 'moved', at: now }
743
+ }
744
+ return true
745
+ }
746
+
747
+ /**
748
+ * @param {Record<string, any>} state
749
+ * @param {Record<string, any>} message
750
+ */
751
+ #applyDeadlineFromReceipt(state, message) {
752
+ const receipt = message.receipt
753
+ if (receipt?.state !== 'received' || !Number.isSafeInteger(receipt.receivedAt)) return
754
+ if (message.kind !== 'request' || message.status !== 'accepted' || message.replyStatus !== 'waiting') return
755
+ if (Number.isSafeInteger(message.deadlineAt)) return
756
+ const timeoutMs = Number.isSafeInteger(receipt.timeoutMs) ? receipt.timeoutMs : state.settings.requestTimeoutMs
757
+ const deadlineAt = representableDeadline(receipt.receivedAt, timeoutMs)
758
+ if (deadlineAt !== undefined) message.deadlineAt = deadlineAt
759
+ }
760
+
761
+ /** @param {Record<string, any>} event */
762
+ async #observeInbox(event) {
763
+ const sessionId = event.sessionId
764
+ if (typeof sessionId !== 'string') return
765
+ const ids = this.#receiptByTarget.get(sessionId)
766
+ if (ids === undefined || ids.size === 0) return
767
+ const snapshot = this.ledger.read()
768
+ const checks = []
769
+ for (const id of ids) {
770
+ const message = findMessage(snapshot, id)
771
+ if (message === undefined || message.status !== 'accepted' || message.receipt?.state === 'received') continue
772
+ if (message.receipt === undefined) continue
773
+ const journal = snapshot.transfers?.[message.id]
774
+ if (journal?.state === 'prepared') continue
775
+ let presence
776
+ try {
777
+ presence = this.adapter.nativePending(sessionId, message.durableMessageId)
778
+ } catch {
779
+ continue
780
+ }
781
+ if (!isPlainObject(presence) || presence.known !== true) continue
782
+ checks.push({ id, pending: presence.pending === true })
783
+ }
784
+ if (checks.length === 0) return
785
+ await this.#mutate((state) => {
786
+ let changed = false
787
+ for (const check of checks) {
788
+ const message = findMessage(state, check.id)
789
+ if (message === undefined || message.status !== 'accepted') continue
790
+ const receipt = message.receipt
791
+ if (receipt === undefined || receipt.state === 'received') continue
792
+ if (check.pending) {
793
+ if (receipt.state !== 'queued') {
794
+ receipt.state = 'queued'
795
+ changed = true
796
+ }
797
+ } else if (receipt.state !== 'uncertain') {
798
+ receipt.state = 'uncertain'
799
+ changed = true
800
+ }
801
+ }
802
+ return changed ? true : skipMutation(undefined)
803
+ })
804
+ }
805
+
806
+ /**
807
+ * Cold, grouped catch-up. One target is read once; all candidate messages are
808
+ * matched from that single index. Never scans the whole history per message.
809
+ * @returns {Promise<void>}
810
+ */
811
+ async #catchUpReceipts() {
812
+ const state = this.ledger.read()
813
+ const groups = new Map()
814
+ for (const message of state.messages) {
815
+ if (typeof message.durableMessageId !== 'string' || message.durableMessageId.length === 0) continue
816
+ if (message.receipt === undefined || message.receipt.state === 'received') continue
817
+ if (!RECEIPT_STATUSES.has(message.status)) continue
818
+ let ids = groups.get(message.to)
819
+ if (ids === undefined) {
820
+ ids = []
821
+ groups.set(message.to, ids)
822
+ }
823
+ ids.push(message.id)
824
+ }
825
+ for (const [targetId, ids] of groups) {
826
+ await this.#catchUpTarget(targetId, ids)
827
+ }
828
+ }
829
+
830
+ /**
831
+ * @param {string} targetId
832
+ * @param {string[]} messageIds
833
+ * @returns {Promise<void>}
834
+ */
835
+ async #catchUpTarget(targetId, messageIds) {
836
+ if (typeof this.adapter.readTargetReceipts !== 'function') return
837
+ let result
838
+ try {
839
+ result = await this.adapter.readTargetReceipts(targetId)
840
+ } catch (error) {
841
+ result = { readable: false, errorCode: errorCode(error) }
842
+ }
843
+ if (this.#disposed) return
844
+ if (result?.readable !== true) {
845
+ this.#receiptRetryTargets.add(targetId)
846
+ return
847
+ }
848
+ const evidence = new Map()
849
+ for (const entry of result.messages ?? []) {
850
+ if (isPlainObject(entry) && typeof entry.id === 'string') evidence.set(entry.id, entry)
851
+ }
852
+ const pending = new Set(Array.isArray(result.pendingIds) ? result.pendingIds : [])
853
+ const pendingKnown = result.pendingKnown === true
854
+ await this.#mutate((state) => {
855
+ let changed = false
856
+ for (const id of messageIds) {
857
+ const message = findMessage(state, id)
858
+ if (message === undefined || message.receipt === undefined || message.receipt.state === 'received') continue
859
+ const hit = evidence.get(message.durableMessageId)
860
+ if (
861
+ hit !== undefined &&
862
+ isStandardSource(hit.source, message.from) &&
863
+ Number.isSafeInteger(hit.seq) &&
864
+ hit.seq >= 0 &&
865
+ Number.isSafeInteger(hit.time) &&
866
+ hit.time >= 0
867
+ ) {
868
+ if (this.#markReceivedInState(state, message, hit.time, hit.seq, this.clock())) changed = true
869
+ continue
870
+ }
871
+ if (message.status !== 'accepted' || pendingKnown !== true) continue
872
+ if (pending.has(message.durableMessageId)) {
873
+ if (message.receipt.state !== 'queued') {
874
+ message.receipt.state = 'queued'
875
+ changed = true
876
+ }
877
+ } else if (message.receipt.state !== 'uncertain') {
878
+ message.receipt.state = 'uncertain'
879
+ changed = true
880
+ }
881
+ }
882
+ return changed ? true : skipMutation(undefined)
883
+ })
884
+ this.#receiptRetryTargets.delete(targetId)
885
+ }
886
+
887
+ /** @returns {Promise<void>} */
888
+ async #retryReceiptTargets() {
889
+ if (this.#receiptRetryTargets.size === 0) return
890
+ const targets = [...this.#receiptRetryTargets].slice(0, LIMITS.RECEIPT_RETRY_TARGETS)
891
+ for (const targetId of targets) {
892
+ if (this.#disposed) return
893
+ const state = this.ledger.read()
894
+ const ids = state.messages
895
+ .filter(
896
+ (message) =>
897
+ message.to === targetId &&
898
+ typeof message.durableMessageId === 'string' &&
899
+ message.receipt !== undefined &&
900
+ message.receipt.state !== 'received' &&
901
+ RECEIPT_STATUSES.has(message.status),
902
+ )
903
+ .map((message) => message.id)
904
+ if (ids.length === 0) {
905
+ this.#receiptRetryTargets.delete(targetId)
906
+ continue
907
+ }
908
+ await this.#catchUpTarget(targetId, ids)
909
+ }
910
+ }
911
+
912
+ /**
913
+ * @template T
914
+ * @param {(state: Record<string, any>) => T} task
915
+ * @param {{ expectedRevision?: number, delivery?: boolean }} [options]
916
+ * @returns {Promise<T>}
917
+ */
918
+ #mutate(task, options = {}) {
919
+ if (this.#disposed) throw fail('disposed', 'session-link is shutting down')
920
+ if (!this.ledger.writable) throw this.#storageError()
921
+ return this.ledger.mutate(task, options)
922
+ }
923
+
924
+ /**
925
+ * @param {number} additionalBytes
926
+ * @param {Record<string, any>} state
927
+ */
928
+ #assertQueueCapacity(state, additionalBytes, reservation) {
929
+ // A create journal's own reservation is subtracted only for an exact
930
+ // match, so the create-to-send transfer counts the payload exactly once
931
+ // and a mismatched request is charged in full.
932
+ const reservationBytes = reservation === undefined ? 0 : createJournalPayloadBytes(state, reservation)
933
+ const current = queuedBytesOf(state) - reservationBytes
934
+ if (current + additionalBytes > this.maxQueuedBytes) {
935
+ throw fail('queue_full', 'session-link undelivered payload capacity is full', {
936
+ queuedBytes: current,
937
+ additionalBytes,
938
+ maxQueuedBytes: this.maxQueuedBytes,
939
+ })
940
+ }
941
+ }
942
+
943
+ // -------------------------------------------------------------------------
944
+ // Startup recovery
945
+ // -------------------------------------------------------------------------
946
+
947
+ /**
948
+ * Cold reconcile every in-flight row without resolving an Agent. No evidence
949
+ * means unknown, never a resend. Then reconcile settings-driven held rows.
950
+ * @returns {Promise<Record<string, any>>}
951
+ */
952
+ async start() {
953
+ return this.#trackOperation(async () => {
954
+ const now = this.clock()
955
+ const before = this.ledger.read()
956
+ const dispatching = before.messages.filter((message) => message.status === 'dispatching')
957
+ const evidence = new Map()
958
+ for (const message of dispatching) {
959
+ try {
960
+ const result = await this.adapter.readEvidence({
961
+ targetId: message.to,
962
+ durableMessageId: message.durableMessageId,
963
+ senderSessionId: message.from,
964
+ })
965
+ evidence.set(message.id, result)
966
+ } catch (error) {
967
+ evidence.set(message.id, { found: false, readable: false, errorCode: errorCode(error) })
968
+ }
969
+ }
970
+ const result = await this.#mutate((state) => {
971
+ let recovered = 0
972
+ let unknown = 0
973
+ let changed = false
974
+ // A journal still in `creating` at startup means an external effect
975
+ // may have run without a recorded result. It becomes uncertain and
976
+ // is never re-run automatically, whatever a reserved id suggests.
977
+ for (const journal of Object.values(state.creates ?? {})) {
978
+ if (journal?.phase !== 'creating') continue
979
+ journal.phase = 'uncertain'
980
+ journal.error = {
981
+ code: 'create_interrupted',
982
+ message:
983
+ 'the plugin restarted while a create effect may have been in flight; verify the workspace/session manually before retrying',
984
+ }
985
+ journal.updatedAt = now
986
+ changed = true
987
+ }
988
+ for (const message of dispatching) {
989
+ const current = findMessage(state, message.id)
990
+ if (current?.status !== 'dispatching') continue
991
+ const evidenceItem = evidence.get(message.id)
992
+ if (evidenceItem?.found === true && evidenceItem.identityConflict !== true) {
993
+ this.#acceptMessage(state, current, now, {
994
+ recoveryEvidence: evidenceItem.evidence ?? 'unknown',
995
+ inboxSeen: evidenceItem.inboxSeen === true,
996
+ })
997
+ if (
998
+ evidenceItem.evidence === 'message' &&
999
+ Number.isSafeInteger(evidenceItem.seq) &&
1000
+ Number.isSafeInteger(evidenceItem.time) &&
1001
+ evidenceItem.seq >= 0 &&
1002
+ evidenceItem.time >= 0
1003
+ ) {
1004
+ this.#markReceivedInState(state, current, evidenceItem.time, evidenceItem.seq, now)
1005
+ }
1006
+ recovered += 1
1007
+ changed = true
1008
+ continue
1009
+ }
1010
+ this.#markUnknownInState(state, current, now, {
1011
+ code:
1012
+ evidenceItem?.identityConflict === true
1013
+ ? 'identity_conflict'
1014
+ : evidenceItem?.invalidTarget === true
1015
+ ? 'target_unavailable'
1016
+ : evidenceItem?.readable === false
1017
+ ? 'evidence_unreadable'
1018
+ : 'no_insertion_evidence',
1019
+ message:
1020
+ evidenceItem?.identityConflict === true
1021
+ ? 'a different sender identity used the same durable message id'
1022
+ : evidenceItem?.invalidTarget === true
1023
+ ? 'target is no longer an eligible ordinary session'
1024
+ : evidenceItem?.readable === false
1025
+ ? 'target history could not be read; treating the delivery as unknown'
1026
+ : 'no insertion evidence was found; the delivery is unknown and was not retried',
1027
+ })
1028
+ unknown += 1
1029
+ changed = true
1030
+ }
1031
+ const cleaned = this.#cleanupState(state, now)
1032
+ changed = changed || cleaned
1033
+ const reconciled = this.#reconcileGlobalQueue(state, now)
1034
+ changed = changed || reconciled.queued > 0 || reconciled.resumed > 0
1035
+ if (!changed) return skipMutation({ dispatching: dispatching.length, recovered, unknown, ...this.#lastCleanup })
1036
+ return { dispatching: dispatching.length, recovered, unknown, ...this.#lastCleanup }
1037
+ })
1038
+ this.#rebuildReceiptIndex(this.ledger.read())
1039
+ await this.#catchUpReceipts()
1040
+ await this.#recoverTransferJournals()
1041
+ this.#rebuildReceiptIndex(this.ledger.read())
1042
+ this.#schedulePump()
1043
+ this.#scheduleTransfer()
1044
+ return result
1045
+ })
1046
+ }
1047
+
1048
+ // -------------------------------------------------------------------------
1049
+ // Timeouts, retention, and disk hygiene
1050
+ // -------------------------------------------------------------------------
1051
+
1052
+ /**
1053
+ * Periodic sweep: request deadlines, body retention, metadata pruning.
1054
+ * @returns {Promise<Record<string, any>>}
1055
+ */
1056
+ async tick() {
1057
+ if (this.#disposed) return { skipped: true }
1058
+ if (!this.ledger.writable) return { skipped: true, storageError: true }
1059
+ return this.#trackOperation(async () => {
1060
+ const now = this.clock()
1061
+ const summary = await this.#mutate((state) => {
1062
+ const changed = this.#cleanupState(state, now)
1063
+ if (!changed) return skipMutation({ timedOut: 0, redacted: 0, prunedMessages: 0, prunedOperations: 0, prunedChains: 0, prunedCreates: 0 })
1064
+ return { ...this.#lastCleanup }
1065
+ })
1066
+ this.#rebuildReceiptIndex(this.ledger.read())
1067
+ await this.#retryReceiptTargets()
1068
+ this.#schedulePump()
1069
+ this.#scheduleTransfer()
1070
+ return summary
1071
+ })
1072
+ }
1073
+
1074
+ /**
1075
+ * @param {Record<string, any>} state
1076
+ * @param {number} now
1077
+ * @returns {boolean}
1078
+ */
1079
+ #cleanupState(state, now) {
1080
+ let changed = false
1081
+ let timedOut = 0
1082
+ for (const message of state.messages) {
1083
+ if (
1084
+ message.kind === 'request' &&
1085
+ message.status === 'accepted' &&
1086
+ message.replyStatus === 'waiting' &&
1087
+ Number.isSafeInteger(message.deadlineAt) &&
1088
+ message.deadlineAt <= now
1089
+ ) {
1090
+ const chain = state.chains[message.chainId]
1091
+ if (chain?.state !== 'closed') {
1092
+ message.replyStatus = 'timed_out'
1093
+ message.timedOutAt = message.deadlineAt
1094
+ message.completedAt = message.timedOutAt
1095
+ timedOut += 1
1096
+ changed = true
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ const retentionMs = state.settings.retentionDays * DAY_MS
1102
+ let redacted = 0
1103
+ for (const message of state.messages) {
1104
+ if (message.redactedAt !== undefined) continue
1105
+ if (message.content.text === '' && message.content.references.length === 0 && message.content.paths.length === 0) continue
1106
+ const completedAt = this.#completedAtFor(message, state)
1107
+ if (completedAt === undefined) continue
1108
+ if (completedAt + retentionMs > now) continue
1109
+ if (ACTIVE_QUEUE_STATUSES.has(message.status) || message.status === 'unknown') continue
1110
+ message.content = { text: '', references: [], paths: [] }
1111
+ message.contentBytes = 2
1112
+ message.redactedAt = now
1113
+ // Body retention applies to the 0.4 subject too; messages without the
1114
+ // field (0.3 rows) are byte-for-byte untouched.
1115
+ if (message.subject !== undefined) message.subject = null
1116
+ // Body retention applies to the private journal too. A completed move no
1117
+ // longer needs recovery evidence; an unresolved journal keeps its
1118
+ // identity/digest but loses the copied body once the record is redacted.
1119
+ const journal = state.transfers?.[message.id]
1120
+ if (journal?.state === 'moved') delete state.transfers[message.id]
1121
+ else if (journal !== undefined) journal.hostMessage.content = []
1122
+ redacted += 1
1123
+ changed = true
1124
+ }
1125
+
1126
+ // Verified terminal completions, captured AFTER this round's timeout and
1127
+ // redaction updates but BEFORE any message pruning. A journal whose first
1128
+ // message is pruned right now may only age out from this same-round
1129
+ // evidence; a message already missing in an earlier round must never
1130
+ // masquerade as verified.
1131
+ const terminalCompletedAt = new Map()
1132
+ if (isPlainObject(state.creates) && Object.keys(state.creates).length > 0) {
1133
+ for (const message of state.messages) {
1134
+ if (message.status === 'unknown' || ACTIVE_QUEUE_STATUSES.has(message.status)) continue
1135
+ const completedAt = this.#completedAtFor(message, state)
1136
+ if (Number.isSafeInteger(completedAt)) terminalCompletedAt.set(message.id, completedAt)
1137
+ }
1138
+ }
1139
+
1140
+ const metadataMs = Math.max(LIMITS.MESSAGE_RETENTION_MS, retentionMs)
1141
+ const messagesByChain = new Map()
1142
+ for (const message of state.messages) {
1143
+ let entries = messagesByChain.get(message.chainId)
1144
+ if (entries === undefined) {
1145
+ entries = []
1146
+ messagesByChain.set(message.chainId, entries)
1147
+ }
1148
+ entries.push(message)
1149
+ }
1150
+ let prunedMessages = 0
1151
+ let prunedChains = 0
1152
+ const prunedIds = new Set()
1153
+ const prunedChainIds = []
1154
+ for (const [chainId, chain] of Object.entries(state.chains)) {
1155
+ if (!isPlainObject(chain)) continue
1156
+ const messages = messagesByChain.get(chainId) ?? []
1157
+ if (messages.length === 0) {
1158
+ if (chain.state === 'closed' && Number.isSafeInteger(chain.closedAt) && chain.closedAt + metadataMs <= now) {
1159
+ prunedChainIds.push(chainId)
1160
+ changed = true
1161
+ }
1162
+ continue
1163
+ }
1164
+ let terminal = true
1165
+ for (const message of messages) {
1166
+ if (message.status === 'unknown' || ACTIVE_QUEUE_STATUSES.has(message.status)) {
1167
+ terminal = false
1168
+ break
1169
+ }
1170
+ const completedAt = this.#completedAtFor(message, state)
1171
+ if (completedAt === undefined || completedAt + metadataMs > now) {
1172
+ terminal = false
1173
+ break
1174
+ }
1175
+ }
1176
+ if (!terminal) continue
1177
+ for (const message of messages) prunedIds.add(message.id)
1178
+ prunedMessages += messages.length
1179
+ prunedChainIds.push(chainId)
1180
+ changed = true
1181
+ }
1182
+ if (prunedIds.size > 0) {
1183
+ state.messages = state.messages.filter((message) => !prunedIds.has(message.id))
1184
+ if (isPlainObject(state.transfers)) {
1185
+ for (const id of prunedIds) delete state.transfers[id]
1186
+ }
1187
+ }
1188
+ for (const chainId of prunedChainIds) {
1189
+ delete state.chains[chainId]
1190
+ prunedChains += 1
1191
+ }
1192
+
1193
+ const operationCutoff = now - LIMITS.OPERATION_RETENTION_MS
1194
+ let prunedOperations = 0
1195
+ const operations = {}
1196
+ for (const [key, operation] of Object.entries(state.operations)) {
1197
+ if (isPlainObject(operation) && Number.isSafeInteger(operation.at) && operation.at < operationCutoff) {
1198
+ prunedOperations += 1
1199
+ changed = true
1200
+ continue
1201
+ }
1202
+ operations[key] = operation
1203
+ }
1204
+ state.operations = operations
1205
+
1206
+ // Create journals: the private task body is dropped as soon as the first
1207
+ // message exists; only terminal records age out after the same 90-day
1208
+ // operation window, and non-terminal/uncertain records are never cleaned.
1209
+ // A pruned record cannot revive an old operationId: first submission
1210
+ // still has to pass the 24-hour operationId window.
1211
+ let prunedCreates = 0
1212
+ if (isPlainObject(state.creates)) {
1213
+ const keptCreates = {}
1214
+ for (const [key, journal] of Object.entries(state.creates)) {
1215
+ if (!isPlainObject(journal)) {
1216
+ keptCreates[key] = journal
1217
+ continue
1218
+ }
1219
+ if (journal.phase === 'sent' && journal.bodyStripped !== true && journal.firstMessageId !== null) {
1220
+ journal.task = ''
1221
+ journal.subject = null
1222
+ journal.bodyStripped = true
1223
+ journal.updatedAt = now
1224
+ changed = true
1225
+ }
1226
+ if (
1227
+ CREATE_TERMINAL_PHASES.includes(journal.phase) &&
1228
+ Number.isSafeInteger(journal.terminalAt) &&
1229
+ journal.terminalAt < operationCutoff
1230
+ ) {
1231
+ const firstId = journal.firstMessageId
1232
+ const hasFirst = typeof firstId === 'string' && firstId.length > 0
1233
+ let mayPrune = !hasFirst
1234
+ if (hasFirst) {
1235
+ const linked = state.messages.find((candidate) => candidate.id === firstId)
1236
+ if (linked !== undefined) {
1237
+ if (linked.status === 'unknown' || ACTIVE_QUEUE_STATUSES.has(linked.status)) {
1238
+ // Unknown and undelivered first messages keep their root
1239
+ // association forever, exactly like the message side.
1240
+ mayPrune = false
1241
+ } else {
1242
+ // A real terminal state ages from its own completion time,
1243
+ // never from the earlier root terminalAt anchor.
1244
+ const completedAt = terminalCompletedAt.get(firstId)
1245
+ mayPrune = Number.isSafeInteger(completedAt) && completedAt < operationCutoff
1246
+ }
1247
+ } else {
1248
+ const verified = terminalCompletedAt.get(firstId)
1249
+ if (verified === undefined) {
1250
+ // Missing without same-round verified terminal evidence:
1251
+ // fail closed, keep the journal, and flag it for review.
1252
+ mayPrune = false
1253
+ if (journal.error === null) {
1254
+ journal.error = {
1255
+ code: 'first_message_evidence_missing',
1256
+ message:
1257
+ 'the associated first message cannot be verified; this create journal is kept for manual review',
1258
+ }
1259
+ journal.updatedAt = now
1260
+ changed = true
1261
+ }
1262
+ } else {
1263
+ mayPrune = verified < operationCutoff
1264
+ }
1265
+ }
1266
+ }
1267
+ if (mayPrune) {
1268
+ prunedCreates += 1
1269
+ changed = true
1270
+ continue
1271
+ }
1272
+ }
1273
+ keptCreates[key] = journal
1274
+ }
1275
+ state.creates = keptCreates
1276
+ }
1277
+
1278
+ this.#lastCleanup = { timedOut, redacted, prunedMessages, prunedOperations, prunedChains, prunedCreates }
1279
+ return changed
1280
+ }
1281
+
1282
+ /**
1283
+ * Completion time anchored on explicit terminal evidence. An open request
1284
+ * still waiting for a reply is not terminal; answered/timed-out/ended or a
1285
+ * request in an explicitly closed group is. Missing evidence stays absent
1286
+ * and retention keeps the record.
1287
+ * @param {Record<string, any>} message
1288
+ * @param {Record<string, any>} state
1289
+ * @returns {number | undefined}
1290
+ */
1291
+ #completedAtFor(message, state) {
1292
+ if (Number.isSafeInteger(message.completedAt)) return message.completedAt
1293
+ if (message.status === 'failed' && Number.isSafeInteger(message.failedAt)) return message.failedAt
1294
+ if (message.status === 'cancelled' && Number.isSafeInteger(message.cancelledAt)) return message.cancelledAt
1295
+ if (message.kind === 'notification' || message.kind === 'reply') {
1296
+ if (message.receipt?.policy === 'context') {
1297
+ return message.receipt.state === 'received' && Number.isSafeInteger(message.receipt.receivedAt)
1298
+ ? message.receipt.receivedAt
1299
+ : undefined
1300
+ }
1301
+ return message.status === 'accepted' && Number.isSafeInteger(message.acceptedAt) ? message.acceptedAt : undefined
1302
+ }
1303
+ if (message.kind === 'request') {
1304
+ if (message.replyStatus === 'answered' && Number.isSafeInteger(message.answeredAt)) return message.answeredAt
1305
+ if (message.replyStatus === 'timed_out' && Number.isSafeInteger(message.timedOutAt)) return message.timedOutAt
1306
+ if (message.status === 'accepted' && message.replyStatus === 'waiting') {
1307
+ const chain = state.chains[message.chainId]
1308
+ if (chain?.state === 'closed' && Number.isSafeInteger(chain.closedAt)) return chain.closedAt
1309
+ }
1310
+ }
1311
+ return undefined
1312
+ }
1313
+
1314
+ /**
1315
+ * @param {Record<string, any>} state
1316
+ * @param {number} now
1317
+ * @returns {{ queued: number, resumed: number }}
1318
+ */
1319
+ #reconcileGlobalQueue(state, now) {
1320
+ let queued = 0
1321
+ let resumed = 0
1322
+ const settings = state.settings
1323
+ if (!settings.enabled) {
1324
+ // Disabling is a durable queueing transition, not just a read-time gate:
1325
+ // pending rows become held('disabled') in the same settings mutation so
1326
+ // the panel state machine never claims an undelivered row is waiting to
1327
+ // dispatch while session-link is closed.
1328
+ for (const message of state.messages) {
1329
+ if (message.status !== 'pending') continue
1330
+ if (state.chains[message.chainId] === undefined) continue
1331
+ message.status = 'held'
1332
+ message.heldAt = now
1333
+ message.heldReason = 'disabled'
1334
+ message.error = { code: 'disabled', message: 'held until session-link is enabled again' }
1335
+ delete message.heldOverflow
1336
+ message.releaseAuthorized = false
1337
+ queued += 1
1338
+ }
1339
+ } else if (settings.paused) {
1340
+ for (const message of state.messages) {
1341
+ if (message.status !== 'pending') continue
1342
+ if (state.chains[message.chainId]?.state !== 'open') continue
1343
+ message.status = 'held'
1344
+ message.heldAt = now
1345
+ message.heldReason = 'paused'
1346
+ message.error = { code: 'paused', message: 'held until the global pause is cleared' }
1347
+ delete message.heldOverflow
1348
+ message.releaseAuthorized = false
1349
+ queued += 1
1350
+ }
1351
+ } else {
1352
+ for (const message of state.messages) {
1353
+ if (!isAutomaticHeld(message)) continue
1354
+ if (state.chains[message.chainId]?.state !== 'open') continue
1355
+ message.status = 'pending'
1356
+ message.heldAt = null
1357
+ message.heldReason = null
1358
+ message.autoResumedAt = now
1359
+ message.error = null
1360
+ delete message.heldOverflow
1361
+ message.releaseAuthorized = false
1362
+ resumed += 1
1363
+ }
1364
+ }
1365
+ return { queued, resumed }
1366
+ }
1367
+
1368
+ /**
1369
+ * On owner resume of one chain, automatic global held rows can continue when
1370
+ * the global settings are open. Owner/unknown legacy held rows stay.
1371
+ * @param {Record<string, any>} state
1372
+ * @param {Record<string, any>} chain
1373
+ * @param {number} now
1374
+ * @returns {number}
1375
+ */
1376
+ #resumeChainHeld(state, chain, now) {
1377
+ if (!state.settings.enabled || state.settings.paused) return 0
1378
+ let resumed = 0
1379
+ for (const message of state.messages) {
1380
+ if (message.chainId !== chain.id) continue
1381
+ if (!isAutomaticHeld(message)) continue
1382
+ message.status = 'pending'
1383
+ message.heldAt = null
1384
+ message.heldReason = null
1385
+ message.autoResumedAt = now
1386
+ message.error = null
1387
+ delete message.heldOverflow
1388
+ message.releaseAuthorized = false
1389
+ resumed += 1
1390
+ }
1391
+ return resumed
1392
+ }
1393
+
1394
+ // -------------------------------------------------------------------------
1395
+ // Send / reply
1396
+ // -------------------------------------------------------------------------
1397
+
1398
+ /**
1399
+ * @param {{
1400
+ * from: string,
1401
+ * fromTitle?: string,
1402
+ * targets: unknown,
1403
+ * kind: string,
1404
+ * text: unknown,
1405
+ * operationId: string,
1406
+ * parentMessageId?: string,
1407
+ * references?: unknown,
1408
+ * paths?: unknown,
1409
+ * }} request
1410
+ * @returns {Promise<Record<string, any>>}
1411
+ */
1412
+ async send(request) {
1413
+ const result = await this.#trackOperation(() => this.#send(request))
1414
+ this.#schedulePump()
1415
+ return result
1416
+ }
1417
+
1418
+ async #send(request) {
1419
+ const object = assertPlainObject(request, 'send request')
1420
+ assertAllowedKeys(
1421
+ object,
1422
+ ['from', 'fromTitle', 'targets', 'kind', 'text', 'operationId', 'parentMessageId', 'references', 'paths', 'subject'],
1423
+ 'send request',
1424
+ )
1425
+ const from = requiredString(object.from, 'from', { min: 1, max: 256 })
1426
+ const kind = requiredString(object.kind, 'kind', { max: 32 })
1427
+ if (kind !== 'notification' && kind !== 'request') {
1428
+ throw fail('invalid_argument', 'send accepts only notification or request; replies use session_link_reply')
1429
+ }
1430
+ const targets = normalizeTargets(object.targets, from)
1431
+ const operationId = requiredString(object.operationId, 'operationId', { max: 128 })
1432
+ const parentMessageId = optionalString(object.parentMessageId, 'parentMessageId', { max: 256 })
1433
+ const subject = normalizeSubject(object.subject)
1434
+ const content = normalizeContent({
1435
+ text: object.text,
1436
+ references: object.references,
1437
+ paths: object.paths,
1438
+ })
1439
+ assertSubjectEnvelope(subject, content.bytes)
1440
+ assertCallBytes(
1441
+ {
1442
+ targets: object.targets,
1443
+ kind,
1444
+ text: object.text,
1445
+ operationId: object.operationId,
1446
+ ...(object.parentMessageId === undefined ? {} : { parentMessageId: object.parentMessageId }),
1447
+ ...(object.references === undefined ? {} : { references: object.references }),
1448
+ ...(object.paths === undefined ? {} : { paths: object.paths }),
1449
+ ...(subject === undefined ? {} : { subject }),
1450
+ },
1451
+ 'send call',
1452
+ )
1453
+ const fingerprint = callFingerprint({
1454
+ kind,
1455
+ text: content.text,
1456
+ references: content.references,
1457
+ paths: content.paths,
1458
+ parentMessageId: parentMessageId ?? null,
1459
+ targets,
1460
+ subject,
1461
+ })
1462
+ // An operationId owned by a create journal's first-message reservation is
1463
+ // only usable for the exact reserved dispatch; anything else conflicts
1464
+ // before it could consume or overwrite the reservation.
1465
+ const existingState = this.ledger.read()
1466
+ const sendReservation = findCreateSendReservation(existingState, from, operationId)
1467
+ if (
1468
+ sendReservation !== undefined &&
1469
+ !createReservationExact(sendReservation, {
1470
+ kind,
1471
+ targets,
1472
+ text: content.text,
1473
+ references: content.references,
1474
+ paths: content.paths,
1475
+ subject: subject ?? null,
1476
+ parentMessageId: parentMessageId ?? null,
1477
+ })
1478
+ ) {
1479
+ throw fail('conflict_operation', 'operationId is reserved for a create first message; the payload must match exactly', {
1480
+ operationId,
1481
+ })
1482
+ }
1483
+ // Idempotent retry must win before eligibility is rechecked: a target may
1484
+ // have been archived since the original call, yet the caller is entitled
1485
+ // to the original per-target statuses and no resend.
1486
+ const existingOperation = this.#lookupOperation(existingState, from, operationId)
1487
+ if (existingOperation !== undefined) {
1488
+ if (!fingerprintMatches(existingOperation.fingerprint, fingerprint)) {
1489
+ throw fail('conflict_operation', 'operationId was already used with different content, kind, targets, or parent', {
1490
+ operationId,
1491
+ })
1492
+ }
1493
+ return this.#operationResult(existingState, existingOperation)
1494
+ }
1495
+ await this.#requireSender(from)
1496
+ const targetInfo = new Map()
1497
+ for (const target of targets) targetInfo.set(target, await this.#requireTarget(target))
1498
+ const fromInfo = await this.adapter.getSession(from, { fresh: true })
1499
+ const fromTitle = optionalString(object.fromTitle, 'fromTitle', { max: 500 }) ?? fromInfo?.title ?? from
1500
+
1501
+ const now = this.clock()
1502
+ let created = []
1503
+ const result = await this.#mutate(
1504
+ (state) => {
1505
+ const duplicate = this.#lookupOperation(state, from, operationId)
1506
+ if (duplicate !== undefined) {
1507
+ if (!fingerprintMatches(duplicate.fingerprint, fingerprint)) {
1508
+ throw fail('conflict_operation', 'operationId was already used with different content, kind, targets, or parent', {
1509
+ operationId,
1510
+ })
1511
+ }
1512
+ return skipMutation(this.#operationResult(state, duplicate))
1513
+ }
1514
+ validateOperationId(operationId, now)
1515
+ if (!state.settings.enabled) throw fail('disabled', 'session-link is disabled; enable it in settings first')
1516
+
1517
+ let chain
1518
+ let hop = 0
1519
+ if (parentMessageId !== undefined) {
1520
+ const parent = findMessage(state, parentMessageId)
1521
+ if (parent === undefined) throw fail('parent_not_found', 'parent message not found')
1522
+ if (parent.from !== from && parent.to !== from) throw fail('parent_forbidden', 'only a participant may continue this thread')
1523
+ if (parent.status !== 'accepted') {
1524
+ throw fail('parent_not_accepted', 'send parent must already be accepted; only explicit replies may use unconfirmed parents')
1525
+ }
1526
+ chain = state.chains[parent.chainId]
1527
+ if (chain === undefined) throw fail('chain_missing', 'parent chain is missing')
1528
+ if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
1529
+ if (chain.state === 'paused') throw fail('chain_paused', 'chain is paused')
1530
+ hop = parent.hop + 1
1531
+ } else {
1532
+ chain = {
1533
+ id: this.uuid(),
1534
+ rootOwner: from,
1535
+ createdAt: now,
1536
+ state: 'open',
1537
+ messageCount: 0,
1538
+ }
1539
+ state.chains[chain.id] = chain
1540
+ }
1541
+
1542
+ const reservedForSend = findCreateSendReservation(state, from, operationId)
1543
+ this.#assertQueueCapacity(
1544
+ state,
1545
+ (content.bytes + subjectPayloadBytes(subject)) * targets.length,
1546
+ createReservationExact(reservedForSend, {
1547
+ kind,
1548
+ targets,
1549
+ text: content.text,
1550
+ references: content.references,
1551
+ paths: content.paths,
1552
+ subject: subject ?? null,
1553
+ parentMessageId: parentMessageId ?? null,
1554
+ })
1555
+ ? reservedForSend
1556
+ : undefined,
1557
+ )
1558
+ const held = state.settings.paused
1559
+ const messageIds = {}
1560
+ const existingCount = chainMessages(state, chain.id).length
1561
+ for (const target of targets) {
1562
+ const info = targetInfo.get(target)
1563
+ const message = {
1564
+ id: this.uuid(),
1565
+ operationId,
1566
+ from,
1567
+ fromTitle,
1568
+ to: target,
1569
+ toTitle: info?.title ?? target,
1570
+ kind,
1571
+ chainId: chain.id,
1572
+ replyTo: parentMessageId ?? null,
1573
+ parentMessageId: parentMessageId ?? null,
1574
+ hop,
1575
+ createdAt: now,
1576
+ acceptedAt: null,
1577
+ completedAt: null,
1578
+ deadlineAt: null,
1579
+ timedOutAt: null,
1580
+ lateReplyAt: null,
1581
+ content: structuredClone(content),
1582
+ contentBytes: content.bytes,
1583
+ subject: subject ?? null,
1584
+ status: held ? 'held' : 'pending',
1585
+ replyStatus: 'none',
1586
+ receipt: createReceipt('context'),
1587
+ deliveryAttempts: 0,
1588
+ durableMessageId: null,
1589
+ error: held
1590
+ ? { code: 'paused', message: 'held until the global pause is cleared' }
1591
+ : null,
1592
+ heldAt: held ? now : null,
1593
+ heldReason: held ? 'paused' : null,
1594
+ }
1595
+ state.messages.push(message)
1596
+ messageIds[target] = message.id
1597
+ }
1598
+ chain.messageCount = existingCount + targets.length
1599
+ const operationKey = this.#operationKey(from, operationId, targets)
1600
+ state.operations[operationKey] = {
1601
+ key: operationKey,
1602
+ from,
1603
+ operationId,
1604
+ fingerprint: sealFingerprint(fingerprint),
1605
+ targets: [...targets],
1606
+ messageIds,
1607
+ at: now,
1608
+ }
1609
+ created = Object.values(messageIds)
1610
+ return {
1611
+ operationId,
1612
+ idempotent: false,
1613
+ chainId: chain.id,
1614
+ held,
1615
+ results: targets.map((target) => ({
1616
+ target,
1617
+ id: messageIds[target],
1618
+ status: held ? 'held' : 'pending',
1619
+ error: held ? { code: 'paused', message: 'held until the global pause is cleared' } : null,
1620
+ })),
1621
+ }
1622
+ },
1623
+ { delivery: true },
1624
+ )
1625
+
1626
+ if (result.idempotent === true || result.held === true) return result
1627
+ await this.#dispatchBatch(created)
1628
+ return {
1629
+ ...result,
1630
+ results: result.results.map((entry) => {
1631
+ const message = this.#message(entry.id)
1632
+ return {
1633
+ ...entry,
1634
+ status: message?.status ?? 'missing',
1635
+ dispatchStatus: message?.status ?? 'missing',
1636
+ error: message?.error ?? null,
1637
+ }
1638
+ }),
1639
+ }
1640
+ }
1641
+
1642
+ /**
1643
+ * @param {{
1644
+ * from: string,
1645
+ * fromTitle?: string,
1646
+ * messageId: string,
1647
+ * text: unknown,
1648
+ * operationId: string,
1649
+ * references?: unknown,
1650
+ * paths?: unknown,
1651
+ * }} request
1652
+ * @returns {Promise<Record<string, any>>}
1653
+ */
1654
+ async reply(request) {
1655
+ const result = await this.#trackOperation(() => this.#reply(request))
1656
+ this.#schedulePump()
1657
+ return result
1658
+ }
1659
+
1660
+ async #reply(request) {
1661
+ const object = assertPlainObject(request, 'reply request')
1662
+ assertAllowedKeys(
1663
+ object,
1664
+ ['from', 'fromTitle', 'messageId', 'text', 'operationId', 'references', 'paths', 'subject'],
1665
+ 'reply request',
1666
+ )
1667
+ const from = requiredString(object.from, 'from', { min: 1, max: 256 })
1668
+ const parentMessageId = requiredString(object.messageId, 'messageId', { min: 1, max: 256 })
1669
+ const operationId = requiredString(object.operationId, 'operationId', { max: 128 })
1670
+ const subject = normalizeSubject(object.subject)
1671
+ const content = normalizeContent({
1672
+ text: object.text,
1673
+ references: object.references,
1674
+ paths: object.paths,
1675
+ })
1676
+ assertSubjectEnvelope(subject, content.bytes)
1677
+ assertCallBytes(
1678
+ {
1679
+ messageId: object.messageId,
1680
+ text: object.text,
1681
+ operationId: object.operationId,
1682
+ ...(object.references === undefined ? {} : { references: object.references }),
1683
+ ...(object.paths === undefined ? {} : { paths: object.paths }),
1684
+ ...(subject === undefined ? {} : { subject }),
1685
+ },
1686
+ 'reply call',
1687
+ )
1688
+ const parentForFingerprint = this.#message(parentMessageId)
1689
+ if (parentForFingerprint === undefined) throw fail('parent_not_found', 'reply parent not found')
1690
+ const fingerprint = callFingerprint({
1691
+ kind: 'reply',
1692
+ text: content.text,
1693
+ references: content.references,
1694
+ paths: content.paths,
1695
+ parentMessageId,
1696
+ targets: [parentForFingerprint.from],
1697
+ subject,
1698
+ })
1699
+ const existingState = this.ledger.read()
1700
+ const replyReservation = findCreateSendReservation(existingState, from, operationId)
1701
+ if (replyReservation !== undefined) {
1702
+ // A reply can never be the reserved first message (kind differs), so
1703
+ // reusing that internal id conflicts instead of consuming the reserve.
1704
+ throw fail('conflict_operation', 'operationId is reserved for a create first message and cannot be reused', {
1705
+ operationId,
1706
+ })
1707
+ }
1708
+ const existingOperation = this.#lookupOperation(existingState, from, operationId)
1709
+ if (existingOperation !== undefined) {
1710
+ if (!fingerprintMatches(existingOperation.fingerprint, fingerprint)) {
1711
+ throw fail('conflict_operation', 'operationId was already used with different content or parent', { operationId })
1712
+ }
1713
+ return this.#operationResult(existingState, existingOperation)
1714
+ }
1715
+ await this.#requireSender(from)
1716
+ const parent = this.#message(parentMessageId)
1717
+ if (parent === undefined) throw fail('parent_not_found', 'reply parent not found')
1718
+ if (parent.kind !== 'request') throw fail('parent_not_request', 'only an explicit request can be answered')
1719
+ if (parent.to !== from) throw fail('parent_forbidden', 'only the original recipient can reply')
1720
+ if (parent.status !== 'accepted' && parent.status !== 'dispatching' && parent.status !== 'unknown') {
1721
+ throw fail('parent_not_accepted', 'parent request has not been accepted by the host')
1722
+ }
1723
+ const chain = this.ledger.read().chains[parent.chainId]
1724
+ if (chain === undefined) throw fail('chain_missing', 'parent chain is missing')
1725
+ if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
1726
+ if (chain.state === 'paused') throw fail('chain_paused', 'chain is paused')
1727
+
1728
+ if (parent.status === 'dispatching' || parent.status === 'unknown') {
1729
+ const evidence = await this.adapter.readEvidence({
1730
+ targetId: parent.to,
1731
+ durableMessageId: parent.durableMessageId,
1732
+ senderSessionId: parent.from,
1733
+ })
1734
+ if (evidence?.found !== true || evidence.identityConflict === true) {
1735
+ throw fail(
1736
+ 'parent_unacknowledged',
1737
+ 'the parent request has no host insertion evidence; a reply cannot use it as a parent',
1738
+ { parentMessageId },
1739
+ )
1740
+ }
1741
+ }
1742
+
1743
+ const targetInfo = await this.#requireTarget(parent.from)
1744
+ const fromInfo = await this.adapter.getSession(from, { fresh: true })
1745
+ const fromTitle = optionalString(object.fromTitle, 'fromTitle', { max: 500 }) ?? fromInfo?.title ?? from
1746
+ const now = this.clock()
1747
+ let created
1748
+ const result = await this.#mutate(
1749
+ (state) => {
1750
+ const duplicate = this.#lookupOperation(state, from, operationId)
1751
+ if (duplicate !== undefined) {
1752
+ if (!fingerprintMatches(duplicate.fingerprint, fingerprint)) {
1753
+ throw fail('conflict_operation', 'operationId was already used with different content or parent', { operationId })
1754
+ }
1755
+ return skipMutation(this.#operationResult(state, duplicate))
1756
+ }
1757
+ validateOperationId(operationId, now)
1758
+ if (!state.settings.enabled) throw fail('disabled', 'session-link is disabled; enable it in settings first')
1759
+ const currentParent = findMessage(state, parentMessageId)
1760
+ if (currentParent === undefined) throw fail('parent_not_found', 'reply parent not found')
1761
+ if (currentParent.kind !== 'request') throw fail('parent_not_request', 'only an explicit request can be answered')
1762
+ if (currentParent.to !== from) throw fail('parent_forbidden', 'only the original recipient can reply')
1763
+ if (currentParent.status !== 'accepted' && currentParent.status !== 'dispatching' && currentParent.status !== 'unknown') {
1764
+ throw fail('parent_not_accepted', 'parent request is no longer acceptable as a reply parent')
1765
+ }
1766
+ const currentChain = state.chains[currentParent.chainId]
1767
+ if (currentChain === undefined) throw fail('chain_missing', 'parent chain is missing')
1768
+ if (currentChain.state === 'closed') throw fail('chain_closed', 'chain is closed')
1769
+ if (currentChain.state === 'paused') throw fail('chain_paused', 'chain is paused')
1770
+ const nextHop = currentParent.hop + 1
1771
+ this.#assertQueueCapacity(state, content.bytes + subjectPayloadBytes(subject))
1772
+ const held = state.settings.paused
1773
+ const existingCount = chainMessages(state, currentParent.chainId).length
1774
+ const message = {
1775
+ id: this.uuid(),
1776
+ operationId,
1777
+ from,
1778
+ fromTitle,
1779
+ to: currentParent.from,
1780
+ toTitle: targetInfo?.title ?? currentParent.from,
1781
+ kind: 'reply',
1782
+ chainId: currentParent.chainId,
1783
+ replyTo: currentParent.id,
1784
+ parentMessageId: currentParent.id,
1785
+ hop: nextHop,
1786
+ createdAt: now,
1787
+ acceptedAt: null,
1788
+ completedAt: null,
1789
+ deadlineAt: null,
1790
+ timedOutAt: null,
1791
+ late: currentParent.replyStatus === 'timed_out',
1792
+ content: structuredClone(content),
1793
+ contentBytes: content.bytes,
1794
+ subject: subject ?? null,
1795
+ status: held ? 'held' : 'pending',
1796
+ replyStatus: 'none',
1797
+ receipt: createReceipt('context'),
1798
+ deliveryAttempts: 0,
1799
+ durableMessageId: null,
1800
+ error: held
1801
+ ? { code: 'paused', message: 'held until the global pause is cleared' }
1802
+ : null,
1803
+ heldAt: held ? now : null,
1804
+ heldReason: held ? 'paused' : null,
1805
+ }
1806
+ state.messages.push(message)
1807
+ currentChain.messageCount = existingCount + 1
1808
+ const operationKey = this.#operationKey(from, operationId, [currentParent.from])
1809
+ state.operations[operationKey] = {
1810
+ key: operationKey,
1811
+ from,
1812
+ operationId,
1813
+ fingerprint: sealFingerprint(fingerprint),
1814
+ targets: [currentParent.from],
1815
+ messageIds: { [currentParent.from]: message.id },
1816
+ at: now,
1817
+ }
1818
+ created = message.id
1819
+ return {
1820
+ operationId,
1821
+ idempotent: false,
1822
+ chainId: message.chainId,
1823
+ held,
1824
+ results: [
1825
+ {
1826
+ target: message.to,
1827
+ id: message.id,
1828
+ status: message.status,
1829
+ error: message.error,
1830
+ },
1831
+ ],
1832
+ }
1833
+ },
1834
+ { delivery: true },
1835
+ )
1836
+ if (result.idempotent === true || result.held === true) return result
1837
+ await this.#dispatchBatch([created])
1838
+ const message = this.#message(created)
1839
+ return {
1840
+ ...result,
1841
+ results: [
1842
+ {
1843
+ target: message?.to,
1844
+ id: created,
1845
+ status: message?.status ?? 'missing',
1846
+ dispatchStatus: message?.status ?? 'missing',
1847
+ error: message?.error ?? null,
1848
+ },
1849
+ ],
1850
+ }
1851
+ }
1852
+
1853
+ // -------------------------------------------------------------------------
1854
+ // Create session + first task
1855
+ // -------------------------------------------------------------------------
1856
+
1857
+ /**
1858
+ * Create one ordinary session in an explicit location and deliver its first
1859
+ * task message through the standard agent-message relay. Every external
1860
+ * effect is preceded by a durable journal update; a crash during an effect
1861
+ * becomes `uncertain` and is never rebuilt automatically. One caller +
1862
+ * operationId may only ever run one effect sequence in this process.
1863
+ * @param {Record<string, unknown>} request
1864
+ * @returns {Promise<Record<string, any>>}
1865
+ */
1866
+ async create(request) {
1867
+ const parsed = parseCreateRequest(request)
1868
+ const key = `${parsed.from}|${parsed.operationId}`
1869
+ const pending = this.#createsInFlight.get(key)
1870
+ if (pending !== undefined) {
1871
+ // Same root operationId with different normalized inputs is a conflict
1872
+ // even while the first call is still running.
1873
+ if (pending.fingerprint !== parsed.fingerprint) {
1874
+ throw fail('conflict_operation', 'operationId is already creating a session with different inputs', {
1875
+ operationId: parsed.operationId,
1876
+ })
1877
+ }
1878
+ return pending.promise
1879
+ }
1880
+ const run = this.#trackOperation(() => this.#create(parsed, key))
1881
+ this.#createsInFlight.set(key, { fingerprint: parsed.fingerprint, promise: run })
1882
+ try {
1883
+ return await run
1884
+ } finally {
1885
+ if (this.#createsInFlight.get(key)?.promise === run) this.#createsInFlight.delete(key)
1886
+ }
1887
+ }
1888
+
1889
+ /** @param {ReturnType<typeof parseCreateRequest>} input @param {string} key */
1890
+ async #create(input, key) {
1891
+ const { from, operationId, fingerprint, location } = input
1892
+ const existingState = this.ledger.read()
1893
+ const existing = this.#lookupOperation(existingState, from, operationId)
1894
+ if (existing !== undefined) {
1895
+ // A send/reply fingerprint can never equal a create fingerprint, so a
1896
+ // matching record means this exact create operation already exists.
1897
+ if (!fingerprintMatches(existing.fingerprint, fingerprint)) {
1898
+ throw fail('conflict_operation', 'operationId was already used with different content, kind, targets, or parent', {
1899
+ operationId,
1900
+ })
1901
+ }
1902
+ return this.#createAdvance(key, { idempotent: true })
1903
+ }
1904
+ const now = this.clock()
1905
+ validateOperationId(operationId, now)
1906
+ if (!existingState.settings.enabled) {
1907
+ throw fail('disabled', 'session-link is disabled; enable it in settings first')
1908
+ }
1909
+ if (existingState.settings.paused) {
1910
+ throw fail('paused', 'session-link is paused; clear the global pause before creating a session')
1911
+ }
1912
+ await this.#requireSender(from)
1913
+ // Capability boundary: checked here, before the journal exists, so a host
1914
+ // without create/rename/workspace keeps mounting the original plugin.
1915
+ await this.adapter.assertCreateCapabilities()
1916
+
1917
+ // Read-only location resolution; any failure precedes the journal and the
1918
+ // first external effect, so it is a clean rejection with no side effects.
1919
+ let resolvedWorkspacePath = null
1920
+ if (location.kind === 'workspace') {
1921
+ const workspace = await this.adapter.getWorkspace(location.workspaceId)
1922
+ location.workspaceTitle = workspace.title
1923
+ resolvedWorkspacePath = workspace.path
1924
+ } else {
1925
+ if (location.kind === 'existing_project') {
1926
+ const project = await this.adapter.checkExistingProjectPath(location.workspacePath)
1927
+ location.workspacePath = project.path
1928
+ resolvedWorkspacePath = project.path
1929
+ } else {
1930
+ await this.adapter.checkProjectLocation({
1931
+ parentDir: location.parentDir,
1932
+ directoryName: location.directoryName,
1933
+ })
1934
+ }
1935
+ }
1936
+
1937
+ // A pure-random reserved id, proven absent before it is offered. Even a
1938
+ // proven-absent id never proves a transaction: the host may still return
1939
+ // a different identity, and that replacement is recorded, not hidden.
1940
+ const requestedSessionId = `session-${this.uuid()}`
1941
+ if (await this.adapter.sessionExists(requestedSessionId)) {
1942
+ throw fail('session_id_collision', 'the reserved session id already exists; refusing to create', {
1943
+ requestedSessionId,
1944
+ })
1945
+ }
1946
+
1947
+ const sendOperationId = mintOperationId(this.uuid(), now)
1948
+ const journal = {
1949
+ key,
1950
+ from,
1951
+ operationId,
1952
+ fingerprint: sealFingerprint(fingerprint),
1953
+ at: now,
1954
+ updatedAt: now,
1955
+ phase: 'creating',
1956
+ task: input.task,
1957
+ subject: input.subject ?? null,
1958
+ title: input.resolvedTitle,
1959
+ location: structuredClone(location),
1960
+ requestedSessionId,
1961
+ sessionId: null,
1962
+ workspaceId: location.kind === 'workspace' ? location.workspaceId : null,
1963
+ workspacePath: resolvedWorkspacePath,
1964
+ renameStatus: 'not_attempted',
1965
+ renameError: null,
1966
+ sendOperationId,
1967
+ firstMessageId: null,
1968
+ error: null,
1969
+ terminalAt: null,
1970
+ bodyStripped: false,
1971
+ }
1972
+ const claimed = await this.#mutate(
1973
+ (state) => {
1974
+ const duplicate = this.#lookupCreate(state, from, operationId)
1975
+ if (duplicate !== undefined) {
1976
+ if (!fingerprintMatches(duplicate.fingerprint, fingerprint)) {
1977
+ throw fail('conflict_operation', 'operationId was already used with different create inputs', { operationId })
1978
+ }
1979
+ return skipMutation(false)
1980
+ }
1981
+ // The intent body joins the same 16 MiB undelivered envelope, checked
1982
+ // before this journal is persisted and before any external effect.
1983
+ this.#assertQueueCapacity(state, createJournalPayloadBytes(state, journal))
1984
+ state.creates = { ...(state.creates ?? {}), [key]: structuredClone(journal) }
1985
+ return true
1986
+ },
1987
+ { delivery: true },
1988
+ )
1989
+ if (claimed !== true) return this.#createAdvance(key, { idempotent: true })
1990
+ return this.#runCreateEffects(key, { idempotent: false })
1991
+ }
1992
+
1993
+ /**
1994
+ * Run the external effect chain for a journal this process just created.
1995
+ * Each effect persists its real outcome before the next effect starts; a
1996
+ * failure after the first side effect is reported in the returned view.
1997
+ * @param {string} key
1998
+ * @param {{ idempotent: boolean }} options
1999
+ */
2000
+ async #runCreateEffects(key, options) {
2001
+ let journal = this.#createJournal(key)
2002
+ if (journal === undefined) throw fail('internal', 'create journal disappeared')
2003
+
2004
+ if (journal.location.kind === 'new_project' && journal.workspacePath === null) {
2005
+ let directory
2006
+ try {
2007
+ directory = await this.adapter.ensureProjectDirectory({
2008
+ parentDir: journal.location.parentDir,
2009
+ directoryName: journal.location.directoryName,
2010
+ })
2011
+ } catch (error) {
2012
+ await this.#journalCreateFailure(key, error)
2013
+ return this.#createView(key, options.idempotent)
2014
+ }
2015
+ await this.#mutate(
2016
+ (state) => {
2017
+ const current = state.creates?.[key]
2018
+ if (current === undefined) return skipMutation(undefined)
2019
+ current.workspacePath = directory.path
2020
+ current.updatedAt = this.clock()
2021
+ return true
2022
+ },
2023
+ { delivery: true },
2024
+ )
2025
+ }
2026
+
2027
+ journal = this.#createJournal(key)
2028
+ if (journal.workspaceId === null) {
2029
+ let workspace
2030
+ try {
2031
+ workspace = await this.adapter.createWorkspace(
2032
+ journal.workspacePath,
2033
+ journal.location.workspaceTitle ?? (journal.location.kind === 'existing_project' ? undefined : journal.title),
2034
+ )
2035
+ } catch (error) {
2036
+ await this.#journalCreateFailure(key, error)
2037
+ return this.#createView(key, options.idempotent)
2038
+ }
2039
+ await this.#mutate(
2040
+ (state) => {
2041
+ const current = state.creates?.[key]
2042
+ if (current === undefined) return skipMutation(undefined)
2043
+ current.workspaceId = workspace.id
2044
+ current.workspacePath = workspace.path
2045
+ current.location.workspaceTitle = workspace.title
2046
+ current.updatedAt = this.clock()
2047
+ return true
2048
+ },
2049
+ { delivery: true },
2050
+ )
2051
+ }
2052
+
2053
+ journal = this.#createJournal(key)
2054
+ if (journal.sessionId === null) {
2055
+ let created
2056
+ try {
2057
+ created = await this.adapter.createSession({
2058
+ workspaceId: journal.workspaceId,
2059
+ sessionId: journal.requestedSessionId,
2060
+ })
2061
+ } catch (error) {
2062
+ // The host attach failure carries the real created id in its details;
2063
+ // a pre-create rejection proves no session; anything else may have
2064
+ // created a session, so the operation stops as uncertain.
2065
+ const actualId = typeof error?.details?.sessionId === 'string' ? error.details.sessionId : null
2066
+ if (actualId !== null && actualId.length > 0) {
2067
+ await this.#mutate(
2068
+ (state) => {
2069
+ const current = state.creates?.[key]
2070
+ if (current === undefined) return skipMutation(undefined)
2071
+ current.sessionId = actualId
2072
+ current.phase = 'attach_failed'
2073
+ current.terminalAt = this.clock()
2074
+ current.error = createErrorView(error)
2075
+ current.updatedAt = this.clock()
2076
+ return true
2077
+ },
2078
+ { delivery: true },
2079
+ )
2080
+ return this.#createView(key, options.idempotent)
2081
+ }
2082
+ if (PRE_CREATE_FAILURE_CODES.has(errorCode(error))) {
2083
+ await this.#journalCreateFailure(key, error)
2084
+ return this.#createView(key, options.idempotent)
2085
+ }
2086
+ await this.#mutate(
2087
+ (state) => {
2088
+ const current = state.creates?.[key]
2089
+ if (current === undefined) return skipMutation(undefined)
2090
+ current.phase = 'uncertain'
2091
+ current.error = createErrorView(
2092
+ error,
2093
+ 'the create result could not be confirmed; a session may already exist — verify manually and do not delete anything',
2094
+ )
2095
+ current.updatedAt = this.clock()
2096
+ return true
2097
+ },
2098
+ { delivery: true },
2099
+ )
2100
+ return this.#createView(key, options.idempotent)
2101
+ }
2102
+ await this.#mutate(
2103
+ (state) => {
2104
+ const current = state.creates?.[key]
2105
+ if (current === undefined) return skipMutation(undefined)
2106
+ current.sessionId = created.sessionId
2107
+ current.phase = 'created'
2108
+ current.error = null
2109
+ current.updatedAt = this.clock()
2110
+ return true
2111
+ },
2112
+ { delivery: true },
2113
+ )
2114
+ // The host create return only proves publication, not durability. The
2115
+ // actual live agent must flush before this is reported as persistent or
2116
+ // before the first message; a failed barrier is an actionable partial
2117
+ // result and is never a reason to rebuild the session.
2118
+ const barrier = await this.adapter.confirmSessionDurable(created.sessionId)
2119
+ if (barrier.ok !== true) {
2120
+ await this.#mutate(
2121
+ (state) => {
2122
+ const current = state.creates?.[key]
2123
+ if (current === undefined) return skipMutation(undefined)
2124
+ current.error = { code: barrier.code, message: barrier.message.slice(0, 400) }
2125
+ current.updatedAt = this.clock()
2126
+ return true
2127
+ },
2128
+ { delivery: true },
2129
+ )
2130
+ return this.#createView(key, options.idempotent)
2131
+ }
2132
+ }
2133
+
2134
+ return this.#createAdvance(key, options)
2135
+ }
2136
+
2137
+ /**
2138
+ * Idempotent continuation of an existing journal: `creating` (a crash or a
2139
+ * stuck effect whose result cannot be proven) becomes `uncertain` and never
2140
+ * re-runs an effect; terminal phases replay their real state; otherwise the
2141
+ * flow resumes rename-once then the first send with the persisted
2142
+ * sendOperationId, which the standard send path deduplicates.
2143
+ * @param {string} key
2144
+ * @param {{ idempotent: boolean }} options
2145
+ */
2146
+ async #createAdvance(key, options) {
2147
+ let journal = this.#createJournal(key)
2148
+ if (journal === undefined) throw fail('internal', 'create journal disappeared')
2149
+ if (journal.phase === 'creating') {
2150
+ await this.#mutate(
2151
+ (state) => {
2152
+ const current = state.creates?.[key]
2153
+ if (current === undefined || current.phase !== 'creating') return skipMutation(undefined)
2154
+ current.phase = 'uncertain'
2155
+ current.error = createErrorView(
2156
+ fail(
2157
+ 'create_interrupted',
2158
+ 'the plugin restarted while a create effect may have been in flight; verify the workspace/session manually before retrying',
2159
+ ),
2160
+ )
2161
+ current.updatedAt = this.clock()
2162
+ return true
2163
+ },
2164
+ { delivery: true },
2165
+ )
2166
+ journal = this.#createJournal(key)
2167
+ }
2168
+ const phase = journal.phase
2169
+ if (phase === 'uncertain' || phase === 'sent' || phase === 'attach_failed' || phase === 'failed') {
2170
+ return this.#createView(key, options.idempotent)
2171
+ }
2172
+
2173
+ await this.#requireSender(journal.from)
2174
+ const settings = this.ledger.read().settings
2175
+ if (!settings.enabled) {
2176
+ throw fail('disabled', 'session-link is disabled; enable it in settings first')
2177
+ }
2178
+
2179
+ // Durability fence: never continue to rename or the first message unless
2180
+ // the created session's live agent confirms the host flush. A failed
2181
+ // barrier returns the real partial state; it never rebuilds anything.
2182
+ const barrier = await this.adapter.confirmSessionDurable(journal.sessionId)
2183
+ if (barrier.ok !== true) {
2184
+ await this.#mutate(
2185
+ (state) => {
2186
+ const current = state.creates?.[key]
2187
+ if (current === undefined) return skipMutation(undefined)
2188
+ current.error = { code: barrier.code, message: barrier.message.slice(0, 400) }
2189
+ current.updatedAt = this.clock()
2190
+ return true
2191
+ },
2192
+ { delivery: true },
2193
+ )
2194
+ return this.#createView(key, options.idempotent)
2195
+ }
2196
+ if (journal.error !== null) {
2197
+ await this.#mutate(
2198
+ (state) => {
2199
+ const current = state.creates?.[key]
2200
+ if (current === undefined || current.error === null) return skipMutation(undefined)
2201
+ current.error = null
2202
+ current.updatedAt = this.clock()
2203
+ return true
2204
+ },
2205
+ { delivery: true },
2206
+ )
2207
+ journal = this.#createJournal(key)
2208
+ }
2209
+
2210
+ // Rename exactly once, after the real id is persisted and before the
2211
+ // first send. An interrupted claim is recorded as a failed rename, never
2212
+ // re-attempted automatically; a rename failure never blocks the first task.
2213
+ if (journal.renameStatus === 'pending') {
2214
+ await this.#mutate(
2215
+ (state) => {
2216
+ const current = state.creates?.[key]
2217
+ if (current === undefined || current.renameStatus !== 'pending') return skipMutation(undefined)
2218
+ current.renameStatus = 'rename_failed'
2219
+ current.renameError = 'interrupted'
2220
+ current.phase = 'rename_failed'
2221
+ current.updatedAt = this.clock()
2222
+ return true
2223
+ },
2224
+ { delivery: true },
2225
+ )
2226
+ journal = this.#createJournal(key)
2227
+ }
2228
+ if (journal.renameStatus === 'not_attempted' && journal.sessionId !== null) {
2229
+ await this.#mutate(
2230
+ (state) => {
2231
+ const current = state.creates?.[key]
2232
+ if (current === undefined || current.renameStatus !== 'not_attempted') return skipMutation(undefined)
2233
+ current.renameStatus = 'pending'
2234
+ current.updatedAt = this.clock()
2235
+ return true
2236
+ },
2237
+ { delivery: true },
2238
+ )
2239
+ try {
2240
+ await this.adapter.renameSession(journal.sessionId, journal.title)
2241
+ await this.#mutate(
2242
+ (state) => {
2243
+ const current = state.creates?.[key]
2244
+ if (current === undefined) return skipMutation(undefined)
2245
+ current.renameStatus = 'renamed'
2246
+ current.renameError = null
2247
+ current.phase = 'renamed'
2248
+ current.updatedAt = this.clock()
2249
+ return true
2250
+ },
2251
+ { delivery: true },
2252
+ )
2253
+ } catch (error) {
2254
+ await this.#mutate(
2255
+ (state) => {
2256
+ const current = state.creates?.[key]
2257
+ if (current === undefined) return skipMutation(undefined)
2258
+ current.renameStatus = 'rename_failed'
2259
+ current.renameError = errorCode(error)
2260
+ current.phase = 'rename_failed'
2261
+ current.updatedAt = this.clock()
2262
+ return true
2263
+ },
2264
+ { delivery: true },
2265
+ )
2266
+ }
2267
+ journal = this.#createJournal(key)
2268
+ }
2269
+
2270
+ if (journal.sessionId === null) return this.#createView(key, options.idempotent)
2271
+
2272
+ const priorMessage =
2273
+ typeof journal.firstMessageId === 'string' && journal.firstMessageId.length > 0
2274
+ ? this.ledger.read().messages.find((candidate) => candidate.id === journal.firstMessageId)
2275
+ : undefined
2276
+ if (typeof journal.firstMessageId === 'string' && journal.firstMessageId.length > 0 && priorMessage === undefined) {
2277
+ // The recorded first message is gone (pruned or corrupt): never guess
2278
+ // whether it was delivered and never send again.
2279
+ await this.#mutate(
2280
+ (state) => {
2281
+ const current = state.creates?.[key]
2282
+ if (current === undefined || current.phase === 'uncertain') return skipMutation(undefined)
2283
+ current.phase = 'uncertain'
2284
+ current.error = {
2285
+ code: 'first_message_evidence_missing',
2286
+ message: 'the recorded first message can no longer be verified; delivery is unknown and will not be retried automatically',
2287
+ }
2288
+ current.updatedAt = this.clock()
2289
+ return true
2290
+ },
2291
+ { delivery: true },
2292
+ )
2293
+ return this.#createView(key, options.idempotent)
2294
+ }
2295
+ const priorFailed = priorMessage !== undefined && priorMessage.status === 'failed'
2296
+ if (priorMessage !== undefined && !priorFailed) {
2297
+ // accepted / held / pending / dispatching / unknown / cancelled keep
2298
+ // their original status; unknown is never re-sent. Reconcile a crash
2299
+ // window where the message exists but the journal never reached `sent`.
2300
+ if (journal.phase !== 'sent') {
2301
+ await this.#mutate(
2302
+ (state) => {
2303
+ const current = state.creates?.[key]
2304
+ if (current === undefined || current.phase === 'sent') return skipMutation(undefined)
2305
+ current.phase = 'sent'
2306
+ current.terminalAt = this.clock()
2307
+ current.error = null
2308
+ current.task = ''
2309
+ current.subject = null
2310
+ current.bodyStripped = true
2311
+ current.updatedAt = this.clock()
2312
+ return true
2313
+ },
2314
+ { delivery: true },
2315
+ )
2316
+ }
2317
+ return this.#createView(key, options.idempotent)
2318
+ }
2319
+
2320
+ // Owed: no first message yet, or the previous attempt failed before the
2321
+ // host accepted it (standard send fails only before steer, so a failed
2322
+ // record is provably un-inserted and an explicit same-operation retry may
2323
+ // continue to the SAME session).
2324
+ if (typeof journal.task !== 'string' || journal.task.length === 0) {
2325
+ await this.#mutate(
2326
+ (state) => {
2327
+ const current = state.creates?.[key]
2328
+ if (current === undefined) return skipMutation(undefined)
2329
+ current.error = {
2330
+ code: 'first_task_body_missing',
2331
+ message: 'the stored first task body is no longer present; it will not be reconstructed',
2332
+ }
2333
+ current.updatedAt = this.clock()
2334
+ return true
2335
+ },
2336
+ { delivery: true },
2337
+ )
2338
+ return this.#createView(key, options.idempotent)
2339
+ }
2340
+
2341
+ let sendOperationId = journal.sendOperationId
2342
+ if (priorFailed) {
2343
+ // The failed message record itself is the proof of an un-inserted
2344
+ // attempt, so a fresh persistent internal id is safe.
2345
+ sendOperationId = mintOperationId(this.uuid(), this.clock())
2346
+ await this.#mutate(
2347
+ (state) => {
2348
+ const current = state.creates?.[key]
2349
+ if (current === undefined) return skipMutation(undefined)
2350
+ current.sendOperationId = sendOperationId
2351
+ current.updatedAt = this.clock()
2352
+ return true
2353
+ },
2354
+ { delivery: true },
2355
+ )
2356
+ } else {
2357
+ const stateNow = this.ledger.read()
2358
+ const priorOperation = Object.values(stateNow.operations).some(
2359
+ (candidate) => candidate?.from === journal.from && candidate?.operationId === journal.sendOperationId,
2360
+ )
2361
+ const priorAnyMessage = stateNow.messages.some(
2362
+ (candidate) => candidate.operationId === journal.sendOperationId,
2363
+ )
2364
+ if (!priorOperation && !priorAnyMessage) {
2365
+ let submittable = true
2366
+ try {
2367
+ validateOperationId(journal.sendOperationId, this.clock())
2368
+ } catch {
2369
+ submittable = false
2370
+ }
2371
+ if (!submittable) {
2372
+ // Reissuing an expired internal id is allowed only with durable
2373
+ // proof it was never submitted: in never-attempted phases the send
2374
+ // was never called; in `sending` the absence of any operation or
2375
+ // message inside the 90-day tombstone horizon proves no record was
2376
+ // created-then-pruned. Otherwise the outcome stays unknown.
2377
+ const mintAt = parseOperationMintTime(journal.sendOperationId)
2378
+ const neverAttempted =
2379
+ journal.phase === 'created' || journal.phase === 'renamed' || journal.phase === 'rename_failed'
2380
+ const withinHorizon =
2381
+ mintAt !== null && this.clock() - mintAt <= LIMITS.OPERATION_RETENTION_MS
2382
+ if (neverAttempted || withinHorizon) {
2383
+ sendOperationId = mintOperationId(this.uuid(), this.clock())
2384
+ const reissued = sendOperationId
2385
+ await this.#mutate(
2386
+ (state) => {
2387
+ const current = state.creates?.[key]
2388
+ if (current === undefined) return skipMutation(undefined)
2389
+ current.sendOperationId = reissued
2390
+ current.updatedAt = this.clock()
2391
+ return true
2392
+ },
2393
+ { delivery: true },
2394
+ )
2395
+ } else {
2396
+ await this.#mutate(
2397
+ (state) => {
2398
+ const current = state.creates?.[key]
2399
+ if (current === undefined || current.phase === 'uncertain') return skipMutation(undefined)
2400
+ current.phase = 'uncertain'
2401
+ current.error = {
2402
+ code: 'create_send_unprovable',
2403
+ message:
2404
+ 'the internal first-message id expired without durable proof that it was never submitted; delivery stays unknown and is not retried automatically',
2405
+ }
2406
+ current.updatedAt = this.clock()
2407
+ return true
2408
+ },
2409
+ { delivery: true },
2410
+ )
2411
+ return this.#createView(key, options.idempotent)
2412
+ }
2413
+ }
2414
+ }
2415
+ // Otherwise the old id already owns a durable operation/message: reuse
2416
+ // it unchanged so the standard send path deduplicates instead of
2417
+ // inserting a second time.
2418
+ }
2419
+
2420
+ if (journal.phase !== 'sending') {
2421
+ await this.#mutate(
2422
+ (state) => {
2423
+ const current = state.creates?.[key]
2424
+ if (current === undefined || current.firstMessageId !== null) return skipMutation(undefined)
2425
+ current.phase = 'sending'
2426
+ current.updatedAt = this.clock()
2427
+ return true
2428
+ },
2429
+ { delivery: true },
2430
+ )
2431
+ }
2432
+ try {
2433
+ // The persisted sendOperationId makes this call idempotent inside the
2434
+ // standard send path: an already-inserted first message is returned,
2435
+ // never inserted a second time.
2436
+ const result = await this.send({
2437
+ from: journal.from,
2438
+ targets: [journal.sessionId],
2439
+ kind: 'request',
2440
+ text: journal.task,
2441
+ ...(journal.subject === null || journal.subject === undefined ? {} : { subject: journal.subject }),
2442
+ operationId: sendOperationId,
2443
+ })
2444
+ const messageId = result?.results?.[0]?.id
2445
+ if (typeof messageId !== 'string' || messageId.length === 0) {
2446
+ throw fail('internal', 'the first message id is missing from the send result')
2447
+ }
2448
+ const recorded = this.ledger.read().messages.find((candidate) => candidate.id === messageId)
2449
+ if (recorded === undefined) {
2450
+ // Evidence is insufficient to classify the attempt: mark unknown and
2451
+ // do not send again.
2452
+ await this.#mutate(
2453
+ (state) => {
2454
+ const current = state.creates?.[key]
2455
+ if (current === undefined || current.phase === 'uncertain') return skipMutation(undefined)
2456
+ current.firstMessageId = messageId
2457
+ current.phase = 'uncertain'
2458
+ current.error = {
2459
+ code: 'first_message_evidence_missing',
2460
+ message: 'the first message record cannot be verified; delivery is unknown and will not be retried automatically',
2461
+ }
2462
+ current.updatedAt = this.clock()
2463
+ return true
2464
+ },
2465
+ { delivery: true },
2466
+ )
2467
+ return this.#createView(key, options.idempotent)
2468
+ }
2469
+ if (recorded.status === 'failed') {
2470
+ // Provably un-inserted: keep the body and the `sending` phase so an
2471
+ // explicit retry of this same root operation re-sends to the same
2472
+ // session with a fresh internal id.
2473
+ await this.#mutate(
2474
+ (state) => {
2475
+ const current = state.creates?.[key]
2476
+ if (current === undefined) return skipMutation(undefined)
2477
+ current.firstMessageId = messageId
2478
+ current.phase = 'sending'
2479
+ current.error = {
2480
+ code: recorded.error?.code ?? 'first_send_failed',
2481
+ message:
2482
+ recorded.error?.message ??
2483
+ 'the first message was not accepted by the host; retry this same operation to continue to the same session',
2484
+ }
2485
+ current.updatedAt = this.clock()
2486
+ return true
2487
+ },
2488
+ { delivery: true },
2489
+ )
2490
+ return this.#createView(key, options.idempotent)
2491
+ }
2492
+ await this.#mutate(
2493
+ (state) => {
2494
+ const current = state.creates?.[key]
2495
+ if (current === undefined) return skipMutation(undefined)
2496
+ current.firstMessageId = messageId
2497
+ current.phase = 'sent'
2498
+ current.terminalAt = this.clock()
2499
+ current.error = null
2500
+ // The delivered message owns the body now; the journal keeps only
2501
+ // fingerprint, status, and association.
2502
+ current.task = ''
2503
+ current.subject = null
2504
+ current.bodyStripped = true
2505
+ current.updatedAt = this.clock()
2506
+ return true
2507
+ },
2508
+ { delivery: true },
2509
+ )
2510
+ } catch (error) {
2511
+ await this.#mutate(
2512
+ (state) => {
2513
+ const current = state.creates?.[key]
2514
+ if (current === undefined) return skipMutation(undefined)
2515
+ current.error = createErrorView(error)
2516
+ current.updatedAt = this.clock()
2517
+ return true
2518
+ },
2519
+ { delivery: true },
2520
+ )
2521
+ }
2522
+
2523
+ return this.#createView(key, options.idempotent)
2524
+ }
2525
+
2526
+ /** @param {string} key @returns {Record<string, any> | undefined} */
2527
+ #createJournal(key) {
2528
+ return this.ledger.read().creates?.[key]
2529
+ }
2530
+
2531
+ /** @param {string} key @param {unknown} error */
2532
+ async #journalCreateFailure(key, error) {
2533
+ await this.#mutate(
2534
+ (state) => {
2535
+ const current = state.creates?.[key]
2536
+ if (current === undefined) return skipMutation(undefined)
2537
+ current.phase = 'failed'
2538
+ current.terminalAt = this.clock()
2539
+ current.error = createErrorView(error)
2540
+ current.updatedAt = this.clock()
2541
+ return true
2542
+ },
2543
+ { delivery: true },
2544
+ )
2545
+ }
2546
+
2547
+ /**
2548
+ * @param {string} key
2549
+ * @param {boolean} idempotent
2550
+ * @returns {Record<string, any>}
2551
+ */
2552
+ #createView(key, idempotent) {
2553
+ const state = this.ledger.read()
2554
+ const journal = state.creates?.[key]
2555
+ if (journal === undefined) throw fail('internal', 'create journal disappeared')
2556
+ let firstMessage = null
2557
+ if (journal.firstMessageId !== null) {
2558
+ const message = state.messages.find((candidate) => candidate.id === journal.firstMessageId)
2559
+ firstMessage = {
2560
+ id: journal.firstMessageId,
2561
+ status: message?.status ?? 'missing',
2562
+ error: message?.error ?? null,
2563
+ }
2564
+ }
2565
+ return {
2566
+ operationId: journal.operationId,
2567
+ idempotent,
2568
+ phase: journal.phase,
2569
+ workspace:
2570
+ journal.workspaceId !== null
2571
+ ? {
2572
+ id: journal.workspaceId,
2573
+ path: journal.workspacePath,
2574
+ title: journal.location.workspaceTitle ?? journal.title,
2575
+ }
2576
+ : null,
2577
+ sessionId: journal.sessionId,
2578
+ requestedSessionId: journal.requestedSessionId,
2579
+ title: journal.title,
2580
+ titleStatus: journal.renameStatus,
2581
+ firstMessage,
2582
+ sendOperationId: journal.sendOperationId,
2583
+ error: journal.error ?? null,
2584
+ }
2585
+ }
2586
+
2587
+ // -------------------------------------------------------------------------
2588
+ // Owner card lookup
2589
+ // -------------------------------------------------------------------------
2590
+
2591
+ /**
2592
+ * Owner-only bounded lookup for one session's cards. Only messages that
2593
+ * participate in `sessionId` (from or to) are returned, with the minimal
2594
+ * identity/topic/association/status fields; unknown ids and non-participant
2595
+ * records are reported as missing instead of fabricated.
2596
+ *
2597
+ * Optional `includeBody: true` grants the verified inbound business text
2598
+ * for exactly one case: the requested id equals the message's durable host
2599
+ * id AND `sessionId` is the recipient. The body is the raw ledger content
2600
+ * text (never executed, no paths read, never derived from other records or
2601
+ * the journal); a redacted message yields `body: null` with
2602
+ * `bodyState: 'redacted'`. Without the grant no body field is added, so the
2603
+ * default response shape is unchanged.
2604
+ * @param {Record<string, unknown>} request
2605
+ * @returns {Record<string, any>}
2606
+ */
2607
+ cardLookup(request) {
2608
+ const object = assertPlainObject(request, 'card_lookup request')
2609
+ assertAllowedKeys(object, ['sessionId', 'ids', 'includeBody'], 'card_lookup request')
2610
+ if (object.includeBody !== undefined && typeof object.includeBody !== 'boolean') {
2611
+ throw fail('invalid_argument', 'includeBody must be a boolean')
2612
+ }
2613
+ const includeBody = object.includeBody === true
2614
+ const sessionId = requiredString(object.sessionId, 'sessionId', { min: 1, max: 256 })
2615
+ if (!Array.isArray(object.ids)) throw fail('invalid_argument', 'ids must be an array')
2616
+ if (object.ids.length === 0 || object.ids.length > LIMITS.MAX_CARD_LOOKUP_IDS) {
2617
+ throw fail(
2618
+ 'invalid_argument',
2619
+ `ids must contain between 1 and ${LIMITS.MAX_CARD_LOOKUP_IDS} entries`,
2620
+ { maxIds: LIMITS.MAX_CARD_LOOKUP_IDS },
2621
+ )
2622
+ }
2623
+ const ids = object.ids.map((id, index) => requiredString(id, `ids[${index}]`, { min: 1, max: 512 }))
2624
+ const state = this.ledger.read()
2625
+ const results = []
2626
+ const missing = []
2627
+ const emitted = new Set()
2628
+ // One durable message is one card, however many of its ids were asked;
2629
+ // the entry stays addressable so a later exact durable-id request can
2630
+ // still grant the body regardless of id order.
2631
+ const entryByMessageId = new Map()
2632
+ for (const id of ids) {
2633
+ if (emitted.has(id)) continue
2634
+ emitted.add(id)
2635
+ const message = state.messages.find(
2636
+ (candidate) =>
2637
+ candidate.id === id ||
2638
+ (typeof candidate.durableMessageId === 'string' && candidate.durableMessageId === id),
2639
+ )
2640
+ if (message === undefined || (message.from !== sessionId && message.to !== sessionId)) {
2641
+ missing.push(id)
2642
+ continue
2643
+ }
2644
+ const existingEntry = entryByMessageId.get(message.id)
2645
+ if (existingEntry !== undefined) {
2646
+ grantInboundBody(existingEntry, message, id, sessionId, includeBody)
2647
+ continue
2648
+ }
2649
+ const entry = {
2650
+ id: message.id,
2651
+ durableMessageId: message.durableMessageId ?? null,
2652
+ from: message.from,
2653
+ to: message.to,
2654
+ fromTitle: message.fromTitle ?? null,
2655
+ toTitle: message.toTitle ?? null,
2656
+ kind: message.kind,
2657
+ subject: displaySubject(message),
2658
+ chainId: message.chainId,
2659
+ parentMessageId: message.parentMessageId ?? null,
2660
+ status: message.status,
2661
+ replyStatus: message.replyStatus,
2662
+ receipt: structuredClone(message.receipt ?? null),
2663
+ }
2664
+ grantInboundBody(entry, message, id, sessionId, includeBody)
2665
+ entryByMessageId.set(message.id, entry)
2666
+ results.push(entry)
2667
+ }
2668
+ return { results, missing }
2669
+ }
2670
+
2671
+ // -------------------------------------------------------------------------
2672
+ // Identity and eligibility
2673
+ // -------------------------------------------------------------------------
2674
+
2675
+ /** @param {string} sessionId */
2676
+ async #requireSender(sessionId) {
2677
+ const info = await this.adapter.getSession(sessionId, { fresh: true })
2678
+ if (info === undefined || info === null) throw fail('identity_unregistered', 'caller session is not registered')
2679
+ if (info.subagent === true) throw fail('identity_subagent', 'subagent sessions cannot use session-link')
2680
+ if (info.archived === true) throw fail('identity_archived', 'archived sessions cannot send')
2681
+ if (info.registered === false) throw fail('identity_unregistered', 'caller session is not a registered ordinary session')
2682
+ if (info.formatStatus === 'migration-required') throw fail('identity_migration_required', 'caller session requires migration')
2683
+ if (info.eligible === false) throw fail('identity_unavailable', 'caller session is not an eligible ordinary session')
2684
+ }
2685
+
2686
+ /** @param {string} sessionId */
2687
+ async #requireTarget(sessionId) {
2688
+ const info = await this.adapter.getSession(sessionId, { fresh: true })
2689
+ if (info === undefined || info === null) throw fail('target_unregistered', 'target session is not registered')
2690
+ if (info.subagent === true) throw fail('target_subagent', 'target is a subagent session')
2691
+ if (info.archived === true) throw fail('target_archived', 'target session is archived; archived sessions are history-only')
2692
+ if (info.registered === false) throw fail('target_unregistered', 'target is not a registered ordinary session')
2693
+ if (info.formatStatus === 'migration-required') throw fail('target_migration_required', 'target session requires migration')
2694
+ if (info.eligible === false) throw fail('target_unavailable', 'target session is not eligible for delivery')
2695
+ return info
2696
+ }
2697
+
2698
+ // -------------------------------------------------------------------------
2699
+ // Dispatch pipeline
2700
+ // -------------------------------------------------------------------------
2701
+
2702
+ /** @param {string[]} ids */
2703
+ async #dispatchBatch(ids) {
2704
+ for (const id of ids) this.#starting.add(id)
2705
+ try {
2706
+ await Promise.all(ids.map((id) => this.#withSlot(() => this.#dispatchOne(id))))
2707
+ } finally {
2708
+ for (const id of ids) this.#starting.delete(id)
2709
+ }
2710
+ }
2711
+
2712
+ /** @template T @param {() => Promise<T>} task @returns {Promise<T>} */
2713
+ async #withSlot(task) {
2714
+ const acquired = await this.#acquireSlot()
2715
+ if (!acquired) return undefined
2716
+ try {
2717
+ return await task()
2718
+ } finally {
2719
+ this.#releaseSlot()
2720
+ }
2721
+ }
2722
+
2723
+ /** @returns {Promise<boolean>} */
2724
+ async #acquireSlot() {
2725
+ while (true) {
2726
+ if (this.#disposed) return false
2727
+ const max = this.ledger.read().settings.maxConcurrent
2728
+ if (this.#slots < max) {
2729
+ this.#slots += 1
2730
+ return true
2731
+ }
2732
+ await new Promise((resolve) => this.#waiters.push(resolve))
2733
+ }
2734
+ }
2735
+
2736
+ #releaseSlot() {
2737
+ this.#slots -= 1
2738
+ const next = this.#waiters.shift()
2739
+ if (next !== undefined) next()
2740
+ }
2741
+
2742
+ /**
2743
+ * A settings save that raises maxConcurrent must wake every sleeping slot
2744
+ * waiter immediately; the old slot owner may stay blocked in the host. Each
2745
+ * waiter loops back through #acquireSlot and re-reads the latest durable cap,
2746
+ * so waking all is safe for increases and requeues them for decreases.
2747
+ */
2748
+ #wakeSlotWaiters() {
2749
+ if (this.#disposed) return
2750
+ const waiters = this.#waiters.splice(0)
2751
+ for (const resolve of waiters) resolve()
2752
+ }
2753
+
2754
+ /** @param {string} id */
2755
+ async #dispatchOne(id) {
2756
+ if (this.#disposed || !this.ledger.writable) return
2757
+ const message = this.#message(id)
2758
+ if (message === undefined || message.status !== 'pending') return
2759
+ const token = { id, aborted: false }
2760
+ this.#attempts.add(token)
2761
+ try {
2762
+ if (this.#disposed || token.aborted) return
2763
+ const chain = this.ledger.read().chains[message.chainId]
2764
+ if (chain === undefined) {
2765
+ await this.#markFailed(id, 'chain_missing', 'chain is missing')
2766
+ return
2767
+ }
2768
+ if (chain.state === 'closed') {
2769
+ await this.#cancelPending(id, 'chain_closed', 'chain closed before delivery')
2770
+ return
2771
+ }
2772
+ if (chain.state === 'paused') return
2773
+ const preflight = dispatchBlockers(this.ledger.read(), message)
2774
+ if (preflight.includes('disabled') || preflight.includes('paused')) {
2775
+ await this.#heldIfPending(id, preflight.includes('disabled') ? 'disabled' : 'paused')
2776
+ return
2777
+ }
2778
+
2779
+ let built
2780
+ try {
2781
+ built = await this.adapter.buildDelivery(this.#publicMessage(message))
2782
+ } catch {
2783
+ await this.#markFailed(id, 'message_build_failed', 'host message could not be constructed')
2784
+ return
2785
+ }
2786
+ const durableMessageId = built?.durableMessageId
2787
+ if (typeof durableMessageId !== 'string' || durableMessageId.length === 0) {
2788
+ await this.#markFailed(id, 'message_build_failed', 'host message id is missing')
2789
+ return
2790
+ }
2791
+
2792
+ const marked = await this.#mutate((state) => {
2793
+ const current = findMessage(state, id)
2794
+ if (current?.status !== 'pending') return skipMutation(false)
2795
+ current.status = 'dispatching'
2796
+ current.durableMessageId = durableMessageId
2797
+ current.dispatchStartedAt = this.clock()
2798
+ delete current.heldOverflow
2799
+ current.deliveryAttempts = (current.deliveryAttempts ?? 0) + 1
2800
+ current.error = null
2801
+ this.#indexReceiptMessage(current)
2802
+ return true
2803
+ })
2804
+ if (marked !== true) return
2805
+ if (this.#disposed || token.aborted) return
2806
+
2807
+ let outcome
2808
+ try {
2809
+ outcome = await this.adapter.deliver({
2810
+ targetId: message.to,
2811
+ hostMessage: built.hostMessage,
2812
+ beforeResolve: () => this.#gateLive(id, false, token),
2813
+ beforeFollowup: () => this.#gateLive(id, true, token),
2814
+ })
2815
+ } catch (error) {
2816
+ outcome = { stage: 'followup', error: { code: errorCode(error) } }
2817
+ }
2818
+ if (this.#disposed || token.aborted) return
2819
+ if (!this.ledger.writable) return
2820
+ const classified = classifyDeliveryOutcome(outcome)
2821
+ if (classified.status === 'accepted') {
2822
+ await this.#acceptMessageById(id, this.clock(), { recoveryEvidence: undefined })
2823
+ await this.#reconcileAcceptedPresence(id)
2824
+ return
2825
+ }
2826
+ if (classified.status === 'failed') {
2827
+ await this.#markFailed(id, classified.code, classified.message)
2828
+ return
2829
+ }
2830
+ if (classified.status === 'unknown') {
2831
+ await this.#markUnknown(id, classified.code, classified.message)
2832
+ return
2833
+ }
2834
+ await this.#applyBlocked(id, classified)
2835
+ } finally {
2836
+ this.#attempts.delete(token)
2837
+ }
2838
+ }
2839
+
2840
+ /**
2841
+ * Recheck the serialized pause/disable gate immediately before resolve and
2842
+ * again immediately before followup. There is no business budget reservation
2843
+ * here; only the final synchronous commit gate remains.
2844
+ * @param {string} id
2845
+ * @param {boolean} beforeFollowup
2846
+ * @param {{ id: string, aborted: boolean }} token
2847
+ */
2848
+ async #gateLive(id, beforeFollowup, token) {
2849
+ if (this.#disposed || token.aborted) {
2850
+ return { ok: false, action: 'cancelled', code: 'disposed', message: 'session-link is shutting down' }
2851
+ }
2852
+ if (!this.ledger.writable) {
2853
+ return { ok: false, action: 'cancelled', code: 'storage_error', message: 'session-link storage is unavailable' }
2854
+ }
2855
+ const state = this.ledger.read()
2856
+ const message = findMessage(state, id)
2857
+ const initial = this.#checkLiveState(message)
2858
+ if (initial.ok !== true) return initial
2859
+ const info = await this.adapter.getSession(message.to, { fresh: true })
2860
+ if (info === undefined || info === null || info.eligible === false) {
2861
+ return {
2862
+ ok: false,
2863
+ action: 'failed',
2864
+ code: info?.archived === true ? 'target_archived' : info?.subagent === true ? 'target_subagent' : 'target_unavailable',
2865
+ message: 'target is no longer eligible before host insertion',
2866
+ }
2867
+ }
2868
+ // Re-read the durable gate synchronously after the async eligibility read.
2869
+ // A pause acknowledged while that read was in flight must prevent the
2870
+ // followup that would otherwise use the old snapshot.
2871
+ if (!this.ledger.writable) {
2872
+ return { ok: false, action: 'cancelled', code: 'storage_error', message: 'session-link storage is unavailable' }
2873
+ }
2874
+ const latest = this.ledger.read()
2875
+ const checked = this.#checkLiveState(findMessage(latest, id))
2876
+ if (checked.ok === true && beforeFollowup === true) {
2877
+ checked.commit = (run) => this.#commitFollowup(id, token, run)
2878
+ }
2879
+ return checked
2880
+ }
2881
+
2882
+ /**
2883
+ * Final synchronous check immediately around the host followup. The caller
2884
+ * invokes `run()` inside this same call stack, so a pause or unload that
2885
+ * committed while an earlier async read was in flight cannot slip between
2886
+ * the final state check and the host insertion.
2887
+ * @param {string} id
2888
+ * @param {{ id: string, aborted: boolean }} token
2889
+ * @param {() => void} run
2890
+ */
2891
+ #commitFollowup(id, token, run) {
2892
+ if (this.#disposed || token.aborted) {
2893
+ return { ok: false, blocked: { ok: false, action: 'cancelled', code: 'disposed', message: 'session-link is shutting down' } }
2894
+ }
2895
+ if (!this.ledger.writable) {
2896
+ return { ok: false, blocked: { ok: false, action: 'cancelled', code: 'storage_error', message: 'session-link storage is unavailable' } }
2897
+ }
2898
+ const state = this.ledger.read()
2899
+ const checked = this.#checkLiveState(findMessage(state, id))
2900
+ if (checked.ok !== true) return { ok: false, blocked: checked }
2901
+ run()
2902
+ return { ok: true }
2903
+ }
2904
+
2905
+ /**
2906
+ * @param {Record<string, any> | undefined} message
2907
+ * @returns {{ ok: true } | { ok: false, action: string, code?: string, message?: string }}
2908
+ */
2909
+ #checkLiveState(message) {
2910
+ if (message === undefined) {
2911
+ return { ok: false, action: 'cancelled', code: 'message_missing', message: 'message disappeared' }
2912
+ }
2913
+ if (message.status !== 'dispatching') {
2914
+ return { ok: false, action: 'cancelled', code: 'not_dispatching', message: 'message left dispatching state' }
2915
+ }
2916
+ const state = this.ledger.read()
2917
+ const chain = state.chains[message.chainId]
2918
+ if (chain === undefined) {
2919
+ return { ok: false, action: 'failed', code: 'chain_missing', message: 'chain is missing' }
2920
+ }
2921
+ // Close cannot withdraw an already-dispatching attempt; it is reconciled as
2922
+ // accepted/unknown exactly as if the close had happened after insertion.
2923
+ if (chain.state === 'paused') {
2924
+ return { ok: false, action: 'pending', code: 'chain_paused', message: 'chain paused before host insertion' }
2925
+ }
2926
+ const settings = state.settings
2927
+ if (!settings.enabled) {
2928
+ return { ok: false, action: 'held', code: 'disabled', message: 'session-link disabled before host insertion' }
2929
+ }
2930
+ if (settings.paused) {
2931
+ return { ok: false, action: 'held', code: 'paused', message: 'global pause set before host insertion' }
2932
+ }
2933
+ return { ok: true }
2934
+ }
2935
+
2936
+ /**
2937
+ * @param {string} id
2938
+ * @param {{ status: string, code?: string, message?: string }} classified
2939
+ */
2940
+ async #applyBlocked(id, classified) {
2941
+ await this.#mutate((state) => {
2942
+ const current = findMessage(state, id)
2943
+ if (current?.status !== 'dispatching') return skipMutation(undefined)
2944
+ const status = classified.status
2945
+ current.status = status
2946
+ current.error = { code: classified.code ?? 'blocked', message: classified.message ?? 'blocked' }
2947
+ current.releaseAuthorized = false
2948
+ if (status === 'held') {
2949
+ current.heldAt = this.clock()
2950
+ current.heldReason = classified.code ?? 'blocked'
2951
+ } else {
2952
+ current.heldAt = null
2953
+ current.heldReason = null
2954
+ delete current.heldOverflow
2955
+ }
2956
+ if (status === 'cancelled') {
2957
+ current.cancelledAt = this.clock()
2958
+ current.completedAt = current.cancelledAt
2959
+ }
2960
+ if (status === 'failed') {
2961
+ current.failedAt = this.clock()
2962
+ current.completedAt = current.failedAt
2963
+ }
2964
+ if (status === 'unknown') current.unknownAt = this.clock()
2965
+ // No host insertion happened, so the host message id cannot be recovery
2966
+ // evidence for a later attempt.
2967
+ current.durableMessageId = null
2968
+ return true
2969
+ })
2970
+ }
2971
+
2972
+ /**
2973
+ * Legacy rows are only queued when native pending evidence still exists.
2974
+ * Context rows already became queued at acceptance; a missing legacy row is
2975
+ * left uncertain rather than pretending it was received.
2976
+ * @param {string} id
2977
+ * @returns {Promise<void>}
2978
+ */
2979
+ async #reconcileAcceptedPresence(id) {
2980
+ if (typeof this.adapter.nativePending !== 'function' || this.#disposed) return
2981
+ const before = this.ledger.read()
2982
+ const message = findMessage(before, id)
2983
+ if (
2984
+ message?.status !== 'accepted' ||
2985
+ message.receipt?.policy !== 'legacy-accepted' ||
2986
+ message.receipt.state === 'received' ||
2987
+ message.receipt.state === 'queued'
2988
+ ) {
2989
+ return
2990
+ }
2991
+ let presence
2992
+ try {
2993
+ presence = this.adapter.nativePending(message.to, message.durableMessageId)
2994
+ } catch {
2995
+ return
2996
+ }
2997
+ if (presence?.known !== true) return
2998
+ await this.#mutate((state) => {
2999
+ const current = findMessage(state, id)
3000
+ if (current?.receipt?.policy !== 'legacy-accepted' || current.receipt.state === 'received') {
3001
+ return skipMutation(undefined)
3002
+ }
3003
+ const next = presence.pending === true ? 'queued' : 'uncertain'
3004
+ if (current.receipt.state === next) return skipMutation(undefined)
3005
+ current.receipt.state = next
3006
+ return true
3007
+ })
3008
+ }
3009
+
3010
+ /** @param {string} id @param {number} now @param {object} [extras] */
3011
+ async #acceptMessageById(id, now, extras = {}) {
3012
+ await this.#mutate((state) => {
3013
+ const message = findMessage(state, id)
3014
+ if (message === undefined) return skipMutation(undefined)
3015
+ if (message.status === 'accepted') {
3016
+ this.#applyAcceptedEffects(state, message, now)
3017
+ return true
3018
+ }
3019
+ if (message.status !== 'dispatching' && message.status !== 'unknown') return skipMutation(undefined)
3020
+ this.#acceptMessage(state, message, now, extras)
3021
+ return true
3022
+ })
3023
+ }
3024
+
3025
+ /**
3026
+ * @param {Record<string, any>} state
3027
+ * @param {Record<string, any>} message
3028
+ * @param {number} now
3029
+ * @param {{ recoveryEvidence?: string, inboxSeen?: boolean }} [extras]
3030
+ */
3031
+ #acceptMessage(state, message, now, extras = {}) {
3032
+ const justAccepted = message.status !== 'accepted'
3033
+ message.status = 'accepted'
3034
+ message.acceptedAt = Number.isSafeInteger(message.acceptedAt) ? message.acceptedAt : now
3035
+ message.dispatchStatus = 'accepted'
3036
+ message.error = null
3037
+ if (extras.recoveryEvidence !== undefined) message.recoveryEvidence = extras.recoveryEvidence
3038
+ if (extras.inboxSeen === true) message.inboxSeen = true
3039
+ this.#applyAcceptedEffects(state, message, now, { justAccepted })
3040
+ }
3041
+
3042
+ /**
3043
+ * @param {Record<string, any>} state
3044
+ * @param {Record<string, any>} message
3045
+ * @param {number} now
3046
+ * @param {{ justAccepted?: boolean }} [options]
3047
+ */
3048
+ #applyAcceptedEffects(state, message, now, options = {}) {
3049
+ if (message.receipt?.policy === 'context' && message.receipt.state === 'not_sent') {
3050
+ message.receipt.state = 'queued'
3051
+ }
3052
+ if (message.kind === 'request') {
3053
+ if (message.replyStatus === undefined || message.replyStatus === 'none') message.replyStatus = 'waiting'
3054
+ if (message.replyStatus === 'waiting') this.#applyRequestAcceptedTiming(state, message, options.justAccepted === true)
3055
+ return
3056
+ }
3057
+ if (message.kind === 'notification' || message.kind === 'reply') {
3058
+ if (message.receipt?.policy === 'context') {
3059
+ if (message.receipt.state === 'received' && Number.isSafeInteger(message.receipt.receivedAt)) {
3060
+ message.completedAt = message.receipt.receivedAt
3061
+ }
3062
+ } else if (message.receipt?.policy === 'legacy-accepted' || message.receipt === undefined) {
3063
+ if (options.justAccepted === true && !Number.isSafeInteger(message.completedAt)) {
3064
+ message.completedAt = message.acceptedAt
3065
+ }
3066
+ }
3067
+ }
3068
+ if (message.kind !== 'reply' || typeof message.parentMessageId !== 'string') return
3069
+ const parent = findMessage(state, message.parentMessageId)
3070
+ if (parent === undefined || parent.kind !== 'request') return
3071
+ if (parent.status === 'dispatching' || parent.status === 'unknown') {
3072
+ const parentJustAccepted = parent.status !== 'accepted'
3073
+ parent.status = 'accepted'
3074
+ parent.acceptedAt = parent.acceptedAt ?? message.acceptedAt ?? now
3075
+ parent.dispatchStatus = 'accepted'
3076
+ parent.recoveryEvidence = parent.recoveryEvidence ?? 'reply'
3077
+ parent.error = null
3078
+ this.#applyRequestAcceptedTiming(state, parent, parentJustAccepted)
3079
+ }
3080
+ if (parent.replyStatus !== 'answered') {
3081
+ parent.replyStatus = 'answered'
3082
+ parent.answeredAt = message.acceptedAt ?? now
3083
+ parent.completedAt = parent.answeredAt
3084
+ parent.answeredBy = message.id
3085
+ }
3086
+ if (parent.timedOutAt !== null && parent.timedOutAt !== undefined) {
3087
+ message.late = true
3088
+ parent.lateReplyAt = message.acceptedAt ?? now
3089
+ }
3090
+ }
3091
+
3092
+ /**
3093
+ * New context requests snapshot the current timeout and wait for exact
3094
+ * context evidence before a deadline exists. Legacy rows keep the 0.2 rule:
3095
+ * an already-existing deadline is never touched; a request that is accepted
3096
+ * for the first time in 0.3 gets acceptedAt + the timeout current at that
3097
+ * acceptance.
3098
+ * @param {Record<string, any>} state
3099
+ * @param {Record<string, any>} message
3100
+ * @param {boolean} justAccepted
3101
+ */
3102
+ #applyRequestAcceptedTiming(state, message, justAccepted) {
3103
+ const receipt = message.receipt
3104
+ if (receipt?.policy === 'legacy-accepted') {
3105
+ if (!justAccepted || Number.isSafeInteger(message.deadlineAt)) return
3106
+ const deadlineAt = representableDeadline(message.acceptedAt, state.settings.requestTimeoutMs)
3107
+ if (deadlineAt !== undefined) message.deadlineAt = deadlineAt
3108
+ return
3109
+ }
3110
+ if (receipt?.policy === 'context') {
3111
+ if (justAccepted && receipt.timeoutMs === null) receipt.timeoutMs = state.settings.requestTimeoutMs
3112
+ if (receipt.state === 'received') this.#applyDeadlineFromReceipt(state, message)
3113
+ return
3114
+ }
3115
+ // Defensive fallback for a malformed pre-v3 row: preserve the old rule
3116
+ // rather than inventing a receipt-based timer.
3117
+ if (!Number.isSafeInteger(message.deadlineAt)) {
3118
+ const deadlineAt = representableDeadline(message.acceptedAt, state.settings.requestTimeoutMs)
3119
+ if (deadlineAt !== undefined) message.deadlineAt = deadlineAt
3120
+ }
3121
+ }
3122
+
3123
+ /** @param {string} id @param {string} code @param {string} message */
3124
+ async #markFailed(id, code, message) {
3125
+ await this.#mutate((state) => {
3126
+ const current = findMessage(state, id)
3127
+ if (current === undefined) return skipMutation(undefined)
3128
+ if (current.status === 'accepted') return skipMutation(undefined)
3129
+ if (current.status !== 'dispatching' && current.status !== 'pending' && current.status !== 'held') return skipMutation(undefined)
3130
+ current.status = 'failed'
3131
+ current.error = { code, message }
3132
+ current.failedAt = this.clock()
3133
+ current.completedAt = current.failedAt
3134
+ return true
3135
+ })
3136
+ }
3137
+
3138
+ /** @param {string} id @param {string} code @param {string} message */
3139
+ async #markUnknown(id, code, message) {
3140
+ await this.#mutate((state) => {
3141
+ const current = findMessage(state, id)
3142
+ if (current === undefined) return skipMutation(undefined)
3143
+ if (current.status === 'accepted') return skipMutation(undefined)
3144
+ if (current.status !== 'dispatching') return skipMutation(undefined)
3145
+ this.#markUnknownInState(state, current, this.clock(), { code, message })
3146
+ return true
3147
+ })
3148
+ }
3149
+
3150
+ /**
3151
+ * @param {Record<string, any>} state
3152
+ * @param {Record<string, any>} message
3153
+ * @param {number} now
3154
+ * @param {{ code: string, message: string }} error
3155
+ */
3156
+ #markUnknownInState(state, message, now, error) {
3157
+ message.status = 'unknown'
3158
+ message.unknownAt = now
3159
+ message.error = { code: error.code, message: error.message }
3160
+ // The durable message id and attempt counter remain: the panel must show
3161
+ // that an insertion may have happened and that it is never auto-retried.
3162
+ }
3163
+
3164
+ /** @param {string} id @param {string} code */
3165
+ async #heldIfPending(id, code) {
3166
+ await this.#mutate((state) => {
3167
+ const message = findMessage(state, id)
3168
+ if (message?.status !== 'pending') return skipMutation(undefined)
3169
+ message.status = 'held'
3170
+ message.heldAt = this.clock()
3171
+ message.heldReason = code
3172
+ message.error = { code, message: 'held until the global setting is cleared' }
3173
+ message.releaseAuthorized = false
3174
+ delete message.heldOverflow
3175
+ return true
3176
+ })
3177
+ }
3178
+
3179
+ /** @param {string} id @param {string} code @param {string} message */
3180
+ async #cancelPending(id, code, message) {
3181
+ await this.#mutate((state) => {
3182
+ const current = findMessage(state, id)
3183
+ if (current?.status !== 'pending') return skipMutation(undefined)
3184
+ current.status = 'cancelled'
3185
+ current.cancelledAt = this.clock()
3186
+ current.completedAt = current.cancelledAt
3187
+ current.error = { code, message }
3188
+ return true
3189
+ })
3190
+ }
3191
+
3192
+ // -------------------------------------------------------------------------
3193
+ // Legacy native next-turn transfer
3194
+ // -------------------------------------------------------------------------
3195
+
3196
+ #scheduleTransfer() {
3197
+ if (this.#disposed || this.#transferScheduled || this.#transferRunning) return
3198
+ if (!this.ledger.writable) return
3199
+ const settings = this.ledger.read().settings
3200
+ if (!settings.enabled || settings.paused) return
3201
+ this.#transferScheduled = true
3202
+ queueMicrotask(() => {
3203
+ this.#transferScheduled = false
3204
+ if (this.#disposed || !this.ledger.writable) return
3205
+ const task = this.#runTransferPass().catch((error) => {
3206
+ this.logger.warn(`session-link transfer pass failed: ${errorCode(error)}`)
3207
+ })
3208
+ this.#track(task)
3209
+ })
3210
+ }
3211
+
3212
+ /** @returns {Promise<void>} */
3213
+ async #runTransferPass() {
3214
+ if (this.#disposed || this.#transferRunning || !this.ledger.writable) return
3215
+ const state = this.ledger.read()
3216
+ if (!state.settings.enabled || state.settings.paused) return
3217
+ this.#transferRunning = true
3218
+ try {
3219
+ const groups = this.#transferCandidates(state)
3220
+ let targets = 0
3221
+ for (const [targetId, ids] of groups) {
3222
+ if (this.#disposed || targets >= LIMITS.TRANSFER_TARGETS_PER_PASS) break
3223
+ targets += 1
3224
+ try {
3225
+ await this.#transferTarget(targetId, ids)
3226
+ } catch (error) {
3227
+ if (errorCode(error) === 'storage_error' || errorCode(error) === 'disposed') throw error
3228
+ this.logger.warn(`session-link transfer for ${targetId} failed: ${errorCode(error)}`)
3229
+ }
3230
+ }
3231
+ } finally {
3232
+ this.#transferRunning = false
3233
+ }
3234
+ }
3235
+
3236
+ /**
3237
+ * Only legacy-accepted rows that are still eligible under D1 participate.
3238
+ * @param {Record<string, any>} state
3239
+ * @returns {Map<string, string[]>}
3240
+ */
3241
+ #transferCandidates(state) {
3242
+ const groups = new Map()
3243
+ for (const message of state.messages) {
3244
+ const receipt = message.receipt
3245
+ if (receipt?.policy !== 'legacy-accepted') continue
3246
+ if (message.status !== 'accepted') continue
3247
+ if (message.kind === 'request' && message.replyStatus === 'answered') continue
3248
+ if (typeof message.durableMessageId !== 'string' || message.durableMessageId.length === 0) continue
3249
+ if (receipt.state === 'received') continue
3250
+ const journal = state.transfers?.[message.id]
3251
+ if (journal?.state === 'moved' || journal?.state === 'uncertain') continue
3252
+ const chain = state.chains[message.chainId]
3253
+ if (chain?.state !== 'open') continue
3254
+ let ids = groups.get(message.to)
3255
+ if (ids === undefined) {
3256
+ ids = []
3257
+ groups.set(message.to, ids)
3258
+ }
3259
+ ids.push(message.id)
3260
+ }
3261
+ return groups
3262
+ }
3263
+
3264
+ /**
3265
+ * Recover prepared/moved journals on open. This only reads the live native
3266
+ * inbox (never resolves a cold Agent) plus exact receipt evidence; a changed
3267
+ * or missing row becomes uncertain and is never re-sent automatically.
3268
+ * @returns {Promise<void>}
3269
+ */
3270
+ async #recoverTransferJournals() {
3271
+ if (typeof this.adapter.inspectNativeTarget !== 'function') return
3272
+ const state = this.ledger.read()
3273
+ const transfers = state.transfers
3274
+ if (!isPlainObject(transfers)) return
3275
+ for (const [messageId, journal] of Object.entries(transfers)) {
3276
+ if (this.#disposed) return
3277
+ if (journal?.state === 'uncertain') continue
3278
+ const message = findMessage(state, messageId)
3279
+ if (message === undefined) continue
3280
+ if (journal.state === 'prepared') {
3281
+ if (message.receipt?.state === 'received') {
3282
+ await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
3283
+ continue
3284
+ }
3285
+ const chain = state.chains[message.chainId]
3286
+ if (
3287
+ message.status !== 'accepted' ||
3288
+ (message.kind === 'request' && message.replyStatus === 'answered') ||
3289
+ chain?.state === 'closed'
3290
+ ) {
3291
+ await this.#writeTransferState(messageId, 'uncertain', 'precondition_lost')
3292
+ continue
3293
+ }
3294
+ if (!state.settings.enabled || state.settings.paused) continue
3295
+ }
3296
+ let info
3297
+ try {
3298
+ info = this.adapter.inspectNativeTarget(message.to)
3299
+ } catch {
3300
+ continue
3301
+ }
3302
+ if (info?.live !== true) {
3303
+ if (message.receipt?.state === 'received' && journal.state !== 'moved') {
3304
+ await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
3305
+ }
3306
+ continue
3307
+ }
3308
+ const nextStepIds = new Set((info.nextStep ?? []).map((entry) => entry?.id))
3309
+ const nextTurn = (info.nextTurn ?? []).find((entry) => entry?.id === message.durableMessageId)
3310
+ if (nextStepIds.has(message.durableMessageId) || message.receipt?.state === 'received') {
3311
+ if (journal.state !== 'moved') await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
3312
+ continue
3313
+ }
3314
+ if (nextTurn !== undefined) {
3315
+ if (
3316
+ !isStandardSource(nextTurn.source, message.from) ||
3317
+ !hostMessageMatchesLedger(message, nextTurn) ||
3318
+ hostMessageDigest(nextTurn) !== journal.digest
3319
+ ) {
3320
+ await this.#writeTransferState(messageId, 'uncertain', 'content_mismatch')
3321
+ continue
3322
+ }
3323
+ if (journal.state === 'moved') {
3324
+ // D5: the original ID is still safely in next-turn and matches, so
3325
+ // the same row may be attempted again once D1 still holds.
3326
+ await this.#writeTransferState(messageId, 'prepared', undefined)
3327
+ }
3328
+ continue
3329
+ }
3330
+ await this.#writeTransferState(messageId, 'uncertain', 'not_pending_no_evidence')
3331
+ }
3332
+ }
3333
+
3334
+ /**
3335
+ * @param {string} targetId
3336
+ * @param {string[]} messageIds
3337
+ * @returns {Promise<void>}
3338
+ */
3339
+ async #transferTarget(targetId, messageIds) {
3340
+ if (typeof this.adapter.inspectNativeTarget !== 'function' || typeof this.adapter.moveNativePending !== 'function') {
3341
+ return
3342
+ }
3343
+ let info
3344
+ try {
3345
+ info = this.adapter.inspectNativeTarget(targetId)
3346
+ } catch {
3347
+ return
3348
+ }
3349
+ if (info?.live !== true || info.running !== true || info.archived === true || info.subagent === true) return
3350
+ for (const messageId of messageIds) {
3351
+ if (this.#disposed || !this.ledger.writable) return
3352
+ const state = this.ledger.read()
3353
+ if (!state.settings.enabled || state.settings.paused) return
3354
+ const message = findMessage(state, messageId)
3355
+ if (message === undefined || !this.#isTransferEligible(state, message)) continue
3356
+ const durableId = message.durableMessageId
3357
+ const native = (info.nextTurn ?? []).find((entry) => entry?.id === durableId)
3358
+ const inNextStep = (info.nextStep ?? []).some((entry) => entry?.id === durableId)
3359
+ const existing = state.transfers?.[messageId]
3360
+ if (inNextStep) {
3361
+ if (existing !== undefined && existing.state !== 'moved') {
3362
+ await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
3363
+ }
3364
+ continue
3365
+ }
3366
+ if (native === undefined) {
3367
+ if (existing?.state === 'prepared') {
3368
+ await this.#writeTransferState(messageId, 'uncertain', 'not_pending_no_evidence')
3369
+ }
3370
+ continue
3371
+ }
3372
+ if (!isStandardSource(native.source, message.from) || !hostMessageMatchesLedger(message, native)) {
3373
+ if (existing?.state === 'prepared') await this.#writeTransferState(messageId, 'uncertain', 'content_mismatch')
3374
+ continue
3375
+ }
3376
+ const digest = hostMessageDigest(native)
3377
+ if (existing?.state === 'prepared' && existing.digest !== digest) {
3378
+ await this.#writeTransferState(messageId, 'uncertain', 'content_mismatch')
3379
+ continue
3380
+ }
3381
+ const queuePosition = (info.nextTurn ?? []).findIndex((entry) => entry?.id === durableId)
3382
+ if (existing === undefined) {
3383
+ const prepared = await this.#persistTransferPrepared(message, native, info, digest, queuePosition)
3384
+ if (!prepared) continue
3385
+ }
3386
+ const guard = () => this.#transferGuard(messageId)
3387
+ let outcome
3388
+ try {
3389
+ outcome = this.adapter.moveNativePending({
3390
+ targetId,
3391
+ durableMessageId: durableId,
3392
+ senderSessionId: message.from,
3393
+ queuePosition,
3394
+ digest,
3395
+ guard,
3396
+ })
3397
+ } catch (error) {
3398
+ outcome = { outcome: 'uncertain', error: errorCode(error) }
3399
+ }
3400
+ if (!isPlainObject(outcome)) continue
3401
+ if (outcome.outcome === 'moved') {
3402
+ let flushed = false
3403
+ try {
3404
+ flushed = (await this.adapter.flushTarget(targetId)) === true
3405
+ } catch {
3406
+ flushed = false
3407
+ }
3408
+ if (this.#disposed) return
3409
+ if (flushed) {
3410
+ await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
3411
+ } else {
3412
+ await this.#writeTransferState(messageId, 'uncertain', 'flush_failed')
3413
+ }
3414
+ } else if (outcome.outcome === 'rolled_back' || outcome.outcome === 'fallback_next_turn') {
3415
+ await this.#bumpTransferAttempt(messageId, outcome.outcome === 'fallback_next_turn' ? 'steer_fell_back_next_turn' : 'move_rolled_back')
3416
+ } else if (outcome.outcome === 'missing' || outcome.outcome === 'already_claimed') {
3417
+ await this.#writeTransferState(messageId, 'uncertain', 'not_pending_no_evidence')
3418
+ } else if (outcome.outcome === 'mismatch') {
3419
+ await this.#writeTransferState(messageId, 'uncertain', 'content_mismatch')
3420
+ } else if (outcome.outcome === 'uncertain' || outcome.outcome === 'threw') {
3421
+ await this.#writeTransferState(messageId, 'uncertain', outcome.error ?? 'move_uncertain')
3422
+ }
3423
+ if (this.#disposed) return
3424
+ try {
3425
+ info = this.adapter.inspectNativeTarget(targetId)
3426
+ } catch {
3427
+ return
3428
+ }
3429
+ if (info?.live !== true || info.running !== true) return
3430
+ }
3431
+ }
3432
+
3433
+ /**
3434
+ * @param {Record<string, any>} state
3435
+ * @param {Record<string, any>} message
3436
+ * @returns {boolean}
3437
+ */
3438
+ #isTransferEligible(state, message) {
3439
+ if (message.receipt?.policy !== 'legacy-accepted') return false
3440
+ if (message.receipt.state === 'received') return false
3441
+ if (message.status !== 'accepted') return false
3442
+ if (message.kind === 'request' && message.replyStatus === 'answered') return false
3443
+ if (typeof message.durableMessageId !== 'string' || message.durableMessageId.length === 0) return false
3444
+ if (!state.settings.enabled || state.settings.paused) return false
3445
+ return state.chains[message.chainId]?.state === 'open'
3446
+ }
3447
+
3448
+ /** @param {string} messageId @returns {boolean} */
3449
+ #transferGuard(messageId) {
3450
+ if (this.#disposed || !this.ledger.writable) return false
3451
+ const state = this.ledger.read()
3452
+ const message = findMessage(state, messageId)
3453
+ if (message === undefined) return false
3454
+ return this.#isTransferEligible(state, message)
3455
+ }
3456
+
3457
+ /**
3458
+ * @param {Record<string, any>} message
3459
+ * @param {Record<string, any>} native
3460
+ * @param {Record<string, any>} info
3461
+ * @param {string} digest
3462
+ * @param {number} queuePosition
3463
+ * @returns {Promise<boolean>}
3464
+ */
3465
+ async #persistTransferPrepared(message, native, info, digest, queuePosition) {
3466
+ const now = this.clock()
3467
+ let persisted = false
3468
+ await this.#mutate((state) => {
3469
+ const current = findMessage(state, message.id)
3470
+ if (current === undefined || !this.#isTransferEligible(state, current)) return skipMutation(undefined)
3471
+ state.transfers ??= {}
3472
+ state.transfers[message.id] = {
3473
+ messageId: message.id,
3474
+ target: current.to,
3475
+ durableMessageId: current.durableMessageId,
3476
+ hostMessage: structuredClone(native),
3477
+ queuePosition: queuePosition < 0 ? 0 : queuePosition,
3478
+ hostSessionSeq: Number.isSafeInteger(info.hostSessionSeq) ? info.hostSessionSeq : -1,
3479
+ at: now,
3480
+ digest,
3481
+ state: 'prepared',
3482
+ attempts: 0,
3483
+ }
3484
+ current.receipt.transfer = { state: 'prepared', at: now }
3485
+ persisted = true
3486
+ return true
3487
+ })
3488
+ return persisted
3489
+ }
3490
+
3491
+ /**
3492
+ * @param {string} messageId
3493
+ * @param {'prepared'|'moved'|'uncertain'} transferState
3494
+ * @param {string} [error]
3495
+ * @param {{ at?: number }} [options]
3496
+ * @returns {Promise<void>}
3497
+ */
3498
+ async #writeTransferState(messageId, transferState, error, options = {}) {
3499
+ await this.#mutate((state) => {
3500
+ const message = findMessage(state, messageId)
3501
+ const journal = state.transfers?.[messageId]
3502
+ if (message === undefined || journal === undefined) return skipMutation(undefined)
3503
+ const at = options.at ?? this.clock()
3504
+ journal.state = transferState
3505
+ journal.at = at
3506
+ if (error === undefined) delete journal.error
3507
+ else journal.error = error
3508
+ message.receipt.transfer = {
3509
+ state: transferState,
3510
+ at,
3511
+ ...(error === undefined ? {} : { error }),
3512
+ }
3513
+ return true
3514
+ })
3515
+ }
3516
+
3517
+ /**
3518
+ * @param {string} messageId
3519
+ * @param {string} error
3520
+ * @returns {Promise<void>}
3521
+ */
3522
+ async #bumpTransferAttempt(messageId, error) {
3523
+ await this.#mutate((state) => {
3524
+ const message = findMessage(state, messageId)
3525
+ const journal = state.transfers?.[messageId]
3526
+ if (message === undefined || journal === undefined) return skipMutation(undefined)
3527
+ journal.attempts = (journal.attempts ?? 0) + 1
3528
+ if (journal.attempts >= LIMITS.TRANSFER_MAX_ATTEMPTS) {
3529
+ const at = this.clock()
3530
+ journal.state = 'uncertain'
3531
+ journal.at = at
3532
+ journal.error = error
3533
+ message.receipt.transfer = { state: 'uncertain', at, error }
3534
+ } else {
3535
+ message.receipt.transfer = { state: 'prepared', at: journal.at }
3536
+ }
3537
+ return true
3538
+ })
3539
+ }
3540
+
3541
+ // -------------------------------------------------------------------------
3542
+ // Automatic pump
3543
+ // -------------------------------------------------------------------------
3544
+
3545
+ #schedulePump() {
3546
+ if (this.#disposed || this.#pumpScheduled) return
3547
+ this.#pumpScheduled = true
3548
+ queueMicrotask(() => {
3549
+ this.#pumpScheduled = false
3550
+ void this.#pump().catch(() => undefined)
3551
+ })
3552
+ }
3553
+
3554
+ async #pump() {
3555
+ if (this.#disposed || !this.ledger.writable) return
3556
+ const settings = this.ledger.read().settings
3557
+ if (!settings.enabled || settings.paused) return
3558
+ while (this.#slots < settings.maxConcurrent) {
3559
+ if (this.#disposed) return
3560
+ const id = this.#nextPendingId()
3561
+ if (id === undefined) return
3562
+ this.#slots += 1
3563
+ this.#starting.add(id)
3564
+ const task = this.#dispatchOne(id)
3565
+ .catch((error) => this.logger.warn(`dispatch failed: ${errorCode(error)}`))
3566
+ .finally(() => {
3567
+ this.#starting.delete(id)
3568
+ this.#slots -= 1
3569
+ const next = this.#waiters.shift()
3570
+ if (next !== undefined) next()
3571
+ this.#schedulePump()
3572
+ })
3573
+ this.#track(task)
3574
+ }
3575
+ }
3576
+
3577
+ /** @returns {string | undefined} */
3578
+ #nextPendingId() {
3579
+ const state = this.ledger.read()
3580
+ for (const message of state.messages) {
3581
+ if (message.status !== 'pending') continue
3582
+ if (this.#starting.has(message.id)) continue
3583
+ if (dispatchBlockers(state, message).length > 0) continue
3584
+ return message.id
3585
+ }
3586
+ return undefined
3587
+ }
3588
+
3589
+ /** Wait for currently scheduled attempts and public operations to settle. */
3590
+ async drain() {
3591
+ await new Promise((resolve) => setImmediate(resolve))
3592
+ while (true) {
3593
+ const tasks = [...this.#tasks]
3594
+ if (tasks.length === 0 && this.#slots === 0) return
3595
+ if (tasks.length > 0) await Promise.allSettled(tasks)
3596
+ await new Promise((resolve) => setImmediate(resolve))
3597
+ }
3598
+ }
3599
+
3600
+ // -------------------------------------------------------------------------
3601
+ // Owner control + settings
3602
+ // -------------------------------------------------------------------------
3603
+
3604
+ /**
3605
+ * Settings CAS is independent from the message ledger revision. Exactly one
3606
+ * expectedSettingsRevision is accepted; old expectedRevision/legacy patches
3607
+ * are explicit invalid_argument/policy_removed failures.
3608
+ * @param {{ expectedSettingsRevision: number, patch: unknown }} request
3609
+ * @returns {Promise<{ revision: number, settingsRevision: number, settings: Record<string, any>, queued: number, resumed: number }>}
3610
+ */
3611
+ async updateSettings(request) {
3612
+ return this.#trackOperation(() => this.#updateSettings(request))
3613
+ }
3614
+
3615
+ async #updateSettings(request) {
3616
+ const object = assertPlainObject(request, 'settings request')
3617
+ // An old page sends expectedRevision together with removed quota fields.
3618
+ // The whole patch is a policy removal so the UI is told to refresh instead
3619
+ // of reporting a misleading argument error.
3620
+ if (isPlainObject(object.patch)) {
3621
+ const legacy = LEGACY_SETTINGS_KEYS.filter((key) => key in object.patch)
3622
+ if (legacy.length > 0) {
3623
+ throw fail('policy_removed', 'old session-link settings were removed; refresh the page and try again', {
3624
+ removedKeys: legacy,
3625
+ })
3626
+ }
3627
+ }
3628
+ assertAllowedKeys(object, ['expectedSettingsRevision', 'patch'], 'settings request', 'invalid_argument')
3629
+ const expectedSettingsRevision = boundedInteger(object.expectedSettingsRevision, 'expectedSettingsRevision', { min: 1 })
3630
+ const now = this.clock()
3631
+ const result = await this.#mutate((state) => {
3632
+ if (state.settingsRevision !== expectedSettingsRevision) {
3633
+ throw fail('revision_conflict', 'settings changed since the panel snapshot; refresh and retry', {
3634
+ currentSettingsRevision: state.settingsRevision,
3635
+ })
3636
+ }
3637
+ const next = applySettingsPatch(state.settings, object.patch, { now })
3638
+ const unchanged = JSON.stringify(next) === JSON.stringify(state.settings)
3639
+ if (unchanged) {
3640
+ return skipMutation({
3641
+ revision: state.revision,
3642
+ settingsRevision: state.settingsRevision,
3643
+ settings: structuredClone(state.settings),
3644
+ queued: 0,
3645
+ resumed: 0,
3646
+ })
3647
+ }
3648
+ state.settings = next
3649
+ state.settingsRevision += 1
3650
+ const reconciled = this.#reconcileGlobalQueue(state, now)
3651
+ return {
3652
+ revision: state.revision + 1,
3653
+ settingsRevision: state.settingsRevision,
3654
+ settings: structuredClone(next),
3655
+ queued: reconciled.queued,
3656
+ resumed: reconciled.resumed,
3657
+ }
3658
+ })
3659
+ this.#wakeSlotWaiters()
3660
+ this.#schedulePump()
3661
+ this.#scheduleTransfer()
3662
+ return result
3663
+ }
3664
+
3665
+ /**
3666
+ * Authenticated owner control. Every mutation is CAS-guarded on expectedRevision
3667
+ * and fails explicitly instead of silently overwriting another tab's change.
3668
+ * @param {Record<string, unknown>} request
3669
+ * @returns {Promise<Record<string, any>>}
3670
+ */
3671
+ async ownerControl(request) {
3672
+ return this.#trackOperation(() => this.#ownerControl(request))
3673
+ }
3674
+
3675
+ async #ownerControl(request) {
3676
+ const object = assertPlainObject(request, 'control request')
3677
+ const action = requiredString(object.action, 'action', { max: 64 })
3678
+ if (!CONTROL_ACTIONS.includes(action)) throw fail('invalid_argument', `unknown control action ${action}`)
3679
+ assertAllowedKeys(
3680
+ object,
3681
+ ['expectedRevision', 'action', 'id', 'latchId', 'resolution', 'by'],
3682
+ 'control request',
3683
+ )
3684
+ const expectedRevision = boundedInteger(object.expectedRevision, 'expectedRevision', { min: 1 })
3685
+ const now = this.clock()
3686
+ const by = optionalString(object.by, 'by', { max: 256 }) ?? 'owner'
3687
+
3688
+ if (action === 'resume_budget') {
3689
+ throw fail('policy_removed', 'business quotas were removed in 0.2; refresh the page', {
3690
+ policyRemoved: true,
3691
+ })
3692
+ }
3693
+
3694
+ const id = requiredString(object.id, 'id', { min: 1, max: 512 })
3695
+ if (action === 'recheck') {
3696
+ return this.recheck(id, { revision: expectedRevision, by })
3697
+ }
3698
+ const result = await this.#mutate(
3699
+ (state) => {
3700
+ if (action === 'pause') {
3701
+ const chain = this.#chainById(state, id)
3702
+ if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
3703
+ if (chain.state === 'paused') throw fail('conflict', 'chain is already paused')
3704
+ chain.state = 'paused'
3705
+ chain.pausedAt = now
3706
+ chain.pausedReason = 'owner'
3707
+ return { revision: state.revision + 1, chain: this.#chainView(state, chain) }
3708
+ }
3709
+ if (action === 'resume') {
3710
+ const chain = this.#chainById(state, id)
3711
+ if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
3712
+ if (chain.state === 'open') throw fail('conflict', 'chain is already open')
3713
+ chain.state = 'open'
3714
+ delete chain.pausedAt
3715
+ delete chain.pausedReason
3716
+ const resumed = this.#resumeChainHeld(state, chain, now)
3717
+ return { revision: state.revision + 1, chain: this.#chainView(state, chain), resumed }
3718
+ }
3719
+ if (action === 'close') {
3720
+ const chain = this.#chainById(state, id)
3721
+ if (chain.state === 'closed') throw fail('conflict', 'chain is already closed')
3722
+ this.#closeChainInState(state, chain, now, 'owner')
3723
+ return { revision: state.revision + 1, chain: this.#chainView(state, chain) }
3724
+ }
3725
+ if (action === 'cancel') {
3726
+ const message = findMessage(state, id)
3727
+ if (message !== undefined) {
3728
+ if (!this.#cancelMessageInState(message, now, 'owner')) {
3729
+ throw fail('conflict', 'only a pending or held message can be cancelled')
3730
+ }
3731
+ return { revision: state.revision + 1, message: this.#messageView(state, message) }
3732
+ }
3733
+ const chain = this.#chainById(state, id)
3734
+ let cancelled = 0
3735
+ for (const message of chainMessages(state, chain.id)) {
3736
+ if (this.#cancelMessageInState(message, now, 'owner')) cancelled += 1
3737
+ }
3738
+ if (cancelled === 0) throw fail('conflict', 'chain has no pending or held messages')
3739
+ return { revision: state.revision + 1, cancelled }
3740
+ }
3741
+ if (action === 'release') {
3742
+ const message = findMessage(state, id)
3743
+ if (message !== undefined) {
3744
+ this.#releaseOne(state, message, now)
3745
+ return { revision: state.revision + 1, released: 1 }
3746
+ }
3747
+ const chain = this.#chainById(state, id)
3748
+ let released = 0
3749
+ for (const message of chainMessages(state, chain.id)) {
3750
+ if (message.status !== 'held') continue
3751
+ this.#releaseOne(state, message, now)
3752
+ released += 1
3753
+ }
3754
+ if (released === 0) throw fail('conflict', 'chain has no held messages')
3755
+ return { revision: state.revision + 1, released }
3756
+ }
3757
+ if (action === 'resolve_unknown') {
3758
+ const message = findMessage(state, id)
3759
+ if (message === undefined) throw fail('not_found', 'message not found')
3760
+ if (message.status !== 'unknown') throw fail('conflict', 'only an unknown record can be resolved manually')
3761
+ const resolution = requiredString(object.resolution, 'resolution', { max: 32 })
3762
+ if (resolution === 'accepted') {
3763
+ message.manualConfirmedAt = now
3764
+ message.manualConfirmedBy = by
3765
+ this.#acceptMessage(state, message, now, { recoveryEvidence: 'manual' })
3766
+ return { revision: state.revision + 1, message: this.#messageView(state, message) }
3767
+ }
3768
+ if (resolution === 'closed') {
3769
+ message.status = 'cancelled'
3770
+ message.cancelledAt = now
3771
+ message.completedAt = now
3772
+ message.resolution = 'unconfirmed'
3773
+ message.unconfirmedAt = now
3774
+ message.unconfirmedBy = by
3775
+ message.error = { code: 'owner_closed_unconfirmed', message: 'owner closed this record without confirming receipt' }
3776
+ return { revision: state.revision + 1, message: this.#messageView(state, message) }
3777
+ }
3778
+ throw fail('invalid_argument', "resolution must be 'accepted' or 'closed'")
3779
+ }
3780
+ throw fail('invalid_argument', `unknown control action ${action}`)
3781
+ },
3782
+ { expectedRevision },
3783
+ )
3784
+
3785
+ this.#schedulePump()
3786
+ return result
3787
+ }
3788
+
3789
+ /**
3790
+ * Read-only evidence recheck for an unknown/dispatching record.
3791
+ * @param {string} id
3792
+ * @param {{ revision?: number, by?: string }} [options]
3793
+ */
3794
+ async recheck(id, options = {}) {
3795
+ return this.#trackOperation(() => this.#recheck(id, options))
3796
+ }
3797
+
3798
+ async #recheck(id, options) {
3799
+ const before = this.#message(id)
3800
+ if (before === undefined) throw fail('not_found', 'message not found')
3801
+ if (before.status !== 'unknown' && before.status !== 'dispatching') {
3802
+ throw fail('conflict', 'only an unknown or dispatching record can be rechecked')
3803
+ }
3804
+ let evidence
3805
+ try {
3806
+ evidence = await this.adapter.readEvidence({
3807
+ targetId: before.to,
3808
+ durableMessageId: before.durableMessageId,
3809
+ senderSessionId: before.from,
3810
+ })
3811
+ } catch (error) {
3812
+ evidence = { found: false, readable: false, errorCode: errorCode(error) }
3813
+ }
3814
+ return this.#mutate(
3815
+ (state) => {
3816
+ const message = findMessage(state, id)
3817
+ if (message === undefined) throw fail('not_found', 'message not found')
3818
+ if (message.status !== 'unknown' && message.status !== 'dispatching') {
3819
+ throw fail('conflict', 'message is no longer unknown or dispatching')
3820
+ }
3821
+ if (evidence?.found === true && evidence.identityConflict !== true) {
3822
+ this.#acceptMessage(state, message, this.clock(), {
3823
+ recoveryEvidence: evidence.evidence ?? 'manual_recheck',
3824
+ inboxSeen: evidence.inboxSeen === true,
3825
+ })
3826
+ if (
3827
+ evidence.evidence === 'message' &&
3828
+ Number.isSafeInteger(evidence.seq) &&
3829
+ Number.isSafeInteger(evidence.time) &&
3830
+ evidence.seq >= 0 &&
3831
+ evidence.time >= 0
3832
+ ) {
3833
+ this.#markReceivedInState(state, message, evidence.time, evidence.seq, this.clock())
3834
+ }
3835
+ return { revision: state.revision + 1, status: 'accepted', evidence: evidence.evidence ?? 'host' }
3836
+ }
3837
+ this.#markUnknownInState(state, message, this.clock(), {
3838
+ code:
3839
+ evidence?.identityConflict === true
3840
+ ? 'identity_conflict'
3841
+ : evidence?.readable === false
3842
+ ? 'evidence_unreadable'
3843
+ : 'no_insertion_evidence',
3844
+ message: 'recheck found no valid insertion evidence; the record remains unknown',
3845
+ })
3846
+ return { revision: state.revision + 1, status: 'unknown', evidence: 'none' }
3847
+ },
3848
+ options.revision === undefined ? {} : { expectedRevision: options.revision },
3849
+ )
3850
+ }
3851
+
3852
+ /**
3853
+ * Model-facing control surface: pause/close only a chain the caller opened,
3854
+ * and cancel only the caller's own pending/held records. No global switches,
3855
+ * no budget release, no unknown resolution, no resume.
3856
+ * @param {{ from: string, action: string, id: string }} request
3857
+ */
3858
+ async modelControl(request) {
3859
+ return this.#trackOperation(() => this.#modelControl(request))
3860
+ }
3861
+
3862
+ async #modelControl(request) {
3863
+ const object = assertPlainObject(request, 'model control request')
3864
+ assertAllowedKeys(object, ['from', 'action', 'id'], 'model control request')
3865
+ const from = requiredString(object.from, 'from', { min: 1, max: 256 })
3866
+ const action = requiredString(object.action, 'action', { max: 32 })
3867
+ const id = requiredString(object.id, 'id', { min: 1, max: 512 })
3868
+ const now = this.clock()
3869
+ return this.#mutate((state) => {
3870
+ if (action === 'cancel') {
3871
+ const message = findMessage(state, id)
3872
+ if (message !== undefined) {
3873
+ if (message.from !== from) throw fail('forbidden', 'a model may cancel only its own pending messages')
3874
+ if (!this.#cancelMessageInState(message, now, 'model')) {
3875
+ throw fail('conflict', 'only a pending or held message can be cancelled')
3876
+ }
3877
+ return { revision: state.revision + 1, message: this.#messageView(state, message) }
3878
+ }
3879
+ const chain = this.#chainById(state, id)
3880
+ if (chain.rootOwner !== from) throw fail('forbidden', 'only the root owner may cancel a chain')
3881
+ let cancelled = 0
3882
+ for (const message of chainMessages(state, chain.id)) {
3883
+ if (message.from !== from) continue
3884
+ if (this.#cancelMessageInState(message, now, 'model')) cancelled += 1
3885
+ }
3886
+ if (cancelled === 0) throw fail('conflict', 'chain has no pending or held messages owned by the caller')
3887
+ return { revision: state.revision + 1, cancelled }
3888
+ }
3889
+ if (action === 'pause' || action === 'close') {
3890
+ const chain = this.#chainById(state, id)
3891
+ if (chain.rootOwner !== from) throw fail('forbidden', 'only the root owner may pause or close this chain')
3892
+ if (action === 'pause') {
3893
+ if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
3894
+ if (chain.state === 'paused') throw fail('conflict', 'chain is already paused')
3895
+ chain.state = 'paused'
3896
+ chain.pausedAt = now
3897
+ chain.pausedReason = 'root_owner'
3898
+ return { revision: state.revision + 1, chain: this.#chainView(state, chain) }
3899
+ }
3900
+ if (chain.state === 'closed') throw fail('conflict', 'chain is already closed')
3901
+ this.#closeChainInState(state, chain, now, 'root_owner')
3902
+ return { revision: state.revision + 1, chain: this.#chainView(state, chain) }
3903
+ }
3904
+ throw fail('invalid_argument', 'model control accepts cancel, pause, or close')
3905
+ })
3906
+ }
3907
+
3908
+ // -------------------------------------------------------------------------
3909
+ // Discovery, history, status, snapshot
3910
+ // -------------------------------------------------------------------------
3911
+
3912
+ /**
3913
+ * @param {Record<string, unknown>} [request]
3914
+ * @returns {Promise<Record<string, any>>}
3915
+ */
3916
+ async list(request = {}) {
3917
+ const object = assertPlainObject(request, 'list request')
3918
+ assertAllowedKeys(object, ['workspace', 'title', 'query', 'cursor', 'limit', 'scope'], 'list request')
3919
+ const scope = object.scope === undefined ? 'sessions' : requiredString(object.scope, 'scope', { max: 16 })
3920
+ if (scope === 'workspaces') return this.#listWorkspaces(object)
3921
+ if (scope !== 'sessions') throw fail('invalid_argument', "scope must be 'sessions' or 'workspaces'")
3922
+ const limit = boundedInteger(object.limit ?? 20, 'limit', { min: 1, max: LIMITS.MAX_PAGE })
3923
+ const offset = decodeCursor(object.cursor)
3924
+ const sessions = (await this.adapter.listSessions({ fresh: true }))
3925
+ .filter((session) => session.archived !== true && session.subagent !== true && session.eligible === true)
3926
+ .filter((session) => matchesSessionFilter(session, object))
3927
+ .sort((a, b) => (a.title ?? a.id).localeCompare(b.title ?? b.id))
3928
+ const page = sessions.slice(offset, offset + limit)
3929
+ return {
3930
+ sessions: page.map((session) => publicSession(session)),
3931
+ total: sessions.length,
3932
+ nextCursor: offset + limit < sessions.length ? encodeCursor(offset + limit) : null,
3933
+ }
3934
+ }
3935
+
3936
+ /**
3937
+ * Registered-workspace discovery, including empty workspaces a session-only
3938
+ * list can never show. Registry order is stable only within one snapshot:
3939
+ * concurrent add/remove can move rows between pages, so callers re-check by
3940
+ * id. Returns id/title/path only — never credentials or internals.
3941
+ * @param {Record<string, any>} object
3942
+ */
3943
+ async #listWorkspaces(object) {
3944
+ if (object.workspace !== undefined) {
3945
+ throw fail('invalid_argument', 'the workspace filter is not available with scope=workspaces')
3946
+ }
3947
+ const limit = boundedInteger(object.limit ?? 20, 'limit', { min: 1, max: LIMITS.MAX_PAGE })
3948
+ const offset = decodeCursor(object.cursor)
3949
+ const titleFilter = object.title === undefined ? null : requiredString(object.title, 'title', { max: 500 })
3950
+ const queryFilter = object.query === undefined ? null : requiredString(object.query, 'query', { max: 500 })
3951
+ const workspaces = (await this.adapter.listWorkspaces()).filter((workspace) => {
3952
+ if (titleFilter !== null && !`${workspace.title}`.toLowerCase().includes(titleFilter.toLowerCase())) return false
3953
+ if (queryFilter !== null) {
3954
+ const haystack = `${workspace.title ?? ''} ${workspace.path ?? ''}`.toLowerCase()
3955
+ if (!haystack.includes(queryFilter.toLowerCase())) return false
3956
+ }
3957
+ return true
3958
+ })
3959
+ const page = workspaces.slice(offset, offset + limit)
3960
+ return {
3961
+ workspaces: page,
3962
+ total: workspaces.length,
3963
+ nextCursor: offset + limit < workspaces.length ? encodeCursor(offset + limit) : null,
3964
+ }
3965
+ }
3966
+
3967
+ /**
3968
+ * History is read-only and bounded. Archived sessions are explicitly
3969
+ * eligible here, while subagents remain outside the ordinary-session scope.
3970
+ * @param {Record<string, unknown>} request
3971
+ */
3972
+ async history(request) {
3973
+ const object = assertPlainObject(request, 'history request')
3974
+ assertAllowedKeys(object, ['sessionId', 'mode', 'query', 'limit', 'beforeSeq'], 'history request')
3975
+ const sessionId = requiredString(object.sessionId, 'sessionId', { min: 1, max: 256 })
3976
+ const mode = object.mode === undefined ? 'recent' : requiredString(object.mode, 'mode', { max: 16 })
3977
+ if (mode !== 'recent' && mode !== 'search') throw fail('invalid_argument', "mode must be 'recent' or 'search'")
3978
+ const limit = boundedInteger(object.limit ?? 10, 'limit', { min: 1, max: LIMITS.HISTORY_MAX_MESSAGES })
3979
+ const beforeSeq =
3980
+ object.beforeSeq === undefined ? undefined : boundedInteger(object.beforeSeq, 'beforeSeq', { min: 0 })
3981
+ const query = mode === 'search' ? requiredString(object.query, 'query', { min: 1, max: 500 }) : undefined
3982
+ const info = await this.adapter.getSession(sessionId, { fresh: true })
3983
+ if (info === undefined || info === null) throw fail('target_unregistered', 'session is not registered')
3984
+ if (info.subagent === true) throw fail('target_subagent', 'subagent sessions have no session-link history')
3985
+ if (info.registered === false) throw fail('target_unregistered', 'session is not registered')
3986
+ return this.adapter.history({ sessionId, mode, query, limit, beforeSeq })
3987
+ }
3988
+
3989
+ /** @param {{ from: string, messageId?: string, chainId?: string }} request */
3990
+ async status(request) {
3991
+ const object = assertPlainObject(request, 'status request')
3992
+ assertAllowedKeys(object, ['from', 'messageId', 'chainId'], 'status request')
3993
+ const from = requiredString(object.from, 'from', { min: 1, max: 256 })
3994
+ const messageId = optionalString(object.messageId, 'messageId', { max: 256 })
3995
+ const chainId = optionalString(object.chainId, 'chainId', { max: 256 })
3996
+ const state = this.ledger.read()
3997
+ let related = state.messages.filter((message) => message.from === from || message.to === from)
3998
+ if (messageId !== undefined) related = related.filter((message) => message.id === messageId)
3999
+ if (chainId !== undefined) related = related.filter((message) => message.chainId === chainId)
4000
+ related.sort((a, b) => b.createdAt - a.createdAt)
4001
+ const limited = related.slice(0, LIMITS.MAX_PAGE)
4002
+ const chains = []
4003
+ const seen = new Set()
4004
+ for (const message of limited) {
4005
+ if (seen.has(message.chainId)) continue
4006
+ seen.add(message.chainId)
4007
+ const chain = state.chains[message.chainId]
4008
+ if (chain !== undefined) chains.push(this.#chainView(state, chain))
4009
+ }
4010
+ const replies = {}
4011
+ for (const message of limited) {
4012
+ const children = state.messages.filter((candidate) => candidate.parentMessageId === message.id)
4013
+ if (children.length > 0) {
4014
+ replies[message.id] = children.slice(0, 20).map((child) => ({
4015
+ id: child.id,
4016
+ from: child.from,
4017
+ to: child.to,
4018
+ status: child.status,
4019
+ createdAt: child.createdAt,
4020
+ late: child.late === true,
4021
+ }))
4022
+ }
4023
+ }
4024
+ return {
4025
+ messages: limited.map((message) => this.#messageView(state, message, from)),
4026
+ chains,
4027
+ replies,
4028
+ total: related.length,
4029
+ hasMore: related.length > limited.length,
4030
+ budgetLatches: [],
4031
+ }
4032
+ }
4033
+
4034
+ /** @param {Record<string, unknown>} [request] */
4035
+ async snapshot(request = {}) {
4036
+ const object = assertPlainObject(request, 'snapshot request')
4037
+ assertAllowedKeys(object, ['limit', 'offset', 'filter', 'sessionId', 'chainId', 'view', 'senderSessionId', 'receiverSessionId'], 'snapshot request')
4038
+ const view = object.view === undefined ? 'full' : requiredString(object.view, 'view', { max: 16 })
4039
+ if (!['full', 'settings', 'records', 'sessions', 'thread'].includes(view)) throw fail('invalid_argument', `unknown snapshot view ${view}`)
4040
+ const limit = boundedInteger(object.limit ?? LIMITS.MAX_PAGE, 'limit', { min: 1, max: LIMITS.MAX_SNAPSHOT_PAGE })
4041
+ const offset = boundedInteger(object.offset ?? 0, 'offset', { min: 0 })
4042
+ const filter = object.filter === undefined ? 'all' : requiredString(object.filter, 'filter', { max: 32 })
4043
+ if (!SNAPSHOT_FILTERS.includes(filter)) throw fail('invalid_argument', `unknown snapshot filter ${filter}`)
4044
+ const sessionId = optionalString(object.sessionId, 'sessionId', { max: 256 })
4045
+ const chainId = optionalString(object.chainId, 'chainId', { max: 256 })
4046
+ const senderSessionId = optionalString(object.senderSessionId, 'senderSessionId', { max: 256 })
4047
+ const receiverSessionId = optionalString(object.receiverSessionId, 'receiverSessionId', { max: 256 })
4048
+ if ((filter === 'received' || filter === 'sent') && sessionId === undefined) {
4049
+ throw fail('invalid_argument', `${filter} filter requires sessionId`)
4050
+ }
4051
+ if (filter === 'chain' && chainId === undefined) {
4052
+ throw fail('invalid_argument', 'chain filter requires chainId')
4053
+ }
4054
+
4055
+ const state = this.ledger.read()
4056
+ if (view === 'settings') {
4057
+ // Fast path for the settings surface: no session discovery, no message
4058
+ // page, no chain views. This keeps opening the panel cheap even with a
4059
+ // large communication history; the records tab requests the full view.
4060
+ const stats = this.ledger.stats()
4061
+ const queuedBytes = queuedBytesOf(state)
4062
+ return {
4063
+ apiVersion: API_VERSION,
4064
+ version: PLUGIN_VERSION,
4065
+ revision: state.revision,
4066
+ settingsRevision: state.settingsRevision,
4067
+ settings: structuredClone(state.settings),
4068
+ messages: undefined,
4069
+ chains: [],
4070
+ budgetLatches: [],
4071
+ sessions: [],
4072
+ total: state.messages.length,
4073
+ hasMore: false,
4074
+ nextOffset: null,
4075
+ inFlight: this.#attempts.size,
4076
+ storage: this.#storageView(stats, queuedBytes),
4077
+ technicalLimits: this.#technicalLimits(stats),
4078
+ ...(state.migration === undefined ? {} : { migration: publicMigration(state.migration) }),
4079
+ }
4080
+ }
4081
+ if (view === 'thread') {
4082
+ if (chainId === undefined) throw fail('invalid_argument', 'thread view requires chainId')
4083
+ const thread = state.messages
4084
+ .filter((message) => message.chainId === chainId)
4085
+ .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
4086
+ .map((message) => this.#messageView(state, message, undefined))
4087
+ const chainIds = new Set([chainId])
4088
+ const stats = this.ledger.stats()
4089
+ const queuedBytes = queuedBytesOf(state)
4090
+ return {
4091
+ apiVersion: API_VERSION,
4092
+ version: PLUGIN_VERSION,
4093
+ revision: state.revision,
4094
+ settingsRevision: state.settingsRevision,
4095
+ settings: structuredClone(state.settings),
4096
+ messages: thread,
4097
+ chains: this.#chainViewsFor(state, chainIds),
4098
+ budgetLatches: [],
4099
+ sessions: [],
4100
+ total: thread.length,
4101
+ hasMore: false,
4102
+ nextOffset: null,
4103
+ inFlight: this.#attempts.size,
4104
+ storage: this.#storageView(stats, queuedBytes),
4105
+ technicalLimits: this.#technicalLimits(stats),
4106
+ ...(state.migration === undefined ? {} : { migration: publicMigration(state.migration) }),
4107
+ }
4108
+ }
4109
+ if (view === 'sessions') {
4110
+ const sessions = await this.adapter.listSessions({})
4111
+ const stats = this.ledger.stats()
4112
+ const queuedBytes = queuedBytesOf(state)
4113
+ return {
4114
+ apiVersion: API_VERSION,
4115
+ version: PLUGIN_VERSION,
4116
+ revision: state.revision,
4117
+ settingsRevision: state.settingsRevision,
4118
+ settings: structuredClone(state.settings),
4119
+ messages: undefined,
4120
+ chains: [],
4121
+ budgetLatches: [],
4122
+ sessions: sessions.map((session) => publicSession(session)),
4123
+ total: state.messages.length,
4124
+ hasMore: false,
4125
+ nextOffset: null,
4126
+ inFlight: this.#attempts.size,
4127
+ storage: this.#storageView(stats, queuedBytes),
4128
+ technicalLimits: this.#technicalLimits(stats),
4129
+ ...(state.migration === undefined ? {} : { migration: publicMigration(state.migration) }),
4130
+ }
4131
+ }
4132
+ let messages = state.messages
4133
+ if (sessionId !== undefined) {
4134
+ messages = messages.filter((message) => message.from === sessionId || message.to === sessionId)
4135
+ }
4136
+ if (chainId !== undefined) messages = messages.filter((message) => message.chainId === chainId)
4137
+ if (senderSessionId !== undefined) messages = messages.filter((message) => message.from === senderSessionId)
4138
+ if (receiverSessionId !== undefined) messages = messages.filter((message) => message.to === receiverSessionId)
4139
+ messages = messages.filter((message) => {
4140
+ if (filter === 'all' || filter === 'chain') return true
4141
+ if (filter === 'received') return message.to === sessionId
4142
+ if (filter === 'sent') return message.from === sessionId
4143
+ if (filter === 'pending_reply') {
4144
+ return message.kind === 'request' && message.status === 'accepted' && message.replyStatus === 'waiting'
4145
+ }
4146
+ if (filter === 'abnormal') {
4147
+ return (
4148
+ message.status === 'failed' ||
4149
+ message.status === 'unknown' ||
4150
+ (message.kind === 'request' && message.replyStatus === 'timed_out')
4151
+ )
4152
+ }
4153
+ if (filter === 'unknown') return message.status === 'unknown'
4154
+ if (filter === 'held') return message.status === 'held'
4155
+ return true
4156
+ })
4157
+ messages = [...messages].sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id))
4158
+ const total = messages.length
4159
+ const page = messages.slice(offset, offset + limit)
4160
+
4161
+ const chainIds = new Set(page.map((message) => message.chainId))
4162
+ if (chainId !== undefined) chainIds.add(chainId)
4163
+ const chains = this.#chainViewsFor(state, chainIds)
4164
+ // The records view is a fast mailbox page: it intentionally omits session
4165
+ // discovery (the client fetches the session list separately once) so a
4166
+ // large Host session store cannot block the first page of records.
4167
+ const sessions = view === 'records' ? [] : await this.adapter.listSessions({})
4168
+ const stats = this.ledger.stats()
4169
+ const queuedBytes = queuedBytesOf(state)
4170
+ const storage = this.#storageView(stats, queuedBytes)
4171
+ return {
4172
+ apiVersion: API_VERSION,
4173
+ version: PLUGIN_VERSION,
4174
+ revision: state.revision,
4175
+ settingsRevision: state.settingsRevision,
4176
+ settings: structuredClone(state.settings),
4177
+ messages: page.map((message) => this.#messageView(state, message, sessionId)),
4178
+ chains,
4179
+ budgetLatches: [],
4180
+ sessions: sessions.map((session) => publicSession(session)),
4181
+ total,
4182
+ hasMore: offset + page.length < total,
4183
+ nextOffset: offset + page.length < total ? offset + page.length : null,
4184
+ inFlight: this.#attempts.size,
4185
+ storage,
4186
+ technicalLimits: this.#technicalLimits(stats),
4187
+ ...(state.migration === undefined ? {} : { migration: publicMigration(state.migration) }),
4188
+ }
4189
+ }
4190
+
4191
+ /** @param {Record<string, any>} stats @param {number} queuedBytes */
4192
+ #storageView(stats, queuedBytes) {
4193
+ const ledgerFull = stats.bytes >= stats.maxBytes
4194
+ const queueFull = queuedBytes >= this.maxQueuedBytes
4195
+ const ledgerNear = stats.bytes >= stats.maxBytes * 0.9
4196
+ const queueNear = queuedBytes >= this.maxQueuedBytes * 0.9
4197
+ let health = 'ok'
4198
+ if (stats.health === 'storage_error') health = 'storage_error'
4199
+ else if (ledgerFull || queueFull) health = 'full'
4200
+ else if (ledgerNear || queueNear) health = 'near_capacity'
4201
+ return {
4202
+ bytes: stats.bytes,
4203
+ maxBytes: stats.maxBytes,
4204
+ queuedBytes,
4205
+ maxQueuedBytes: this.maxQueuedBytes,
4206
+ messageCount: stats.messageCount,
4207
+ chainCount: stats.chainCount,
4208
+ health,
4209
+ ...(stats.error === undefined ? {} : { error: structuredClone(stats.error) }),
4210
+ }
4211
+ }
4212
+
4213
+ /** @param {Record<string, any>} stats */
4214
+ #technicalLimits(stats) {
4215
+ return {
4216
+ maxContentBytes: LIMITS.MAX_CONTENT_BYTES,
4217
+ maxCallBytes: LIMITS.MAX_CALL_BYTES,
4218
+ maxLedgerBytes: stats.maxBytes,
4219
+ maxQueuedBytes: this.maxQueuedBytes,
4220
+ maxReferences: LIMITS.MAX_REFERENCES,
4221
+ maxReferenceChars: LIMITS.MAX_REFERENCE_CHARS,
4222
+ maxPaths: LIMITS.MAX_PATHS,
4223
+ maxPathChars: LIMITS.MAX_PATH_CHARS,
4224
+ historyPageMax: LIMITS.HISTORY_MAX_MESSAGES,
4225
+ listPageMax: LIMITS.MAX_PAGE,
4226
+ historyCharsMax: LIMITS.HISTORY_MAX_CHARS,
4227
+ snapshotPageMax: LIMITS.MAX_SNAPSHOT_PAGE,
4228
+ operationFirstSubmitMs: LIMITS.OPERATION_PAST_MS,
4229
+ operationRetentionMs: LIMITS.OPERATION_RETENTION_MS,
4230
+ }
4231
+ }
4232
+
4233
+ // -------------------------------------------------------------------------
4234
+ // State helpers
4235
+ // -------------------------------------------------------------------------
4236
+
4237
+ /** @param {string} id */
4238
+ #message(id) {
4239
+ return this.ledger.read().messages.find((message) => message.id === id)
4240
+ }
4241
+
4242
+ /**
4243
+ * Root operationId lookup across BOTH durable operation kinds: send/reply
4244
+ * records and create journals. The canonical payload includes `kind`, so a
4245
+ * create canonical can never equal a send/reply canonical and sealed (s2)
4246
+ * digests of different canonicals cannot collide; any cross-kind hit is
4247
+ * always a conflict before `#operationResult` could run.
4248
+ * @param {Record<string, any>} state
4249
+ * @param {string} from
4250
+ * @param {string} operationId
4251
+ */
4252
+ #lookupOperation(state, from, operationId) {
4253
+ const operation = Object.values(state.operations).find(
4254
+ (candidate) => candidate?.from === from && candidate?.operationId === operationId,
4255
+ )
4256
+ if (operation !== undefined) return operation
4257
+ return this.#lookupCreate(state, from, operationId)
4258
+ }
4259
+
4260
+ /**
4261
+ * @param {Record<string, any>} state
4262
+ * @param {string} from
4263
+ * @param {string} operationId
4264
+ * @returns {Record<string, any> | undefined}
4265
+ */
4266
+ #lookupCreate(state, from, operationId) {
4267
+ return Object.values(state.creates ?? {}).find(
4268
+ (journal) => journal?.from === from && journal?.operationId === operationId,
4269
+ )
4270
+ }
4271
+
4272
+ /**
4273
+ * @param {string} from
4274
+ * @param {string} operationId
4275
+ * @param {readonly string[]} targets
4276
+ */
4277
+ #operationKey(from, operationId, targets) {
4278
+ return `${from}|${operationId}|${[...targets].sort().join(',')}`
4279
+ }
4280
+
4281
+ /**
4282
+ * @param {Record<string, any>} state
4283
+ * @param {Record<string, any>} operation
4284
+ */
4285
+ #operationResult(state, operation) {
4286
+ return {
4287
+ operationId: operation.operationId,
4288
+ idempotent: true,
4289
+ chainId: state.messages.find((message) => message.id === operation.messageIds[operation.targets[0]])?.chainId,
4290
+ held: operation.targets.every((target) => findMessage(state, operation.messageIds[target])?.status === 'held'),
4291
+ results: operation.targets.map((target) => {
4292
+ const message = findMessage(state, operation.messageIds[target])
4293
+ return {
4294
+ target,
4295
+ id: operation.messageIds[target],
4296
+ status: message?.status ?? 'missing',
4297
+ dispatchStatus: message?.status ?? 'missing',
4298
+ error: message?.error ?? null,
4299
+ }
4300
+ }),
4301
+ }
4302
+ }
4303
+
4304
+ /**
4305
+ * @param {Record<string, any>} state
4306
+ * @param {Record<string, any>} chain
4307
+ * @param {number} now
4308
+ * @param {string} by
4309
+ */
4310
+ #closeChainInState(state, chain, now, by) {
4311
+ chain.state = 'closed'
4312
+ chain.closedAt = now
4313
+ chain.closedBy = by
4314
+ for (const message of chainMessages(state, chain.id)) {
4315
+ if (message.status === 'pending' || message.status === 'held') {
4316
+ message.status = 'cancelled'
4317
+ message.cancelledAt = now
4318
+ message.completedAt = now
4319
+ message.cancelReason = 'chain_closed'
4320
+ message.error = { code: 'chain_closed', message: 'chain closed before delivery' }
4321
+ message.releaseAuthorized = false
4322
+ continue
4323
+ }
4324
+ // An explicitly closed group ends an accepted but still-waiting
4325
+ // request. Keep its accepted/replyStatus semantics intact (no fake
4326
+ // answer, no work cancellation); only record the completion timestamp
4327
+ // used by retention/terminal cleanup.
4328
+ if (
4329
+ message.status === 'accepted' &&
4330
+ message.kind === 'request' &&
4331
+ message.replyStatus === 'waiting' &&
4332
+ !Number.isSafeInteger(message.completedAt)
4333
+ ) {
4334
+ message.completedAt = now
4335
+ message.completedReason = 'chain_closed'
4336
+ }
4337
+ }
4338
+ }
4339
+
4340
+ /**
4341
+ * @param {Record<string, any>} message
4342
+ * @param {number} now
4343
+ * @param {string} by
4344
+ * @returns {boolean}
4345
+ */
4346
+ #cancelMessageInState(message, now, by) {
4347
+ if (message.status !== 'pending' && message.status !== 'held') return false
4348
+ message.status = 'cancelled'
4349
+ message.cancelledAt = now
4350
+ message.completedAt = now
4351
+ message.cancelledBy = by
4352
+ message.cancelReason = 'cancelled'
4353
+ message.error = { code: 'cancelled', message: 'cancelled before host insertion' }
4354
+ message.releaseAuthorized = false
4355
+ return true
4356
+ }
4357
+
4358
+ /**
4359
+ * Explicit release for historical/manual held records. It never bypasses a
4360
+ * global pause or a paused/closed chain.
4361
+ * @param {Record<string, any>} state
4362
+ * @param {Record<string, any>} message
4363
+ * @param {number} now
4364
+ */
4365
+ #releaseOne(state, message, now) {
4366
+ if (message.status !== 'held') throw fail('conflict', 'only a held message can be released')
4367
+ const chain = state.chains[message.chainId]
4368
+ if (chain === undefined) throw fail('chain_missing', 'chain is missing')
4369
+ if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
4370
+ if (chain.state === 'paused') throw fail('chain_paused', 'resume the chain before releasing held messages')
4371
+ if (!state.settings.enabled) throw fail('disabled', 'enable session-link before releasing held messages')
4372
+ if (state.settings.paused) throw fail('paused', 'clear the global pause before releasing held messages')
4373
+ message.status = 'pending'
4374
+ message.releasedAt = now
4375
+ message.heldAt = null
4376
+ message.heldReason = null
4377
+ message.error = null
4378
+ delete message.heldOverflow
4379
+ message.releaseAuthorized = false
4380
+ }
4381
+
4382
+ /** @param {Record<string, any>} state @param {string} id */
4383
+ #chainById(state, id) {
4384
+ const chain = state.chains[id]
4385
+ if (chain === undefined) throw fail('not_found', 'chain not found')
4386
+ return chain
4387
+ }
4388
+
4389
+ /**
4390
+ * @param {Record<string, any>} state
4391
+ * @param {Record<string, any>} chain
4392
+ * @param {Record<string, any>} [stats]
4393
+ */
4394
+ #chainView(state, chain, stats) {
4395
+ let entry = stats
4396
+ if (entry === undefined) {
4397
+ const messages = chainMessages(state, chain.id)
4398
+ entry = {
4399
+ count: messages.length,
4400
+ pending: messages.filter((message) => message.status === 'pending').length,
4401
+ held: messages.filter((message) => message.status === 'held').length,
4402
+ dispatching: messages.filter((message) => message.status === 'dispatching').length,
4403
+ unknown: messages.filter((message) => message.status === 'unknown').length,
4404
+ lastMessageAt: messages.reduce((latest, message) => Math.max(latest, message.createdAt ?? 0), 0),
4405
+ }
4406
+ }
4407
+ return {
4408
+ id: chain.id,
4409
+ state: chain.state,
4410
+ rootOwner: chain.rootOwner,
4411
+ createdAt: chain.createdAt,
4412
+ ...(chain.pausedAt === undefined ? {} : { pausedAt: chain.pausedAt }),
4413
+ ...(chain.closedAt === undefined ? {} : { closedAt: chain.closedAt }),
4414
+ ...(chain.closedBy === undefined ? {} : { closedBy: chain.closedBy }),
4415
+ ...(chain.pausedReason === undefined ? {} : { pausedReason: chain.pausedReason }),
4416
+ messageCount: entry.count,
4417
+ pending: entry.pending,
4418
+ held: entry.held,
4419
+ dispatching: entry.dispatching,
4420
+ unknown: entry.unknown,
4421
+ lastMessageAt: entry.lastMessageAt || null,
4422
+ }
4423
+ }
4424
+
4425
+ /**
4426
+ * @param {Record<string, any>} state
4427
+ * @param {Iterable<string>} chainIds
4428
+ */
4429
+ #chainViewsFor(state, chainIds) {
4430
+ const stats = new Map()
4431
+ for (const id of chainIds) {
4432
+ if (state.chains[id] === undefined) continue
4433
+ stats.set(id, { count: 0, pending: 0, held: 0, dispatching: 0, unknown: 0, lastMessageAt: 0 })
4434
+ }
4435
+ if (stats.size > 0) {
4436
+ for (const message of state.messages) {
4437
+ const entry = stats.get(message.chainId)
4438
+ if (entry === undefined) continue
4439
+ entry.count += 1
4440
+ if (message.status === 'pending') entry.pending += 1
4441
+ else if (message.status === 'held') entry.held += 1
4442
+ else if (message.status === 'dispatching') entry.dispatching += 1
4443
+ else if (message.status === 'unknown') entry.unknown += 1
4444
+ if (Number.isSafeInteger(message.createdAt) && message.createdAt > entry.lastMessageAt) {
4445
+ entry.lastMessageAt = message.createdAt
4446
+ }
4447
+ }
4448
+ }
4449
+ return [...stats.entries()].map(([id, entry]) => this.#chainView(state, state.chains[id], entry))
4450
+ }
4451
+
4452
+ /**
4453
+ * @param {Record<string, any>} state
4454
+ * @param {Record<string, any>} message
4455
+ * @param {string} [sessionId]
4456
+ */
4457
+ #messageView(state, message, sessionId) {
4458
+ const chain = state.chains[message.chainId]
4459
+ const automaticHeld = isAutomaticHeld(message)
4460
+ const releasable =
4461
+ message.status === 'held' &&
4462
+ state.settings.enabled &&
4463
+ !state.settings.paused &&
4464
+ chain?.state === 'open' &&
4465
+ !automaticHeld
4466
+ return {
4467
+ ...structuredClone(message),
4468
+ status: message.status,
4469
+ dispatchStatus: message.status,
4470
+ text: message.content?.text ?? '',
4471
+ subject: displaySubject(message),
4472
+ direction:
4473
+ sessionId === undefined ? null : message.from === sessionId ? 'out' : message.to === sessionId ? 'in' : null,
4474
+ chainState: chain?.state ?? null,
4475
+ releasable,
4476
+ replies: state.messages
4477
+ .filter((candidate) => candidate.parentMessageId === message.id)
4478
+ .slice(0, 20)
4479
+ .map((candidate) => candidate.id),
4480
+ }
4481
+ }
4482
+
4483
+ /** @param {Record<string, any>} message */
4484
+ #publicMessage(message) {
4485
+ return { ...structuredClone(message), rendered: renderInboundText(message) }
4486
+ }
4487
+
4488
+ /** @returns {{ operationId: string }} */
4489
+ mintOperation() {
4490
+ return { operationId: mintOperationId(this.uuid(), this.clock()) }
4491
+ }
4492
+
4493
+ /** @param {string} id */
4494
+ inspect(id) {
4495
+ const state = this.ledger.read()
4496
+ const message = findMessage(state, id)
4497
+ if (message === undefined) return undefined
4498
+ const chain = state.chains[message.chainId]
4499
+ return {
4500
+ message: this.#messageView(state, message),
4501
+ chain: chain === undefined ? null : this.#chainView(state, chain),
4502
+ replies: state.messages.filter((candidate) => candidate.parentMessageId === message.id),
4503
+ }
4504
+ }
4505
+
4506
+ /**
4507
+ * Stop background scheduling, abort uncommitted followups, and wait at most
4508
+ * five seconds. If a task remains, mark still-dispatching rows unknown and
4509
+ * report whether it is safe to release the writer; if the mark itself cannot
4510
+ * complete, the caller must keep the writer lock instead of risking a
4511
+ * second-writer handoff.
4512
+ * @returns {Promise<{ drained: boolean, timedOut: boolean, markedUnknown: number, storageSafe: boolean }>}
4513
+ */
4514
+ async dispose() {
4515
+ if (this.#disposePromise !== undefined) return this.#disposePromise
4516
+ this.#disposed = true
4517
+ if (this.#receiptDispose !== undefined) {
4518
+ try {
4519
+ this.#receiptDispose()
4520
+ } catch {
4521
+ // A listener already removed by the host is safe to ignore.
4522
+ }
4523
+ this.#receiptDispose = undefined
4524
+ }
4525
+ for (const token of this.#attempts) token.aborted = true
4526
+ for (const resolve of this.#waiters.splice(0)) resolve()
4527
+ this.#disposePromise = (async () => {
4528
+ const drained = await this.#drainWithTimeout(LIMITS.DISPOSE_DRAIN_MS)
4529
+ let markedUnknown = 0
4530
+ let markComplete = false
4531
+ if (drained) {
4532
+ markComplete = true
4533
+ } else if (this.ledger.writable) {
4534
+ try {
4535
+ const result = await this.#withTimeout(
4536
+ this.ledger.mutate((state) => {
4537
+ const now = this.clock()
4538
+ let count = 0
4539
+ for (const message of state.messages) {
4540
+ if (message.status !== 'dispatching') continue
4541
+ this.#markUnknownInState(state, message, now, {
4542
+ code: 'dispose_timeout',
4543
+ message: 'session-link unloaded during delivery; the insertion is unknown and was not retried',
4544
+ })
4545
+ count += 1
4546
+ }
4547
+ return count === 0 ? skipMutation(0) : count
4548
+ }),
4549
+ 1000,
4550
+ )
4551
+ markedUnknown = result
4552
+ // A completed queued mutation proves the ledger write queue is idle
4553
+ // after the abort. Every late task checks `disposed` before enqueueing
4554
+ // another write, so the writer can be handed off even if a host await
4555
+ // is still outstanding.
4556
+ markComplete = true
4557
+ } catch {
4558
+ markComplete = false
4559
+ }
4560
+ }
4561
+ return {
4562
+ drained,
4563
+ timedOut: !drained,
4564
+ markedUnknown,
4565
+ storageSafe: drained || markComplete,
4566
+ }
4567
+ })()
4568
+ return this.#disposePromise
4569
+ }
4570
+
4571
+ /** @param {number} ms @returns {Promise<boolean>} */
4572
+ async #drainWithTimeout(ms) {
4573
+ let timer
4574
+ const timeout = new Promise((resolve) => {
4575
+ timer = setTimeout(() => resolve(false), ms)
4576
+ timer.unref?.()
4577
+ })
4578
+ try {
4579
+ return await Promise.race([this.drain().then(() => true), timeout])
4580
+ } finally {
4581
+ clearTimeout(timer)
4582
+ }
4583
+ }
4584
+
4585
+ /** @template T @param {Promise<T>} promise @param {number} ms @returns {Promise<T>} */
4586
+ async #withTimeout(promise, ms) {
4587
+ let timer
4588
+ const timeout = new Promise((_, reject) => {
4589
+ timer = setTimeout(() => reject(fail('timeout', 'operation timed out')), ms)
4590
+ timer.unref?.()
4591
+ })
4592
+ try {
4593
+ return await Promise.race([promise, timeout])
4594
+ } finally {
4595
+ clearTimeout(timer)
4596
+ }
4597
+ }
4598
+ }
4599
+
4600
+ /**
4601
+ * @param {Record<string, any>} session
4602
+ * @param {Record<string, unknown>} filter
4603
+ * @returns {boolean}
4604
+ */
4605
+ function matchesSessionFilter(session, filter) {
4606
+ const workspace = filter.workspace
4607
+ if (workspace !== undefined) {
4608
+ const text = requiredString(workspace, 'workspace', { max: 500, code: 'tool_args' })
4609
+ const haystack = `${session.workspaceTitle ?? ''} ${session.workspaceId ?? ''}`.toLowerCase()
4610
+ if (!haystack.includes(text.toLowerCase())) return false
4611
+ }
4612
+ const title = filter.title
4613
+ if (title !== undefined) {
4614
+ const text = requiredString(title, 'title', { max: 500, code: 'tool_args' })
4615
+ if (!`${session.title ?? ''}`.toLowerCase().includes(text.toLowerCase())) return false
4616
+ }
4617
+ const query = filter.query
4618
+ if (query !== undefined) {
4619
+ const text = requiredString(query, 'query', { max: 500, code: 'tool_args' })
4620
+ const haystack = `${session.title ?? ''} ${session.workspaceTitle ?? ''} ${session.id}`
4621
+ if (!haystack.toLowerCase().includes(text.toLowerCase())) return false
4622
+ }
4623
+ return true
4624
+ }
4625
+
4626
+ /**
4627
+ * @param {Record<string, any>} session
4628
+ * @returns {Record<string, any>}
4629
+ */
4630
+ export function publicSession(session) {
4631
+ return {
4632
+ id: session.id,
4633
+ sessionId: session.id,
4634
+ title: session.title ?? session.id,
4635
+ workspaceId: session.workspaceId ?? null,
4636
+ workspaceTitle: session.workspaceTitle ?? null,
4637
+ status: session.agentStatus ?? (session.live === true ? 'idle' : 'cold'),
4638
+ agentStatus: session.agentStatus ?? (session.live === true ? 'idle' : 'cold'),
4639
+ live: session.live === true,
4640
+ archived: session.archived === true,
4641
+ subagent: session.subagent === true,
4642
+ eligible: session.eligible === true,
4643
+ formatStatus: session.formatStatus ?? 'current',
4644
+ }
4645
+ }
4646
+
4647
+ /** @param {string|undefined} cursor @returns {number} */
4648
+ function decodeCursor(cursor) {
4649
+ if (cursor === undefined || cursor === null) return 0
4650
+ if (typeof cursor !== 'string' || cursor.length === 0 || cursor.length > 512) {
4651
+ throw fail('invalid_argument', 'cursor is invalid')
4652
+ }
4653
+ let decoded
4654
+ try {
4655
+ decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
4656
+ } catch {
4657
+ throw fail('invalid_argument', 'cursor is invalid')
4658
+ }
4659
+ if (!isPlainObject(decoded) || decoded.v !== 1 || !Number.isSafeInteger(decoded.offset) || decoded.offset < 0) {
4660
+ throw fail('invalid_argument', 'cursor is invalid')
4661
+ }
4662
+ if (decoded.offset > 1_000_000) throw fail('invalid_argument', 'cursor is out of range')
4663
+ return decoded.offset
4664
+ }
4665
+
4666
+ /** @param {number} offset @returns {string} */
4667
+ function encodeCursor(offset) {
4668
+ return Buffer.from(JSON.stringify({ v: 1, offset }), 'utf8').toString('base64url')
4669
+ }