@deepseek-ai/dsh-session 0.2.2-rc.1 → 0.2.2-rc.3

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 (41) hide show
  1. package/README.i18n.yaml +4 -4
  2. package/README.md +3 -1
  3. package/README.zh.md +3 -1
  4. package/lib/index.js +151 -79
  5. package/lib/types/fork.d.ts.map +1 -0
  6. package/lib/types/fork.js.map +1 -0
  7. package/lib/types/index.d.ts +17 -1
  8. package/lib/types/index.d.ts.map +1 -0
  9. package/lib/types/index.js +82 -63
  10. package/lib/types/index.js.map +1 -0
  11. package/lib/types/known-event-types.d.ts.map +1 -0
  12. package/lib/types/known-event-types.js +1 -0
  13. package/lib/types/known-event-types.js.map +1 -0
  14. package/lib/types/preparation.d.ts.map +1 -0
  15. package/lib/types/preparation.js.map +1 -0
  16. package/lib/types/repair.d.ts.map +1 -0
  17. package/lib/types/repair.js.map +1 -0
  18. package/lib/types/request-header.d.ts.map +1 -0
  19. package/lib/types/request-header.js.map +1 -0
  20. package/lib/types/seq-ranges.d.ts.map +1 -0
  21. package/lib/types/seq-ranges.js.map +1 -0
  22. package/lib/types/surface.d.ts +6 -0
  23. package/lib/types/surface.d.ts.map +1 -0
  24. package/lib/types/surface.js +57 -11
  25. package/lib/types/surface.js.map +1 -0
  26. package/lib/types/tool-history.d.ts.map +1 -0
  27. package/lib/types/tool-history.js.map +1 -0
  28. package/lib/types/types.d.ts +4 -0
  29. package/lib/types/types.d.ts.map +1 -0
  30. package/lib/types/types.js.map +1 -0
  31. package/package.json +12 -9
  32. package/src/fork.ts +30 -0
  33. package/src/index.ts +1555 -0
  34. package/src/known-event-types.ts +91 -0
  35. package/src/preparation.ts +49 -0
  36. package/src/repair.ts +211 -0
  37. package/src/request-header.ts +69 -0
  38. package/src/seq-ranges.ts +73 -0
  39. package/src/surface.ts +758 -0
  40. package/src/tool-history.ts +75 -0
  41. package/src/types.ts +574 -0
package/src/index.ts ADDED
@@ -0,0 +1,1555 @@
1
+ /**
2
+ * Event-sourced session service: append-only session log, in-memory store, and
3
+ * the derived LLM message history. Persistence is a plugin concern (subscribe
4
+ * to `session/event`, drain on `session/flush`).
5
+ *
6
+ * @module @deepseek-ai/dsh-session
7
+ */
8
+
9
+ import { Context, Service } from '@deepseek-ai/cordis'
10
+ import { isAbsolute } from 'node:path'
11
+ import { brandString } from '@deepseek-ai/dsh-brand'
12
+ import { assertNever, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
13
+ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
14
+ import type { Scoped } from '@deepseek-ai/dsh-scope'
15
+ import type { Message } from '@deepseek-ai/dsh-llm'
16
+ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts'
17
+ import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
18
+ import type { CreateSessionOptions, EpochHeader, PluginRecord, PluginRecordMap, PluginRecordType, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventInput, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts'
19
+ import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts'
20
+ import type { SessionSurface, SessionMessageProjection } from './surface.ts'
21
+ import { foldRequestHeader } from './request-header.ts'
22
+ import { ToolHistoryProjection } from './tool-history.ts'
23
+ import type { ToolHistory } from '@deepseek-ai/dsh-llm'
24
+
25
+ import { buildForkSeed } from './fork.ts'
26
+
27
+ export { buildForkSeed } from './fork.ts'
28
+ export * from './types.ts'
29
+ export { SessionPreparation } from './preparation.ts'
30
+ export type { SessionPreparationOptions } from './preparation.ts'
31
+ export type { AssistantMessage, DeveloperMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
32
+ export { interruptedTurnClosers, ToolCallRecovery, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
33
+ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts'
34
+ export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
35
+ export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
36
+ export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts'
37
+
38
+ declare module '@deepseek-ai/cordis' {
39
+ interface Context {
40
+ sessions: SessionStore
41
+ }
42
+
43
+ interface Events {
44
+ /**
45
+ * Read-only creation announcement after store entry.
46
+ * New events reject until every synchronous listener has returned successfully.
47
+ * A synchronous throw vetoes creation and rolls back with a paired disposal;
48
+ * detach requested during dispatch is deferred.
49
+ * A returned-promise rejection is logged but cannot retroactively veto this
50
+ * synchronous boundary.
51
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
52
+ * receive only sessions entered through that agent's context.
53
+ * @param session - the session just entered and announced.
54
+ * @mode emit
55
+ */
56
+ 'session/created'(this: Scoped<Session>, session: Session): void
57
+ /**
58
+ * Emitted once when an announced session leaves the store, including
59
+ * publication rollback, but never for an entry whose creation announcement
60
+ * did not begin. Listener failures are logged and contained.
61
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
62
+ * @param session - the session that is no longer live in the store.
63
+ * @mode emit
64
+ */
65
+ 'session/disposed'(this: Scoped<Session>, session: Session): void
66
+ /**
67
+ * Post-commit, fire-and-forget append feed. The listener snapshot resolves
68
+ * before the log push, but callbacks run after it; observer failures are
69
+ * logged and contained without making the committed append fail.
70
+ * Array append commits every member before publishing them in order;
71
+ * `session.seq` and `session.surface` already include the complete batch.
72
+ * `session.currentBatchPublication` identifies the member being delivered.
73
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
74
+ * receive only events from sessions entered through that agent's context.
75
+ * @param session - the session whose log grew.
76
+ * @param event - the appended event, exactly as recorded.
77
+ * @mode emit
78
+ */
79
+ 'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
80
+ /**
81
+ * Awaited parallel durability checkpoint: every listener runs and the
82
+ * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch
83
+ * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
84
+ * @param session - the session whose buffered events must reach durable storage.
85
+ * @mode parallel
86
+ */
87
+ 'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
88
+ }
89
+ }
90
+
91
+ declare module '@deepseek-ai/dsh-typert-protocol' {
92
+ interface TypertLookupMap {
93
+ session: TypertLookup<Session, SessionId>
94
+ }
95
+ }
96
+
97
+ /** Validate and freeze one detached creation header in place. */
98
+ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
99
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
100
+ throw new Error('session header is not a plain JSON record')
101
+ }
102
+ const record = input as Record<string, unknown>
103
+ if (Object.hasOwn(record, 'seedLength')) {
104
+ throw new Error('session header has invalid field "seedLength"')
105
+ }
106
+ if (record.version !== SESSION_FORMAT_VERSION) {
107
+ throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
108
+ }
109
+ if (record.id !== id) {
110
+ throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
111
+ }
112
+ if (typeof record.createdAt !== 'number'
113
+ || !Number.isSafeInteger(record.createdAt)
114
+ || record.createdAt < 0) {
115
+ throw new Error('session header createdAt must be a non-negative safe integer')
116
+ }
117
+ if (record.cwd !== undefined) {
118
+ if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
119
+ if (!isAbsolute(record.cwd)) {
120
+ throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
121
+ }
122
+ }
123
+ if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
124
+ throw new Error('session header parentSession must be a string')
125
+ }
126
+ if (typeof record.isSeeded !== 'boolean') {
127
+ throw new Error('session header isSeeded must be a boolean')
128
+ }
129
+ if (record.origin !== undefined && record.origin !== 'subagent') {
130
+ throw new Error('session header origin must be "subagent"')
131
+ }
132
+ if (record.delegationDepth !== undefined
133
+ && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
134
+ throw new Error('session header delegationDepth must be a non-negative safe integer')
135
+ }
136
+ if (record.agentPreset !== undefined && typeof record.agentPreset !== 'string') {
137
+ throw new Error('session header agentPreset must be a string')
138
+ }
139
+ return deepFreeze(record as unknown as SessionHeader)
140
+ }
141
+
142
+ /** Validate and freeze one exclusively owned persistence header in place. */
143
+ function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHeader {
144
+ if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
145
+ const prototype = Reflect.getPrototypeOf(input)
146
+ if (prototype !== Object.prototype && prototype !== null) {
147
+ throw new Error('session header is not a plain JSON record')
148
+ }
149
+ }
150
+ return validateSessionHeader(id, input)
151
+ }
152
+
153
+ /** Detach, validate, and freeze the creation metadata published by a session. */
154
+ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
155
+ const input: unknown = source === undefined
156
+ ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now(), isSeeded: false }
157
+ : source
158
+ const snapshot = snapshotJsonValue(input)
159
+ if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
160
+ return validateSessionHeader(id, snapshot)
161
+ }
162
+
163
+ /**
164
+ * Validate an exclusively owned event and deeply freeze its identified message
165
+ * without copying the event. The caller transfers an object graph that no
166
+ * producer retains and that shares no mutable children with another event.
167
+ * Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
168
+ * @param event - exclusively owned event imported across a trusted boundary.
169
+ * @returns the same event object with a validated, deeply frozen message.
170
+ * @throws when event-local surface metadata, request-header fields, or message invariants are invalid; history relations are not checked.
171
+ */
172
+ export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
173
+ validateSessionEventData(event, `session event at seq ${event.seq}`)
174
+ validateSurfaceMetadata(event)
175
+ assertMessageEventShape(
176
+ event,
177
+ `session event at seq ${event.seq}`,
178
+ )
179
+ switch (event.type) {
180
+ case 'user/message':
181
+ deepFreeze(event.data)
182
+ break
183
+ case 'developer/message':
184
+ case 'system/message':
185
+ case 'assistant/message':
186
+ case 'tool/result':
187
+ deepFreeze(event.data.message)
188
+ break
189
+ default:
190
+ // SessionEventMap is merge-extensible; plugin-owned events carry no core message.
191
+ break
192
+ }
193
+ return event
194
+ }
195
+
196
+ /**
197
+ * Detach one event while preserving deep immutability for its identified message.
198
+ * @param event - event imported across a query or persistence boundary.
199
+ * @returns a detached event snapshot with a validated, deeply frozen message.
200
+ */
201
+ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
202
+ return adoptSessionEvent(structuredClone(event))
203
+ }
204
+
205
+ /** Validate the fixed event envelope after one-pass JSON materialization. */
206
+ function assertSessionEventEnvelope(value: unknown, index: number): asserts value is SessionEvent {
207
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
208
+ throw new Error(`seed event at index ${index} has an invalid event envelope`)
209
+ }
210
+ const event = value as Record<string, unknown>
211
+ for (const key in event) {
212
+ switch (key) {
213
+ case 'type':
214
+ case 'seq':
215
+ case 'time':
216
+ case 'data':
217
+ case 'surfaceOp':
218
+ case 'sourceEventSeqs':
219
+ case 'ignorable':
220
+ break
221
+ default:
222
+ throw new Error(`seed event at index ${index} has an invalid event envelope`)
223
+ }
224
+ }
225
+ const type = event['type']
226
+ const seq = event['seq']
227
+ const time = event['time']
228
+ if (typeof type !== 'string'
229
+ || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || Object.is(seq, -0)
230
+ || typeof time !== 'number' || !Number.isSafeInteger(time)
231
+ || event['data'] === undefined
232
+ || (event['ignorable'] !== undefined && event['ignorable'] !== true)) {
233
+ throw new Error(`seed event at index ${index} has an invalid event envelope`)
234
+ }
235
+ validateSessionEventData(event as SessionEvent, `seed ${type} at index ${index}`)
236
+ switch (type) {
237
+ case 'request/header':
238
+ case 'developer/message':
239
+ case 'system/message':
240
+ case 'user/message':
241
+ case 'assistant/attempt':
242
+ case 'assistant/message':
243
+ case 'tool/result':
244
+ assertCurrentLlmShape(event, index)
245
+ break
246
+ }
247
+ }
248
+
249
+ /** Reject obsolete request headers and malformed messages at the seed/load boundary. */
250
+ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): void {
251
+ const data = event['data']
252
+ const record = typeof data === 'object' && data !== null
253
+ ? data as Record<string, unknown>
254
+ : undefined
255
+ if (event['type'] === 'request/header') {
256
+ const headerRecord = record?.['header'] as Record<string, unknown>
257
+ const config = headerRecord['config']
258
+ if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
259
+ const configRecord = config as Record<string, unknown>
260
+ const reasoningEffort = configRecord['reasoningEffort']
261
+ if (reasoningEffort !== undefined
262
+ && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
263
+ throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
264
+ }
265
+ assertAdapterDefaults(headerRecord['adapterDefaults'], configRecord, index)
266
+ const reason = record?.['reason']
267
+ if (reason !== 'initial' && reason !== 'resume' && reason !== 'change' && reason !== 'series') {
268
+ throw new Error(`seed request/header at index ${index} has an invalid reason`)
269
+ }
270
+ if (record?.['startsSeries'] !== undefined && record['startsSeries'] !== true) {
271
+ throw new Error(`seed request/header at index ${index} has an invalid startsSeries marker`)
272
+ }
273
+ }
274
+ const type = event['type']
275
+ if (type === 'assistant/attempt') {
276
+ assertAssistantSettlementShape(record, type, index)
277
+ return
278
+ }
279
+ if (!isMessageEventType(type)) return
280
+ assertMessageEventShape(event, `seed ${type} at index ${index}`)
281
+ if (type === 'assistant/message') {
282
+ assertAssistantSettlementShape(record, type, index)
283
+ }
284
+ }
285
+
286
+ /** Validate fields used directly by restored Session lifecycle logic without replaying the embedded stream. */
287
+ function assertAssistantSettlementShape(
288
+ data: Record<string, unknown> | undefined,
289
+ type: 'assistant/attempt' | 'assistant/message',
290
+ index: number,
291
+ ): void {
292
+ const turn = data?.['turn']
293
+ const step = data?.['step']
294
+ if (typeof turn !== 'number' || !Number.isSafeInteger(turn) || turn < 0 || Object.is(turn, -0)
295
+ || typeof step !== 'number' || !Number.isSafeInteger(step) || step < 0 || Object.is(step, -0)
296
+ || !Array.isArray(data?.['stream'])) {
297
+ throw new Error(`seed ${type} at index ${index} has invalid settlement fields`)
298
+ }
299
+ }
300
+
301
+ const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens'])
302
+
303
+ /** Validate adapter-default markers imported from a durable request header. */
304
+ function assertAdapterDefaults(
305
+ value: unknown,
306
+ config: Record<string, unknown>,
307
+ index: number,
308
+ ): void {
309
+ if (value === undefined) return
310
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
311
+ throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
312
+ }
313
+ const defaults = value as Record<string, unknown>
314
+ if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key))
315
+ || Object.values(defaults).some(marker => marker !== true)
316
+ || defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
317
+ || defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
318
+ throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
319
+ }
320
+ }
321
+
322
+ /** The surface event types whose payload carries an identified message. */
323
+ function isMessageEventType(type: unknown): type is SurfaceEventType {
324
+ return type === 'developer/message' || type === 'system/message' || type === 'user/message'
325
+ || type === 'assistant/message' || type === 'tool/result'
326
+ }
327
+
328
+ const MESSAGE_ROLE_BY_TYPE: Record<SurfaceEventType, Message['role']> = {
329
+ 'system/message': 'system',
330
+ 'developer/message': 'developer',
331
+ 'user/message': 'user',
332
+ 'assistant/message': 'assistant',
333
+ 'tool/result': 'tool',
334
+ }
335
+
336
+ /** Validate only the event-specific invariants needed to safely replay a message. */
337
+ function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
338
+ const type = event['type']
339
+ if (!isMessageEventType(type)) return
340
+ const data = event['data']
341
+ const record = typeof data === 'object' && data !== null
342
+ ? data as Record<string, unknown>
343
+ : undefined
344
+ const message = type === 'user/message' ? record : record?.['message']
345
+ if (typeof message !== 'object' || message === null
346
+ || typeof (message as Record<string, unknown>)['id'] !== 'string'
347
+ || (message as Record<string, unknown>)['id'] === '') {
348
+ throw new Error(`${subject} lacks an identified message`)
349
+ }
350
+ const messageRecord = message as Record<string, unknown>
351
+ const expectedRole = MESSAGE_ROLE_BY_TYPE[type]
352
+ if (messageRecord['role'] !== expectedRole) {
353
+ throw new Error(`${subject} message must have role "${expectedRole}"`)
354
+ }
355
+ const source = messageRecord['source']
356
+ if (typeof source !== 'object' || source === null
357
+ || typeof (source as Record<string, unknown>)['kind'] !== 'string'
358
+ || (source as Record<string, unknown>)['kind'] === '') {
359
+ throw new Error(`${subject} message has invalid source`)
360
+ }
361
+ if (!Array.isArray(messageRecord['content'])) {
362
+ throw new Error(`${subject} message has invalid content`)
363
+ }
364
+ const sourceRecord = source as Record<string, unknown>
365
+ if (type === 'system/message') {
366
+ if (sourceRecord['kind'] !== 'system-prompt') {
367
+ throw new Error(`${subject} message must have system-prompt source`)
368
+ }
369
+ return
370
+ }
371
+ if (type === 'assistant/message') {
372
+ if (sourceRecord['kind'] !== 'model' || !hasProviderModel(sourceRecord)) {
373
+ throw new Error(`${subject} message must have model source`)
374
+ }
375
+ return
376
+ }
377
+ if (type !== 'tool/result') return
378
+ if (sourceRecord['kind'] !== 'tool'
379
+ || typeof sourceRecord['callId'] !== 'string'
380
+ || sourceRecord['callId'] === '') {
381
+ throw new Error(`${subject} message must have tool source`)
382
+ }
383
+ if (messageRecord['toolCallId'] !== sourceRecord['callId']) {
384
+ throw new Error(`${subject} message has mismatched tool call ids`)
385
+ }
386
+ }
387
+
388
+ /** Whether an unknown value carries the current provider/model pair. */
389
+ function hasProviderModel(value: unknown): boolean {
390
+ if (typeof value !== 'object' || value === null) return false
391
+ const pair = value as Record<string, unknown>
392
+ return typeof pair['provider'] === 'string' && pair['provider'].length > 0
393
+ && typeof pair['model'] === 'string' && pair['model'].length > 0
394
+ }
395
+
396
+ type SessionCallback = (...args: unknown[]) => unknown
397
+
398
+ /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
399
+ function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
400
+ return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
401
+ }
402
+
403
+ /** Invoke one resolved observe-only listener snapshot with per-listener containment. */
404
+ function invokeContainedSessionObservers(
405
+ ctx: Context,
406
+ name: 'session/event' | 'session/disposed',
407
+ id: SessionId,
408
+ args: unknown[],
409
+ callbacks: SessionCallback[],
410
+ ): void {
411
+ for (const callback of callbacks) {
412
+ try {
413
+ const returned: unknown = callback(...args)
414
+ void Promise.resolve(returned).catch((error: unknown) => {
415
+ ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
416
+ })
417
+ } catch (error: unknown) {
418
+ ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`)
419
+ }
420
+ }
421
+ }
422
+
423
+ /** All mutable lifecycle state for one exact store entry. */
424
+ interface SessionEntry {
425
+ readonly id: SessionId
426
+ readonly session: Session
427
+ readonly carrier: Scoped<Session>
428
+ readonly emitCtx: Context
429
+ announcement: 'pending' | 'dispatching' | 'complete' | 'failed'
430
+ appending: boolean
431
+ detachRequested: boolean
432
+ detach(): void
433
+ }
434
+
435
+ /** Store-owned Sessions retain a null entry while unentered; absent keys are independent. */
436
+ const attachments = new WeakMap<Session, SessionEntry | null>()
437
+
438
+ /**
439
+ * Grammar of a {@link PluginRecordType}: `plugin:` followed by
440
+ * slash-separated segments of lowercase letters, digits, `.`, `_`, and `-`,
441
+ * each segment starting with a letter or digit.
442
+ */
443
+ const PLUGIN_RECORD_TYPE = /^plugin:[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)*$/u
444
+
445
+ /**
446
+ * Whether one event type name follows the plugin record grammar.
447
+ * @param type - an event type name.
448
+ * @returns whether the name is a {@link PluginRecordType}.
449
+ */
450
+ function isPluginRecordType(type: string): type is PluginRecordType {
451
+ return PLUGIN_RECORD_TYPE.test(type)
452
+ }
453
+
454
+ /**
455
+ * Type one constructed plugin record as a log event after checking its type
456
+ * against the plugin record grammar. A restored Session already holds events
457
+ * whose types are outside `SessionEventMap` when they are marked ignorable;
458
+ * a plugin record is such an event.
459
+ * @param value - the frozen record envelope built by {@link Session}.
460
+ * @throws when the record type is outside the plugin record grammar.
461
+ */
462
+ function assertPluginRecordEvent(value: unknown): asserts value is SessionEvent {
463
+ const { type } = value as { readonly type: string }
464
+ if (!isPluginRecordType(type)) {
465
+ throw new Error(`plugin record type "${type}" must be "plugin:" followed by lowercase slash-separated segments`)
466
+ }
467
+ }
468
+
469
+ /** Module-private access to {@link Session}'s record commit, assigned once by its static block. */
470
+ let commitPluginRecord: (session: Session, type: PluginRecordType, data: unknown) => SessionSeq
471
+
472
+ /**
473
+ * Append one experimental plugin record: an event marked `ignorable` whose
474
+ * type is declared in {@link PluginRecordMap}, outside {@link SessionEventMap}.
475
+ * The plugin record catalog lists it separately from released event schemas.
476
+ * A reader that does not recognize the type retains and skips the record, which never
477
+ * enters the model-visible surface, and fork and resume carry it with the rest
478
+ * of the log. A Session format migration retains records on a best-effort
479
+ * basis, so a record holds plugin-owned state that its owner can lose without
480
+ * changing how the rest of the log is interpreted.
481
+ *
482
+ * Only production source under `packages/experimental/` may call this
483
+ * function; the `verify-plugin-record-callers` gate rejects any other
484
+ * production caller in this repository. Read records back with
485
+ * {@link pluginRecordOf}.
486
+ * @param session - the Session whose log receives the record.
487
+ * @param type - declared record name: `plugin:` followed by slash-separated
488
+ * segments of lowercase letters, digits, `.`, `_`, and `-`, each starting
489
+ * with a letter or digit.
490
+ * @param data - JSON payload, snapshotted before it enters the log.
491
+ * @returns the sequence number of the committed record.
492
+ * @throws when a store-owned Session is unentered or its creation announcement
493
+ * has not completed, when `type` is outside
494
+ * that grammar, when `data` is not losslessly
495
+ * JSON-serializable, or when the call reenters another append's publication;
496
+ * a rejected record does not change the log.
497
+ */
498
+ export function appendPluginRecord<K extends Extract<keyof PluginRecordMap, PluginRecordType>>(
499
+ session: Session,
500
+ type: K,
501
+ data: NoInfer<PluginRecordMap[K]>,
502
+ ): SessionSeq {
503
+ return commitPluginRecord(session, type, data)
504
+ }
505
+
506
+ /**
507
+ * Read one committed event as a plugin record. The V3-to-V4 format edge also
508
+ * renames each unknown ignorable V3 event into the `plugin:` namespace, so an
509
+ * owner names its records under its own package name and validates `data`.
510
+ * @param event - any committed Session event, live or restored.
511
+ * @returns the record, or undefined when the event is not an ignorable event
512
+ * whose type follows the plugin record grammar.
513
+ */
514
+ export function pluginRecordOf(event: SessionEvent): PluginRecord | undefined {
515
+ const type: string = event.type
516
+ if (event.ignorable !== true || !isPluginRecordType(type)) return undefined
517
+ return { type, seq: event.seq, time: event.time, data: event.data }
518
+ }
519
+
520
+ /** Copy live append inputs before sequence assignment. */
521
+ function snapshotAppend(
522
+ type: SessionEventType,
523
+ data: SessionEventMap[SessionEventType] | undefined,
524
+ opts: SurfaceIntent | undefined,
525
+ subject: string,
526
+ ) {
527
+ const surfaceOp = opts?.surfaceOp
528
+ const sourceEventSeqs = opts?.sourceEventSeqs
529
+ const dataSnapshot = snapshotJsonValue(data)
530
+ if (dataSnapshot === undefined) throw new Error(`${subject} carries non-JSON-serializable data`)
531
+ const surface = snapshotJsonValue({
532
+ ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
533
+ ...surfaceOp === undefined ? {} : { surfaceOp },
534
+ })
535
+ if (surface === undefined) throw new Error(`${subject} carries non-JSON-serializable surface metadata`)
536
+ return { type, data: dataSnapshot, surface }
537
+ }
538
+
539
+ /** Construct and validate one live event from detached inputs. */
540
+ function createAppendEvent(snapshot: ReturnType<typeof snapshotAppend>, seq: SessionSeq): SessionEvent {
541
+ const { type, data, surface } = snapshot
542
+ const event = deepFreeze({ type, seq, time: Date.now(), data, ...surface } as SessionEvent)
543
+ validateSessionEventData(event, `session event "${type}" at seq ${seq}`)
544
+ return event
545
+ }
546
+
547
+ /**
548
+ * An event-sourced session: an append-only log of {@link SessionEvent}s.
549
+ *
550
+ * Plain class (not a Service) — create live instances via
551
+ * `ctx.sessions.create()` and detached instances via {@link create}.
552
+ * Seeding with an existing event log replays/forks a session.
553
+ * @typert object
554
+ */
555
+ export class Session {
556
+ private log: SessionEvent[] = []
557
+ private batchPublication: SessionEvent | undefined
558
+ /** Single incremental owner of surface acceptance and projection state. */
559
+ private readonly surfaceManager: SurfaceManager
560
+
561
+ /** The ordered surface over this session's event log. */
562
+ get surface(): SessionSurface {
563
+ return this.surfaceManager
564
+ }
565
+
566
+ /**
567
+ * Detached, deep-frozen creation metadata (format version, cwd, lineage,
568
+ * and whether fork history exists). Supplied by the store via `ctx.sessions.create()`. When a
569
+ * `Session` is created without a store-owned header, a minimal header is
570
+ * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
571
+ * `session.header` is always present. Kept out of the event log — it is a
572
+ * storage concern, not replayable conversation state.
573
+ */
574
+ readonly header: SessionHeader
575
+
576
+ /** Number of leading events inherited from this Session's fork parent. */
577
+ readonly inheritedEventCount: SessionLogOffset
578
+
579
+ /** The session identity, derived from its durable header's single copy. */
580
+ get id(): SessionId {
581
+ return this.header.id
582
+ }
583
+
584
+ /**
585
+ * The constructor seed length (0 without one), before any marker appended
586
+ * during construction. Seed events never publish on `session/event`. A
587
+ * marker appended before the store attaches occupies this seq without
588
+ * publishing either; otherwise this seq is available for the next append.
589
+ *
590
+ * This in-process offset is not persisted. A fork seed can already contain
591
+ * the child's inherited marker and synthetic closers, so its child-owned
592
+ * history starts at {@link inheritedEventCount}, before this offset. A
593
+ * resumed Session's seed contains its full stored log, while its inherited
594
+ * count keeps the durable fork cut. Consumers needing complete canonical
595
+ * history start at seq 0.
596
+ */
597
+ readonly firstLiveSeq: SessionLogOffset
598
+
599
+ /**
600
+ * First event produced for this object lifecycle. A new fork includes its
601
+ * child-owned seed marker and closers; a restored Session starts after its
602
+ * complete stored prefix. This in-process capture offset is not persisted.
603
+ */
604
+ readonly firstLifecycleSeq: SessionLogOffset
605
+
606
+ /**
607
+ * Create a detached session by validating and snapshotting borrowed seed
608
+ * events and storage metadata.
609
+ * @param id - session identity.
610
+ * @param seed - optional borrowed replay or fork events.
611
+ * @param header - optional borrowed storage metadata.
612
+ * @param inheritedEventCount - exact fork-inherited prefix length for a seeded header.
613
+ * @param projections - pure interpreters for plugin-owned message changes.
614
+ * @returns a detached session.
615
+ * @throws when a seed event requires a missing message interpreter or fails validation.
616
+ */
617
+ static create(
618
+ id: SessionId,
619
+ seed?: readonly SessionEvent[],
620
+ header?: SessionHeader,
621
+ inheritedEventCount?: SessionLogOffset,
622
+ projections?: readonly SessionMessageProjection[],
623
+ ): Session {
624
+ return new Session(id, seed, header, 'snapshot', inheritedEventCount, projections)
625
+ }
626
+
627
+ /**
628
+ * Restore a detached session by adopting an independently owned or deeply frozen seed.
629
+ * Runtime-required event fields, event envelopes, sequence continuity, surface
630
+ * transitions, and header fields are validated without copying or freezing events.
631
+ * Embedded Assistant streams remain opaque until a stream consumer or storage
632
+ * verifier reads them.
633
+ * @param id - restored session identity.
634
+ * @param seed - independently owned or deeply frozen events.
635
+ * @param header - independently owned storage metadata.
636
+ * @param inheritedEventCount - exact fork-inherited prefix length decoded from storage.
637
+ * @param eventState - aliasing state carried from the operation that produced the seed.
638
+ * @param projections - pure interpreters for plugin-owned message changes.
639
+ * @returns a restored detached session.
640
+ * @throws when a seed event requires a missing message interpreter or fails validation.
641
+ */
642
+ static fromRestore(
643
+ id: SessionId,
644
+ seed: readonly SessionEvent[],
645
+ header: SessionHeader,
646
+ inheritedEventCount: SessionLogOffset,
647
+ eventState: SessionSeedEventState,
648
+ projections?: readonly SessionMessageProjection[],
649
+ ): Session {
650
+ return new Session(
651
+ id,
652
+ seed,
653
+ header,
654
+ eventState,
655
+ inheritedEventCount,
656
+ projections,
657
+ )
658
+ }
659
+
660
+ private constructor(
661
+ id: SessionId,
662
+ seed?: readonly SessionEvent[],
663
+ header?: SessionHeader,
664
+ mode: 'snapshot' | SessionSeedEventState = 'snapshot',
665
+ suppliedInheritedEventCount?: SessionLogOffset,
666
+ projections: readonly SessionMessageProjection[] = [],
667
+ ) {
668
+ this.surfaceManager = new SurfaceManager(this.log, SessionLogOffset(0), projections)
669
+ const restoredHeader = mode === 'snapshot' ? undefined : validateRestoredSessionHeader(id, header)
670
+ if (seed !== undefined) {
671
+ // Validate the seed to the SAME invariants `append` enforces, so a
672
+ // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
673
+ // live log that no persistence backend could store: each event's `data`
674
+ // must be JSON-serializable, and `seq` must be contiguous from 0 (the
675
+ // `seq = log.length` contract the whole system relies on). Without this,
676
+ // a bad seed would surface only later as a backend rejection or a silent
677
+ // divergence between the live log and disk.
678
+ for (const [index, source] of seed.entries()) {
679
+ // The seed is a persistence/replay boundary: validate and detach the
680
+ // complete event in one lossless-JSON pass.
681
+ const snapshot = mode === 'snapshot' ? snapshotJsonValue(source) : source
682
+ if (snapshot === undefined) {
683
+ throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
684
+ }
685
+ assertSessionEventEnvelope(snapshot, index)
686
+ if (snapshot.seq !== index) {
687
+ throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
688
+ }
689
+ // A seed is accepted incrementally through the same transition as a
690
+ // live append and a full-log fold. The candidate is planned before it
691
+ // enters `log`, so a failure cannot partially mutate the surface.
692
+ try {
693
+ this.surfaceManager.validateNext(snapshot)
694
+ } catch (error: unknown) {
695
+ throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
696
+ }
697
+ this.log.push(mode === 'snapshot' ? deepFreeze(snapshot) : snapshot)
698
+ }
699
+ }
700
+ this.firstLiveSeq = SessionLogOffset(this.log.length)
701
+ this.header = restoredHeader ?? snapshotSessionHeader(id, header)
702
+ if (this.header.isSeeded && seed === undefined) {
703
+ throw new Error('seeded session requires an explicit constructor seed')
704
+ }
705
+ if (this.header.isSeeded && suppliedInheritedEventCount === undefined) {
706
+ throw new Error('seeded session requires an inherited event count')
707
+ }
708
+ const inheritedEventCount = SessionLogOffset(suppliedInheritedEventCount ?? 0)
709
+ if (!this.header.isSeeded && inheritedEventCount !== 0) {
710
+ throw new Error('unseeded session inherited event count must be 0')
711
+ }
712
+ if (inheritedEventCount > this.log.length) {
713
+ throw new Error('session inherited event count exceeds its event log')
714
+ }
715
+ const seedMarker = this.log[inheritedEventCount]
716
+ const markedSeed = seedMarker?.type === 'session/end-seed' && seedMarker.data.inherited === true
717
+ if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length && !markedSeed) {
718
+ throw new Error('seeded session constructor seed must equal its inherited prefix or mark its inherited cut')
719
+ }
720
+ if (markedSeed && this.log.slice(inheritedEventCount + 1).some(event => event.type === 'session/end-seed' && event.data.inherited === true)) {
721
+ throw new Error('session inherited event count must identify the final inherited marker')
722
+ }
723
+ this.inheritedEventCount = inheritedEventCount
724
+ this.firstLifecycleSeq = mode === 'snapshot' && this.header.isSeeded ? inheritedEventCount : this.firstLiveSeq
725
+ // A fresh seeded child always owns one tagged marker at its inherited cut,
726
+ // even when the copied prefix already ends in an ancestor marker. Restore
727
+ // retains that durable marker and appends only the ordinary resume marker.
728
+ if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded && !markedSeed) {
729
+ this.append('session/end-seed', { inherited: true })
730
+ } else if (seed !== undefined && !(mode === 'snapshot' && this.header.isSeeded) && this.log.at(-1)?.type !== 'session/end-seed') {
731
+ this.append('session/end-seed', {})
732
+ }
733
+ }
734
+
735
+ /** Cached immutable full snapshot of the private append-only log. */
736
+ private eventsSnapshot: readonly SessionEvent[] | undefined
737
+
738
+ /**
739
+ * Return the immutable event stored at one exact sequence number.
740
+ * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
741
+ * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
742
+ * @param seq - event sequence number.
743
+ * @returns the accepted event, or undefined when the log does not contain it.
744
+ */
745
+ eventAt(seq: SessionSeq): SessionEvent | undefined {
746
+ return this.log[seq]
747
+ }
748
+
749
+ /**
750
+ * Materialize an immutable snapshot of a half-open event sequence range.
751
+ * A full current snapshot is reused until the next append; every previously
752
+ * returned snapshot remains stable after later appends.
753
+ * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
754
+ * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
755
+ * @param fromSeq - non-negative inclusive sequence number; defaults to the log start.
756
+ * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end.
757
+ * @returns a frozen array of the selected deeply frozen events.
758
+ */
759
+ snapshotEvents(
760
+ fromSeq: SessionLogOffset = SessionLogOffset(0),
761
+ toSeqExclusive: SessionLogOffset = this.seq,
762
+ ): readonly SessionEvent[] {
763
+ if (fromSeq === 0 && toSeqExclusive === this.log.length) {
764
+ this.eventsSnapshot ??= Object.freeze([...this.log])
765
+ return this.eventsSnapshot
766
+ }
767
+ return Object.freeze(this.log.slice(fromSeq, toSeqExclusive))
768
+ }
769
+
770
+ /**
771
+ * Return this Session's events after its fork-inherited prefix.
772
+ * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited.
773
+ * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).
774
+ * @returns a fresh array containing child-owned events in log order.
775
+ */
776
+ ownEvents(): readonly SessionEvent[] {
777
+ // oxlint-disable-next-line typescript/no-deprecated -- Deprecated reader delegates to the deprecated range read.
778
+ return this.snapshotEvents(this.inheritedEventCount)
779
+ }
780
+
781
+ /**
782
+ * Whether one existing event position is outside the fork-inherited prefix.
783
+ * @param seq - event position in this Session.
784
+ * @returns true when the event belongs to this Session rather than its parent.
785
+ */
786
+ isOwnSeq(seq: SessionSeq): boolean {
787
+ return seq >= this.inheritedEventCount && seq < this.seq
788
+ }
789
+
790
+ /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
791
+ get seq(): SessionLogOffset {
792
+ return SessionLogOffset(this.log.length)
793
+ }
794
+
795
+ /** Current event while synchronously notifying a batch's observers. */
796
+ get currentBatchPublication(): SessionEvent | undefined {
797
+ return this.batchPublication
798
+ }
799
+
800
+ /**
801
+ * Append consecutive events together after validating every candidate and dispatch.
802
+ * Observers read the complete committed log and final message surface.
803
+ * Listener registration changes during publication take effect on the next append.
804
+ * Persistence listeners retain their own durability rules.
805
+ * @param inputs - event payloads and surface intents in commit order. An empty array records no events.
806
+ * @returns immutable committed events in the same order.
807
+ * @throws if the Session cannot accept writes, including an empty append, or any candidate or synchronous dispatch validation fails.
808
+ */
809
+ append(inputs: readonly SessionEventInput[]): readonly SessionEvent[]
810
+
811
+ /**
812
+ * Append one typed event to the log and synchronously notify observers via
813
+ * the store-owned, module-private publication hooks. The hot path never blocks
814
+ * on I/O — persistence plugins buffer asynchronously. Once the event enters
815
+ * the log, the append is committed: observer failures are logged and
816
+ * contained per listener, so they do not change the return value or prevent
817
+ * later listeners from observing the same accepted event.
818
+ * Independent Sessions append without notifying store observers. Once a store
819
+ * prepares or enters the instance, writes require a completed creation
820
+ * announcement and remain disabled after removal.
821
+ *
822
+ * @param type - The event type (key of {@link SessionEventMap}).
823
+ * @param data - The event payload; must be JSON-serializable.
824
+ * @param opts - Surface metadata: `surfaceOp` controls how the event enters
825
+ * the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier
826
+ * events this one derives from. REQUIRED for
827
+ * {@link SurfaceEventType} events (every message-producing event must
828
+ * declare how it joins the surface, the sole source of derived model
829
+ * history) and
830
+ * rejected by the compiler for non-surface types like `turn/start` or
831
+ * `assistant/attempt`. Assistant messages embed their exact provider
832
+ * stream and cannot cite top-level source events.
833
+ * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
834
+ * `data` that entered the log, so reading `event.data` back sees the logged
835
+ * value, never the caller's still-mutable input.
836
+ * @throws if a store-owned Session is unentered or its creation announcement
837
+ * has not completed, or if `data` or surface
838
+ * metadata is not losslessly JSON-serializable
839
+ * (BigInt, function, symbol, undefined, negative zero, non-finite number,
840
+ * circular reference, sparse array, or an exotic object such as
841
+ * Map/Set/Date/class instance), or when the candidate violates the
842
+ * request-header empty-field or tool-error consistency rules, or the
843
+ * canonical surface contract (marker shape and eligibility, unique
844
+ * earlier source-event references, positional replacement validity, and complete
845
+ * shadowed-node coverage). One iterative pass reads, validates, and
846
+ * copies each nested value once, so a stateful getter cannot supply one value
847
+ * to validation and another to storage. The event log is the durable source
848
+ * of truth, so a bad event fails at the append site rather than later during
849
+ * a backend flush. A synchronous internal dispatch validation failure or an
850
+ * append reentered while this acceptance/publication boundary is open also
851
+ * rejects before the log changes.
852
+ */
853
+ append<T extends SessionEventType>(
854
+ type: T,
855
+ data: SessionEventMap[T],
856
+ ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : []
857
+ ): SessionEvent<T>
858
+ append(
859
+ type: SessionEventType | readonly SessionEventInput[],
860
+ data?: SessionEventMap[SessionEventType],
861
+ ...opts: [opts?: SurfaceIntent]
862
+ ): SessionEvent | readonly SessionEvent[] {
863
+ if (typeof type !== 'string') return this.#appendEvents(type)
864
+ this.publicationEntry()
865
+ const snapshot = snapshotAppend(type, data, opts[0], `session event "${type}"`)
866
+ // Payload getters can detach the Session while the snapshots are read.
867
+ const entry = this.publicationEntry()
868
+ const event = createAppendEvent(snapshot, SessionSeq(this.log.length))
869
+ this.commit(event, entry)
870
+ return event
871
+ }
872
+
873
+ /**
874
+ * Validate and publish consecutive events together. Observers see the complete log.
875
+ * @param inputs - event payloads and surface intents in commit order.
876
+ * @returns immutable committed events in the same order.
877
+ * @throws before changing the log if any candidate or synchronous dispatch validation fails.
878
+ */
879
+ #appendEvents(inputs: readonly SessionEventInput[]): readonly SessionEvent[] {
880
+ this.publicationEntry()
881
+ if (inputs.length === 0) return []
882
+ const snapshots = Array.from({ length: inputs.length }, (_, index) => {
883
+ const input = inputs.at(index)
884
+ if (input === undefined) throw new Error(`session batch has an empty member at index ${index}`)
885
+ const [type, data, surfaceOpts] = input
886
+ return snapshotAppend(type, data, surfaceOpts, `session batch member ${index} ("${type}")`)
887
+ })
888
+ // Payload getters may append to or detach the Session.
889
+ const entry = this.publicationEntry()
890
+ const startSeq = this.log.length
891
+ const events = snapshots.map((snapshot, index) => createAppendEvent(snapshot, SessionSeq(startSeq + index)))
892
+ const acceptSurface = this.surfaceManager.prepareBatch(events)
893
+ if (entry !== undefined) entry.appending = true
894
+ try {
895
+ const publications = entry === undefined ? [] : events.map((event) => {
896
+ const args: unknown[] = [this, event]
897
+ const callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...args])
898
+ return { event, args, callbacks }
899
+ })
900
+ for (const event of events) this.log.push(event)
901
+ this.eventsSnapshot = undefined
902
+ acceptSurface()
903
+ if (entry !== undefined) {
904
+ for (const { event, args, callbacks } of publications) {
905
+ this.batchPublication = event
906
+ invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, args, callbacks)
907
+ }
908
+ }
909
+ } finally {
910
+ this.batchPublication = undefined
911
+ if (entry !== undefined) {
912
+ entry.appending = false
913
+ if (entry.detachRequested) entry.detach()
914
+ }
915
+ }
916
+ return events
917
+ }
918
+
919
+ static {
920
+ commitPluginRecord = (session, type, data) => session.#appendRecord(type, data)
921
+ }
922
+
923
+ /**
924
+ * Commit one plugin record; {@link appendPluginRecord} is the only caller.
925
+ * @param type - record type, checked against the plugin record grammar.
926
+ * @param data - JSON payload, snapshotted before it enters the log.
927
+ * @returns the record's sequence number.
928
+ */
929
+ #appendRecord(type: PluginRecordType, data: unknown): SessionSeq {
930
+ this.publicationEntry()
931
+ const dataSnapshot = snapshotJsonValue(data)
932
+ if (dataSnapshot === undefined) {
933
+ throw new Error(`plugin record "${type}" carries non-JSON-serializable data`)
934
+ }
935
+ const entry = this.publicationEntry()
936
+ const record: unknown = deepFreeze({
937
+ type,
938
+ seq: SessionSeq(this.log.length),
939
+ time: Date.now(),
940
+ data: dataSnapshot,
941
+ ignorable: true,
942
+ })
943
+ assertPluginRecordEvent(record)
944
+ this.commit(record, entry)
945
+ return record.seq
946
+ }
947
+
948
+ /**
949
+ * Require announced membership for store-owned Sessions and refuse reentrant publication.
950
+ * @returns the current store attachment, or undefined for an independent Session.
951
+ */
952
+ private publicationEntry(): SessionEntry | undefined {
953
+ const entry = attachments.get(this)
954
+ if (entry === null) {
955
+ throw new Error(`session "${this.id}" cannot append outside a SessionStore`)
956
+ }
957
+ if (entry !== undefined && entry.announcement !== 'complete') {
958
+ throw new Error(`session "${this.id}" cannot append before session/created completes`)
959
+ }
960
+ if (entry?.appending) {
961
+ throw new Error('session append cannot reenter while another append is being published')
962
+ }
963
+ return entry
964
+ }
965
+
966
+ /**
967
+ * Accept one frozen candidate at the next sequence number, enter it into the
968
+ * log, and notify `session/event` observers with per-listener containment.
969
+ * @param event - the candidate built at the current log length.
970
+ * @param entry - the attachment read before the candidate was built.
971
+ */
972
+ private commit(event: SessionEvent, entry: SessionEntry | undefined): void {
973
+ this.surfaceManager.validateNext(event)
974
+
975
+ if (entry !== undefined) entry.appending = true
976
+ try {
977
+ const callbackArgs: unknown[] = [this, event]
978
+ const callbacks = entry === undefined
979
+ ? undefined
980
+ : collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
981
+ this.log.push(event)
982
+ this.eventsSnapshot = undefined
983
+ if (entry !== undefined && callbacks !== undefined) {
984
+ invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
985
+ }
986
+ } finally {
987
+ if (entry !== undefined) {
988
+ entry.appending = false
989
+ if (entry.detachRequested && entry.announcement !== 'dispatching') entry.detach()
990
+ }
991
+ }
992
+ }
993
+
994
+ /** Cached fold of the request-header events — see {@link requestHeader}. */
995
+ private headerFold: EpochHeader | undefined
996
+ /** Log position (events consumed) the header fold has reached. */
997
+ private headerFoldSeq = 0
998
+
999
+ /**
1000
+ * The {@link EpochHeader} in force after the log's last header event — the
1001
+ * header the NEXT request will be compared against — or undefined before
1002
+ * the first `request/header` snapshot. The live, incrementally-maintained
1003
+ * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded
1004
+ * once, when first seen, so a per-step read costs O(new events).
1005
+ * @returns the folded header, or undefined when no header event exists yet.
1006
+ */
1007
+ requestHeader(): EpochHeader | undefined {
1008
+ if (this.headerFoldSeq < this.log.length) {
1009
+ // Frozen on update: the fold is session state exposed by reference — a
1010
+ // consumer mutating it in place (instead of building a replacement)
1011
+ // would desync every later comparison against the log, so mutation
1012
+ // throws instead.
1013
+ this.headerFold = deepFreeze(foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold))
1014
+ this.headerFoldSeq = this.log.length
1015
+ }
1016
+ return this.headerFold
1017
+ }
1018
+
1019
+ /** Cached fold of `request/context` events. */
1020
+ private contextFold: RequestContext | undefined
1021
+ private contextFoldSeq = 0
1022
+
1023
+ /**
1024
+ * Return the latest resolved route metadata, or `undefined` before the first
1025
+ * `request/context` event. Each event is folded once.
1026
+ * @returns the latest immutable route metadata.
1027
+ */
1028
+ requestContext(): RequestContext | undefined {
1029
+ if (this.contextFoldSeq < this.log.length) {
1030
+ for (const event of this.log.slice(this.contextFoldSeq)) {
1031
+ if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
1032
+ }
1033
+ this.contextFoldSeq = this.log.length
1034
+ }
1035
+ return this.contextFold
1036
+ }
1037
+
1038
+ /** Cached historical tool definitions and updates for request projection. */
1039
+ private readonly toolHistoryProjection = new ToolHistoryProjection()
1040
+ /** Index of the next committed event not yet consumed by the tool-history fold. */
1041
+ private toolHistorySeq = 0
1042
+
1043
+ /**
1044
+ * Fold unseen committed events into capability-independent tool history.
1045
+ * Initial access reconstructs inherited history; later reads consume only new events.
1046
+ * @returns an immutable snapshot for LLM request projection, including historical addition definitions.
1047
+ */
1048
+ toolHistory(): ToolHistory {
1049
+ for (const event of this.log.slice(this.toolHistorySeq)) this.toolHistoryProjection.apply(event)
1050
+ this.toolHistorySeq = this.log.length
1051
+ return this.toolHistoryProjection.snapshot()
1052
+ }
1053
+
1054
+ /** The derived-message cache: frozen projections, extended per unseen node. */
1055
+ private derived: Message[] = []
1056
+ /** Surface position (nodes projected) the cache has reached. */
1057
+ private derivedNodes = 0
1058
+ /** {@link SurfaceManager.contentGeneration} the cache was built under. */
1059
+ private derivedGeneration = 0
1060
+
1061
+ /**
1062
+ * Derive the LLM message history by walking the ordered sequences of
1063
+ * message-producing events maintained by `surfaceOp` markers. The
1064
+ * surface is the single source of derived history: every message-producing
1065
+ * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
1066
+ * turn boundary) is correctly absent, and a compaction `replace` deletes the
1067
+ * shadowed nodes from the derivation. The projection rules are
1068
+ * {@link deriveEventMessage}, with logged message projections applied
1069
+ * without changing node membership or message identity.
1070
+ *
1071
+ * CACHED: pure tail growth costs O(new nodes); a replacement or message projection
1072
+ * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is
1073
+ * a fresh snapshot per call (later appends never grow an array a caller
1074
+ * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
1075
+ * Unchanged content reuses frozen event data; projected blocks are frozen
1076
+ * derived copies. Consumers cannot mutate the log through either form.
1077
+ * @returns a fresh array of the shared, frozen derived history.
1078
+ */
1079
+ deriveMessages(): Message[] {
1080
+ const surface = this.surface
1081
+ const nodes = surface.nodes
1082
+ const generation = surface.contentGeneration
1083
+ if (generation !== this.derivedGeneration) {
1084
+ this.derived = []
1085
+ this.derivedNodes = 0
1086
+ this.derivedGeneration = generation
1087
+ }
1088
+ for (const seq of nodes.slice(this.derivedNodes)) {
1089
+ // Surface sequences are built from this.log — seq is always a valid
1090
+ // index by construction. The non-null assertion expresses that invariant.
1091
+ // oxlint-disable-next-line typescript/no-non-null-assertion
1092
+ const msg = this.deriveEventMessage(this.log[seq]!)
1093
+ // A surface node is one of the five message-producing types, but an
1094
+ // empty-content assistant/message (a max-tokens step that hosts only
1095
+ // usage) derives to null and must not enter the transcript.
1096
+ if (msg) this.derived.push(msg)
1097
+ }
1098
+ this.derivedNodes = nodes.length
1099
+ return [...this.derived]
1100
+ }
1101
+
1102
+ /**
1103
+ * Project one event with all committed message projections applied.
1104
+ * The original durable event remains unchanged.
1105
+ * @param event - the event to project.
1106
+ * @returns the derived message, or null when the event produces none.
1107
+ */
1108
+ deriveEventMessage(event: SessionEvent): Message | null {
1109
+ return this.surfaceManager.deriveEventMessage(event)
1110
+ }
1111
+ }
1112
+
1113
+ /** A fork source: either the live session object or its live store id. */
1114
+ export type SessionForkSource = Session | SessionId
1115
+
1116
+ /**
1117
+ * Rejection codes for session forking: the fork source id is unknown to the
1118
+ * live store (`SESSION_NOT_FOUND`) or names a session object that is not the
1119
+ * store's live instance (`SESSION_NOT_LIVE`); the requested child id is
1120
+ * already taken (`SESSION_ALREADY_EXISTS`); or the boundary is not a contiguous
1121
+ * existing seq (`INVALID_BOUNDARY`).
1122
+ */
1123
+ export type SessionForkErrorCode =
1124
+ | 'SESSION_NOT_FOUND'
1125
+ | 'SESSION_NOT_LIVE'
1126
+ | 'SESSION_ALREADY_EXISTS'
1127
+ | 'INVALID_BOUNDARY'
1128
+
1129
+ /** Typed error for session fork rejections. */
1130
+ export class SessionForkError extends Error {
1131
+ constructor(message: string, public readonly code: SessionForkErrorCode) {
1132
+ super(message)
1133
+ this.name = 'SessionForkError'
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * In-memory session store (`ctx.sessions`).
1139
+ *
1140
+ * Persistence is intentionally not implemented here — the agent lifecycle
1141
+ * attaches a session-log writer to each published session's write handle;
1142
+ * a session published outside that lifecycle persists nothing.
1143
+ */
1144
+ export class SessionStore extends Service {
1145
+ private store = new Map<SessionId, SessionEntry>()
1146
+ private counter = 0
1147
+ private readonly projections: SessionMessageProjection[] = []
1148
+
1149
+ /** Borrowed definitions for detached replay; contributions live until their registering fibers unload. */
1150
+ get messageProjections(): readonly SessionMessageProjection[] {
1151
+ return this.projections
1152
+ }
1153
+
1154
+ /**
1155
+ * Register one event interpreter for live creation, restore, and fork.
1156
+ * Disposing the contribution makes sessions that used it refuse further derivation.
1157
+ * @param projection - pure definition owned by the event's plugin.
1158
+ * @returns the fiber-owned disposer.
1159
+ * @throws when another definition already owns this event type.
1160
+ */
1161
+ registerMessageProjection(projection: SessionMessageProjection): () => Promise<void> {
1162
+ if (this.projections.some(item => item.type === projection.type)) {
1163
+ throw new Error(`session message projection "${projection.type}" is already registered`)
1164
+ }
1165
+ return this.ctx.effect(() => {
1166
+ this.projections.push(projection)
1167
+ return () => { this.projections.splice(this.projections.indexOf(projection), 1) }
1168
+ }, 'sessions.registerMessageProjection()')
1169
+ }
1170
+
1171
+ constructor(ctx: Context) {
1172
+ super(ctx, 'sessions')
1173
+ ctx.inject(['typert'], (typeCtx) => {
1174
+ typeCtx.typert.lookups.register('session', {
1175
+ parameter: 'session',
1176
+ wire: 'sessionId',
1177
+ resource: 'session',
1178
+ hostTypeSymbol: '@deepseek-ai/dsh-session#Session',
1179
+ wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
1180
+ resolve: sessionId => this.get(sessionId),
1181
+ })
1182
+ })
1183
+ }
1184
+
1185
+ /**
1186
+ * Create a session owned by the calling fiber: disposing that fiber stops
1187
+ * event notification and removes the session from the store. `options.seed`
1188
+ * populates the session with a copy of those events (replay/fork);
1189
+ * `options.meta` attaches creation metadata (validated absolute `cwd`, seed
1190
+ * and parent lineage, and delegation depth) as the immutable
1191
+ * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
1192
+ *
1193
+ * For an agent whose session must be torn down IN ORDER with its loop (so the
1194
+ * loop's final events are published before the store attachment ends), do NOT use this
1195
+ * — fold the session lifecycle into the agent's own effect via
1196
+ * {@link prepare} + {@link enter} + {@link announce} (see
1197
+ * `dsh-agent-loop`'s creation transaction).
1198
+ *
1199
+ * @param id - the session id; omitted, the store mints `session-<n>`.
1200
+ * @param options - seed events and/or creation metadata for the header.
1201
+ * @returns the live session, already entered and announced.
1202
+ * @throws if a session with `id` already exists, metadata is not a plain
1203
+ * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
1204
+ * non-absolute path (storage backends key directories off it).
1205
+ */
1206
+ create(id?: SessionId, options?: CreateSessionOptions): Session {
1207
+ const session = this.prepare(id, options)
1208
+ // Single effect owned by the calling fiber. Yield the detach BEFORE
1209
+ // announcing so a throwing `session/created` listener rolls the attach back
1210
+ // (the generator effect disposes already-yielded disposers on a throw)
1211
+ // instead of leaking the store entry and its publication hooks.
1212
+ this.ctx.effect(function* (this: SessionStore) {
1213
+ yield this.enter(session)
1214
+ this.announce(session)
1215
+ }.bind(this), 'sessions.create()')
1216
+ return session
1217
+ }
1218
+
1219
+ /**
1220
+ * Build a session WITHOUT entering it into the store — validate the id/cwd and
1221
+ * construct the {@link Session} (with its immutable {@link SessionHeader}).
1222
+ * Pairs with {@link enter} + {@link announce}: a caller that owns a composite
1223
+ * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
1224
+ * effect so a fiber unload tears the session + agent down as a single ORDERED
1225
+ * chain rather than as racing sibling effects — which would remove the publication hooks
1226
+ * before the driver's closing events commit, dropping them.
1227
+ *
1228
+ * @param id - the session id; omitted, the store mints `session-<n>`.
1229
+ * @param options - seed events and/or creation metadata for the header. With
1230
+ * `eventState`, every seed event is either independently owned or any
1231
+ * shared value is deeply frozen; {@link Session.fromRestore} validates and
1232
+ * adopts those values without copying or freezing them.
1233
+ * @returns the store-owned session, not yet entered and read-only until announced.
1234
+ * @throws if a session with `id` already exists, metadata is not a plain
1235
+ * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
1236
+ * non-absolute path.
1237
+ */
1238
+ prepare(id?: SessionId, options?: PrepareSessionOptions): Session {
1239
+ let sessionId: SessionId
1240
+ if (id === undefined) {
1241
+ do sessionId = brandString<SessionId>(`session-${++this.counter}`)
1242
+ while (this.store.has(sessionId))
1243
+ } else {
1244
+ sessionId = brandString<SessionId>(id)
1245
+ }
1246
+ if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
1247
+ if (options !== undefined) {
1248
+ const { eventState } = options
1249
+ switch (eventState) {
1250
+ case 'detached':
1251
+ case 'shared-frozen': {
1252
+ const session = Session.fromRestore(
1253
+ sessionId,
1254
+ options.seed,
1255
+ options.meta,
1256
+ options.inheritedEventCount,
1257
+ eventState,
1258
+ this.projections,
1259
+ )
1260
+ attachments.set(session, null)
1261
+ return session
1262
+ }
1263
+ case undefined:
1264
+ break
1265
+ /* v8 ignore next -- closed-union exhaustiveness guard */
1266
+ default:
1267
+ assertNever(eventState, 'SessionStore.prepare event state')
1268
+ }
1269
+ }
1270
+ const seed = options?.seed
1271
+ const meta = options?.meta
1272
+ const header: SessionHeader = {
1273
+ version: SESSION_FORMAT_VERSION,
1274
+ id: sessionId,
1275
+ createdAt: meta?.createdAt ?? Date.now(),
1276
+ ...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
1277
+ ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
1278
+ isSeeded: meta?.isSeeded ?? false,
1279
+ ...meta?.origin === undefined ? {} : { origin: meta.origin },
1280
+ ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
1281
+ ...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset },
1282
+ }
1283
+ const session = Session.create(sessionId, seed, header, options?.inheritedEventCount, this.projections)
1284
+ attachments.set(session, null)
1285
+ return session
1286
+ }
1287
+
1288
+ /**
1289
+ * Enter a session into the store: install the module-private
1290
+ * append publication hooks and add it to the store. Writes remain disabled
1291
+ * until {@link announce} completes successfully. Returns the DETACH
1292
+ * disposer (hooks + store removal). Does NOT emit `session/created` —
1293
+ * the caller yields this disposer inside its effect and THEN calls
1294
+ * {@link announce}, so a throwing `session/created` listener rolls the attach
1295
+ * back instead of leaking it.
1296
+ *
1297
+ * Re-checks the id for a duplicate: `prepare` and `enter` are public
1298
+ * cross-package primitives and a caller may interleave arbitrary work (or
1299
+ * another create) between them, so a stale prepared session must NOT overwrite
1300
+ * a live store entry of the same id — its detach disposer would later delete
1301
+ * the REAL session. The {@link create} convenience and the agent factory call
1302
+ * the two back-to-back so they never trip this, but the public API cannot
1303
+ * assume that.
1304
+ *
1305
+ * @param session - an unentered session; independent instances become store-owned.
1306
+ * @returns the detach disposer (publication hooks + store removal). When called from
1307
+ * a synchronous `session/created` listener, removal and disposal wait until
1308
+ * that creation dispatch unwinds.
1309
+ * @throws if a session with this id is already in the store.
1310
+ */
1311
+ enter(session: Session): () => void {
1312
+ const id = session.id
1313
+ const carrier = scopeTarget(session, scopeOf(this.ctx))
1314
+ // This is the authoritative collision boundary after arbitrary unpublished
1315
+ // preparation. Only one exact same-id transaction can publish.
1316
+ if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
1317
+ if (attachments.get(session) != null) throw new Error(`session "${id}" is already attached to a store`)
1318
+ const entry: SessionEntry = {
1319
+ id,
1320
+ session,
1321
+ carrier,
1322
+ emitCtx: this.ctx,
1323
+ announcement: 'pending',
1324
+ appending: false,
1325
+ detachRequested: false,
1326
+ detach: () => { this.detachEntered(entry) },
1327
+ }
1328
+ this.store.set(id, entry)
1329
+ attachments.set(session, entry)
1330
+ let entered = true
1331
+ const detach = (): void => {
1332
+ if (!entered) return
1333
+ entered = false
1334
+ // A lifecycle listener may own the advanced detach capability. Keep the
1335
+ // entry and its publication hooks live until synchronous creation or append
1336
+ // publication unwinds, then publish the paired disposal edge.
1337
+ if (entry.announcement === 'dispatching' || entry.appending) {
1338
+ entry.detachRequested = true
1339
+ return
1340
+ }
1341
+ entry.detach()
1342
+ }
1343
+ return detach
1344
+ }
1345
+
1346
+ /** Remove one exact entered session and emit its paired disposal when announced. */
1347
+ private detachEntered(entry: SessionEntry): void {
1348
+ entry.detachRequested = false
1349
+ // A stale capability cannot remove observers or storage belonging to a
1350
+ // later same-id lifecycle.
1351
+ /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
1352
+ if (this.store.get(entry.id) !== entry) return
1353
+ this.store.delete(entry.id)
1354
+ attachments.set(entry.session, null)
1355
+ if (entry.announcement !== 'pending') this.emitDisposed(entry)
1356
+ }
1357
+
1358
+ /** Emit `session/created` exactly once for an {@link enter}ed session (with
1359
+ * the carrier {@link enter} captured). Separate from {@link enter} so the
1360
+ * caller can yield the detach disposer first (rollback safety — see
1361
+ * {@link enter}). The Session remains read-only throughout synchronous
1362
+ * dispatch; successful completion enables appends. A failed announcement
1363
+ * leaves it read-only and cannot be retried.
1364
+ * @param session - the entered session to announce to listeners.
1365
+ * @throws if the session is not live or its announcement already began,
1366
+ * including a reentrant call from a creation listener. */
1367
+ announce(session: Session): void {
1368
+ const entry = this.liveEntryFor(session)
1369
+ if (entry.announcement !== 'pending') {
1370
+ throw new Error(`session "${entry.id}" was already announced`)
1371
+ }
1372
+ // Partial creation dispatch still requires disposal on rollback; listeners
1373
+ // cannot recursively begin another announcement.
1374
+ const callbackArgs: unknown[] = [session]
1375
+ entry.announcement = 'dispatching'
1376
+ try {
1377
+ const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
1378
+ for (const callback of callbacks) {
1379
+ // Synchronous throws intentionally propagate and veto publication; the
1380
+ // yielded detach then emits the paired disposal edge. An async function
1381
+ // is nevertheless assignable to a void listener, so observe its returned
1382
+ // promise: rejection is too late to roll back and must be logged instead
1383
+ // of becoming unhandled.
1384
+ const returned: unknown = callback(...callbackArgs)
1385
+ void Promise.resolve(returned).catch((error: unknown) => {
1386
+ this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
1387
+ })
1388
+ }
1389
+ entry.announcement = 'complete'
1390
+ } catch (error: unknown) {
1391
+ entry.announcement = 'failed'
1392
+ throw error
1393
+ } finally {
1394
+ if (entry.detachRequested) entry.detach()
1395
+ }
1396
+ }
1397
+
1398
+ /** Emit the paired teardown notification with per-listener containment. */
1399
+ private emitDisposed(entry: SessionEntry): void {
1400
+ const callbackArgs: unknown[] = [entry.session]
1401
+ try {
1402
+ const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
1403
+ invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
1404
+ } catch (error: unknown) {
1405
+ this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
1406
+ }
1407
+ }
1408
+
1409
+ /**
1410
+ * Dispatch the awaited `session/flush` durability checkpoint for `session`,
1411
+ * with the carrier captured at {@link enter}. THE flush entry point: the
1412
+ * store owns the carrier, so callers (the checkpoint policy's per-request
1413
+ * barrier, goal-round-driver's idle checkpoint, teardown drains, and consumers
1414
+ * that flush themselves before reading storage) must come through here
1415
+ * rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner
1416
+ * and one spelling.
1417
+ * @param session - the session whose buffered events must reach durable storage.
1418
+ * @returns whether at least one durability listener participated, after every
1419
+ * listener has settled successfully.
1420
+ * @throws the first registered listener failure after every listener settles.
1421
+ */
1422
+ async flush(session: Session): Promise<boolean> {
1423
+ const { carrier } = this.liveEntryFor(session)
1424
+ const callbackArgs: unknown[] = [session]
1425
+ const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
1426
+ const results = await Promise.allSettled(callbacks.map((callback) => {
1427
+ try {
1428
+ return callback(...callbackArgs)
1429
+ } catch (error: unknown) {
1430
+ // Preserve the listener's exact rejection value; flush is a caller-owned
1431
+ // failure boundary, and Cordis listeners may throw arbitrary values.
1432
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors
1433
+ return Promise.reject(error)
1434
+ }
1435
+ }))
1436
+ const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
1437
+ if (failure !== undefined) throw failure.reason
1438
+ return callbacks.length > 0
1439
+ }
1440
+
1441
+ /** Return the exact live entry; detached/prepared objects reject. */
1442
+ private liveEntryFor(session: Session): SessionEntry {
1443
+ const entry = attachments.get(session)
1444
+ if (entry == null || this.store.get(entry.id) !== entry) {
1445
+ throw new Error(`session "${session.id}" is not live in this store`)
1446
+ }
1447
+ return entry
1448
+ }
1449
+
1450
+ /**
1451
+ * Look up a live session.
1452
+ * @param id - the session id to look up.
1453
+ * @returns the session, or undefined when no live session has that id.
1454
+ */
1455
+ get(id: SessionId): Session | undefined {
1456
+ return this.store.get(id)?.session
1457
+ }
1458
+
1459
+ /**
1460
+ * All live sessions, in creation order.
1461
+ * @returns a fresh array; mutating it does not affect the store.
1462
+ */
1463
+ list(): Session[] {
1464
+ return [...this.store.values()].map(entry => entry.session)
1465
+ }
1466
+
1467
+ /**
1468
+ * Create an independent live session from an exact prefix of a live source.
1469
+ * `boundary` is an inclusive source event seq; omitted means the source's
1470
+ * current last event. An open tail receives synthetic tool results and
1471
+ * step/turn closers with the forked cause. Closed steps and turns remain
1472
+ * unchanged, including any failed tool calls already missing results.
1473
+ * `inheritedEventCount` counts only copied source events, excluding these closers.
1474
+ *
1475
+ * @param source - Live source session object or id.
1476
+ * @param boundary - Inclusive source event seq to fork through; omitted means
1477
+ * the source's current last event, and omitted on an empty source forks an
1478
+ * empty child.
1479
+ * @param childSessionId - Optional child session id; omitted delegates to
1480
+ * `SessionStore`'s id policy.
1481
+ * @returns The created live child session.
1482
+ */
1483
+ fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session {
1484
+ if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
1485
+ throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
1486
+ }
1487
+ const liveSource = this._resolveForkSource(source)
1488
+ // oxlint-disable-next-line typescript/no-deprecated -- Existing fork snapshot read; migration deferred.
1489
+ const events = liveSource.snapshotEvents()
1490
+ const resolved = this._forkBoundary(liveSource.id, events, boundary)
1491
+ const seed = resolved === undefined ? [] : buildForkSeed(events, resolved)
1492
+ return this.create(childSessionId, {
1493
+ seed,
1494
+ inheritedEventCount: SessionLogOffset(resolved === undefined ? 0 : resolved + 1),
1495
+ meta: {
1496
+ ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
1497
+ isSeeded: true,
1498
+ },
1499
+ })
1500
+ }
1501
+
1502
+ private _forkBoundary(
1503
+ sessionId: SessionId, events: readonly SessionEvent[], requestedBoundary: SessionSeq | undefined,
1504
+ ): SessionSeq | undefined {
1505
+ const lastEvent = events.at(-1)
1506
+ let boundary: SessionSeq
1507
+ if (requestedBoundary !== undefined) {
1508
+ boundary = requestedBoundary
1509
+ } else {
1510
+ if (lastEvent === undefined) return undefined
1511
+ boundary = lastEvent.seq
1512
+ }
1513
+ if (!Number.isSafeInteger(boundary) || boundary < 0) {
1514
+ throw new SessionForkError(
1515
+ `fork boundary for session "${sessionId}" must be a non-negative safe integer, got ${String(boundary)}`,
1516
+ 'INVALID_BOUNDARY',
1517
+ )
1518
+ }
1519
+ if (boundary >= events.length) {
1520
+ const lastSeq = lastEvent?.seq
1521
+ throw new SessionForkError(
1522
+ `fork boundary ${boundary} does not exist in session "${sessionId}" (last seq: ${lastSeq ?? 'none'})`,
1523
+ 'INVALID_BOUNDARY',
1524
+ )
1525
+ }
1526
+
1527
+ const boundaryEvent = events[boundary]
1528
+ if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
1529
+ throw new SessionForkError(
1530
+ `fork boundary ${boundary} does not match a contiguous event seq in session "${sessionId}"`,
1531
+ 'INVALID_BOUNDARY',
1532
+ )
1533
+ }
1534
+ return boundary
1535
+ }
1536
+
1537
+ private _resolveForkSource(source: SessionForkSource): Session {
1538
+ if (typeof source === 'string') {
1539
+ const session = this.get(source)
1540
+ if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
1541
+ return session
1542
+ }
1543
+
1544
+ const live = this.get(source.id)
1545
+ if (live === undefined) {
1546
+ throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
1547
+ }
1548
+ if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
1549
+ return source
1550
+ }
1551
+
1552
+ }
1553
+
1554
+ export { decodeSeqRanges, encodeSeqRanges } from './seq-ranges.ts'
1555
+ export default SessionStore