dsh-code 1.0.6 → 1.0.7

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.
@@ -23,6 +23,7 @@ import type {} from '@deepseek-ai/dsh-goal'
23
23
  import type {} from '@deepseek-ai/dsh-llm-retry'
24
24
  import type {} from '@deepseek-ai/dsh-plan-mode'
25
25
  import type {} from '@deepseek-ai/dsh-permission-presets'
26
+ import type {} from '@deepseek-ai/dsh-schedule'
26
27
  import type {} from '@deepseek-ai/dsh-sandbox-policy'
27
28
  import type {} from '@deepseek-ai/dsh-session-title'
28
29
  // The subagent package's durable catalog event joins the union the same way
@@ -273,6 +274,86 @@ export interface TranscriptStats {
273
274
  reasoningEffort: string
274
275
  }
275
276
 
277
+ /** One active reminder folded from durable `schedule/change` events. */
278
+ export interface ScheduleRow {
279
+ readonly id: string
280
+ readonly kind: 'after' | 'at' | 'every'
281
+ readonly prompt: string
282
+ /** Next due time (epoch ms); the /schedule panel derives overdue/relative labels. */
283
+ readonly targetAt: number
284
+ /** Recurrence seconds for 'every' rows, undefined otherwise. */
285
+ readonly everySeconds?: number
286
+ }
287
+
288
+ /**
289
+ * The durable `schedule/change` payload shape this fold consumes. Upstream
290
+ * strict-decodes the whole transition stream before appending, so unknown
291
+ * ids here are corrupt-input edges that degrade to a no-op.
292
+ */
293
+ export interface ScheduleChangeLike {
294
+ readonly operation: 'create' | 'delete' | 'dispatch'
295
+ readonly schedule?: {
296
+ readonly id: string
297
+ readonly kind: 'after' | 'at' | 'every'
298
+ readonly prompt: string
299
+ readonly afterSeconds?: number
300
+ readonly everySeconds?: number
301
+ readonly scheduledAt: string
302
+ }
303
+ readonly id?: string
304
+ readonly acceptedAt?: string
305
+ }
306
+
307
+ /*
308
+ * Upstream record semantics (dsh-schedule types): `scheduledAt` is ALREADY
309
+ * the due instant — AfterScheduleRecord carries the RFC 3339 UTC target
310
+ * (delay included), EveryScheduleRecord carries the earliest anchor-aligned
311
+ * occurrence not yet dispatched. No kind ever adds its own interval on top.
312
+ */
313
+
314
+ /** Fold one `schedule/change` into the active-reminder list (create/delete/dispatch). */
315
+ export function applyScheduleChange(rows: readonly ScheduleRow[], data: ScheduleChangeLike): readonly ScheduleRow[] {
316
+ if (data.operation === 'create' && data.schedule !== undefined) {
317
+ const schedule = data.schedule
318
+ const row: ScheduleRow = {
319
+ id: schedule.id,
320
+ kind: schedule.kind,
321
+ prompt: schedule.prompt,
322
+ targetAt: Date.parse(schedule.scheduledAt),
323
+ ...(schedule.kind === 'every' ? { everySeconds: schedule.everySeconds ?? 0 } : {}),
324
+ }
325
+ return [...rows.filter(existing => existing.id !== schedule.id), row]
326
+ }
327
+ if (data.operation === 'delete' && data.id !== undefined) {
328
+ return rows.filter(existing => existing.id !== data.id)
329
+ }
330
+ if (data.operation === 'dispatch' && data.id !== undefined) {
331
+ // A dispatched one-shot reminder is finished. An 'every' reminder
332
+ // advances PAST every missed occurrence in one step: the next target is
333
+ // the first anchor-aligned instant strictly after acceptedAt, stepping
334
+ // from the previous aligned target (upstream advances the same way).
335
+ if (data.acceptedAt === undefined) return rows.filter(existing => existing.id !== data.id)
336
+ const accepted = Date.parse(data.acceptedAt)
337
+ return rows.map(existing => existing.id === data.id
338
+ ? { ...existing, targetAt: nextEveryTarget(existing.targetAt, accepted, existing.everySeconds ?? 0) }
339
+ : existing)
340
+ }
341
+ return rows
342
+ }
343
+
344
+ /** First anchor-aligned target after `acceptedAt`, stepping from the previous aligned target. */
345
+ export function nextEveryTarget(previousTarget: number, acceptedAt: number, everySeconds: number): number {
346
+ const interval = Math.max(1, everySeconds) * 1000
347
+ if (acceptedAt <= previousTarget) return previousTarget + interval
348
+ const missed = Math.ceil((acceptedAt - previousTarget + 1) / interval)
349
+ return previousTarget + missed * interval
350
+ }
351
+
352
+ /** Plugin snapshot sources folded into token stats but never rendered as rows. */
353
+ const HIDDEN_SNAPSHOT_PLUGINS = new Set(['time-context', 'tmux-context'])
354
+ /** Plugin prompt sources rendered as full user rows (they ARE the conversation). */
355
+ const REMINDER_PLUGINS = new Set(['schedule'])
356
+
276
357
  /** The complete TUI transcript view for one session. */
277
358
  export interface TranscriptView {
278
359
  /** Settled entries in log order. */
@@ -319,6 +400,8 @@ export interface TranscriptView {
319
400
  sandbox: string
320
401
  /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
321
402
  goal: GoalFold | undefined
403
+ /** Active reminders folded from `schedule/change` events, oldest target first at render. */
404
+ schedules: readonly ScheduleRow[]
322
405
  /**
323
406
  * Ordered live message ids per inbox target, mirrored from
324
407
  * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
@@ -491,6 +574,7 @@ export function createTranscriptView(): TranscriptView {
491
574
  systemPrompt: '',
492
575
  sandbox: '',
493
576
  goal: undefined,
577
+ schedules: [],
494
578
  pending: { 'next-turn': [], 'next-step': [] },
495
579
  stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
496
580
  anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map(), systemNodes: new Map() },
@@ -558,9 +642,42 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
558
642
  },
559
643
  }
560
644
  }
645
+ // Snapshot injections (time/tmux context) still spend model context but
646
+ // render nothing; the schedule reminder is a real prompt and renders in
647
+ // full — the model acts on it, so the transcript must show it.
648
+ if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
649
+ return {
650
+ ...view,
651
+ pending,
652
+ entries,
653
+ stats: {
654
+ ...view.stats,
655
+ contextSegments: {
656
+ ...view.stats.contextSegments,
657
+ system: view.stats.contextSegments.system + estimateTokens(text),
658
+ },
659
+ },
660
+ }
661
+ }
662
+ if (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin)) {
663
+ return {
664
+ ...view,
665
+ pending,
666
+ entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
667
+ stats: {
668
+ ...view.stats,
669
+ contextSegments: {
670
+ ...view.stats.contextSegments,
671
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
672
+ },
673
+ },
674
+ }
675
+ }
561
676
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
562
677
  ? message.source.summary
563
- : message.source.kind
678
+ : message.source.kind === 'plugin'
679
+ ? message.source.plugin
680
+ : message.source.kind
564
681
  const summary = boundContextSummary(notice)
565
682
  return {
566
683
  ...view,
@@ -963,6 +1080,11 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
963
1080
  entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
964
1081
  }
965
1082
  }
1083
+ case 'schedule/change':
1084
+ // Non-conversational catalog state: the /schedule panel renders the
1085
+ // active list, the transcript shows only the reminder prompts
1086
+ // (handled at user/message above).
1087
+ return { ...view, schedules: applyScheduleChange(view.schedules, event.data) }
966
1088
  case 'session/title':
967
1089
  // Latest-wins title snapshot, log-only; the status line prefers it.
968
1090
  return { ...view, title: event.data.title }
@@ -1098,6 +1220,7 @@ export interface ReplayAccumulator {
1098
1220
  systemPrompt: string
1099
1221
  sandbox: string
1100
1222
  goal: GoalFold | undefined
1223
+ schedules: readonly ScheduleRow[]
1101
1224
  stats: TranscriptStats
1102
1225
  stepStart: Map<string, number>
1103
1226
  toolStart: Map<string, number>
@@ -1137,6 +1260,7 @@ export function createReplayAccumulator(): ReplayAccumulator {
1137
1260
  systemPrompt: '',
1138
1261
  sandbox: '',
1139
1262
  goal: undefined,
1263
+ schedules: [],
1140
1264
  stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
1141
1265
  stepStart: new Map(),
1142
1266
  toolStart: new Map(),
@@ -1270,7 +1394,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1270
1394
  const text = textOf(message.content)
1271
1395
  const images = imagesOf(message.content)
1272
1396
  const files = filesOf(message.content)
1273
- if (message.source.kind === 'user') {
1397
+ if (message.source.kind === 'user' || (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin))) {
1274
1398
  appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
1275
1399
  acc.stats = {
1276
1400
  ...acc.stats,
@@ -1281,9 +1405,21 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1281
1405
  }
1282
1406
  return true
1283
1407
  }
1408
+ if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
1409
+ acc.stats = {
1410
+ ...acc.stats,
1411
+ contextSegments: {
1412
+ ...acc.stats.contextSegments,
1413
+ system: acc.stats.contextSegments.system + estimateTokens(text),
1414
+ },
1415
+ }
1416
+ return true
1417
+ }
1284
1418
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1285
1419
  ? message.source.summary
1286
- : message.source.kind
1420
+ : message.source.kind === 'plugin'
1421
+ ? message.source.plugin
1422
+ : message.source.kind
1287
1423
  const summary = boundContextSummary(notice)
1288
1424
  appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
1289
1425
  acc.stats = {
@@ -1603,6 +1739,10 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1603
1739
  if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1604
1740
  return true
1605
1741
  }
1742
+ case 'schedule/change':
1743
+ acc.schedules = applyScheduleChange(acc.schedules, event.data)
1744
+ acc.ops += 1
1745
+ return true
1606
1746
  case 'session/title':
1607
1747
  acc.title = event.data.title
1608
1748
  return true
@@ -1715,6 +1855,7 @@ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): Transcrip
1715
1855
  systemPrompt: acc.systemPrompt,
1716
1856
  sandbox: acc.sandbox,
1717
1857
  goal: acc.goal,
1858
+ schedules: acc.schedules,
1718
1859
  pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1719
1860
  stats: acc.stats,
1720
1861
  // Handed-out views get their own anchors snapshot: the accumulator keeps
@@ -77,6 +77,7 @@ export type StatusTone =
77
77
  | 'meta'
78
78
  | 'accent'
79
79
  | 'success'
80
+ | 'plan'
80
81
  | 'warn'
81
82
  | 'error'
82
83
  // Context-bar fill: one DeepSeek blue for the whole occupied run (the free
@@ -419,9 +420,7 @@ function buildCandidates(
419
420
  const right: { span: StatusSpan; rank: number; id: string }[] = []
420
421
  const row2: { group: StatusGroup; rank: number; id: string }[] = []
421
422
 
422
- if (facts.plan && enabled.has('plan')) {
423
- row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
424
- }
423
+
425
424
 
426
425
  if (stats.turns > 0 || stats.steps > 0) {
427
426
  if (enabled.has('turns')) {
@@ -522,10 +521,25 @@ function buildCandidates(
522
521
  }
523
522
  const permission = safe(facts.permission)
524
523
  let badge = -1
524
+ // The plan STATION names itself in the permission badge: with the most
525
+ // restrictive preset active, plan mode reads as the green fourth cycle
526
+ // station 'plan' (that preset IS the station's permission layer). Plan on
527
+ // any other preset (a typed /plan mid-session) stays orthogonal: the badge
528
+ // keeps naming the preset and row 2 carries the green plan marker.
529
+ const planStation = facts.plan && permissionTone(permission) === 'success'
525
530
  if (permission !== '' && enabled.has('permission')) {
526
- right.push({ span: { text: permission, tone: permissionTone(permission) }, rank: RANK_BADGE, id: 'permission' })
531
+ right.push({
532
+ span: planStation
533
+ ? { text: 'plan on', tone: 'plan' }
534
+ : { text: permission, tone: permissionTone(permission) },
535
+ rank: RANK_BADGE,
536
+ id: 'permission',
537
+ })
527
538
  badge = right.length - 1
528
539
  }
540
+ if (facts.plan && enabled.has('plan')) {
541
+ row2.push({ group: { spans: [{ text: '⧉ plan', tone: 'accent' }] }, rank: RANK2_PLAN, id: 'plan' })
542
+ }
529
543
  return { left, right, badge, row2 }
530
544
  }
531
545
 
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Skip-tolerant session-query engine for this terminal.
3
+ *
4
+ * The upstream SqliteSessionQueryEngine reconciliation observes EVERY
5
+ * persisted session before each search, and ONE unreadable source (for
6
+ * example a pre-release session artifact the frozen format codecs reject)
7
+ * fails the whole pass with SESSION_QUERY_PERSISTENCE_FAILED — every
8
+ * cross-session search dies because of a single old file nobody opened
9
+ * otherwise. This subclass overrides only the observation loop so an
10
+ * unreadable source is skipped with a warning and the rest of the corpus
11
+ * indexes normally; skipped sessions are retried on later reconciliations
12
+ * and rejoin automatically once a host that can read them is installed.
13
+ *
14
+ * Vendored surface note: `_observeStable` and its module-local helpers are
15
+ * private upstream; this file re-declares the observation loop against the
16
+ * pinned @deepseek-ai line (see package.json peers) and must be re-checked
17
+ * whenever that line moves. The engine class and the tool boundary also
18
+ * share ONE physical parent-package instance through this bundle, which
19
+ * restores instanceof-based typed error messages on the search path.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import SqliteSessionQueryEngine, { type Config } from '@deepseek-ai/dsh-session-query-sqlite'
24
+ import {
25
+ assertSessionHeadersCompatible,
26
+ buildSessionEventSearchDocuments,
27
+ readColdSessionLog,
28
+ SessionQueryError,
29
+ } from '@deepseek-ai/dsh-session-query'
30
+ import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
31
+ import type { SessionPersistenceRevision, SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
32
+
33
+ /** One observed session: detached header plus its derived search documents. */
34
+ interface ObservedSession {
35
+ header: SessionHeader
36
+ inheritedEventCount: SessionLogOffset
37
+ documents: readonly ReturnType<typeof buildSessionEventSearchDocuments>[number][]
38
+ fingerprint: string
39
+ }
40
+
41
+ /** One persisted snapshot as the reconciliation sees it (loaded once readable). */
42
+ interface ObservedPersistedSession {
43
+ header: SessionHeader
44
+ revision: SessionPersistenceRevision
45
+ loaded?: ObservedSession
46
+ /** Diagnosis for a cold read that failed; the session stays unindexed. */
47
+ unreadable?: string
48
+ }
49
+
50
+ /** The engine-internal state the observation loop touches. */
51
+ export interface EngineSurface {
52
+ readonly ctx: {
53
+ sessions: {
54
+ list(): readonly { header: SessionHeader; inheritedEventCount: SessionLogOffset; snapshotEvents(): readonly SessionEvent[]; id: SessionId }[]
55
+ get(id: SessionId): unknown
56
+ }
57
+ logger?: { warn(format: string, ...args: readonly unknown[]): void }
58
+ }
59
+ readonly _persistenceBinding: {
60
+ readonly identity: symbol
61
+ readonly service?: {
62
+ list(options?: { readonly signal?: AbortSignal }): Promise<readonly SessionPersistenceSnapshot[]>
63
+ }
64
+ }
65
+ _lastPersistenceIdentity: symbol | undefined
66
+ }
67
+
68
+ type ColdRead = (persistence: NonNullable<EngineSurface['_persistenceBinding']['service']>, id: SessionId, signal: AbortSignal | undefined) => Promise<{ header: SessionHeader; inheritedEventCount: SessionLogOffset; events: readonly SessionEvent[] }>
69
+
70
+ const STABLE_OBSERVATION_ATTEMPTS = 2
71
+
72
+ function assertNotAborted(signal: AbortSignal | undefined): void {
73
+ if (signal?.aborted) {
74
+ throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED')
75
+ }
76
+ }
77
+
78
+ function isAbort(error: unknown): boolean {
79
+ return error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED'
80
+ }
81
+
82
+ function errorMessage(error: unknown): string {
83
+ return error instanceof Error ? error.message : 'unknown error'
84
+ }
85
+
86
+ function observeSession(header: SessionHeader, inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[]): ObservedSession {
87
+ const detachedHeader = structuredClone(header)
88
+ const detachedEvents = events.map(event => structuredClone(event))
89
+ return {
90
+ header: detachedHeader,
91
+ inheritedEventCount,
92
+ documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents),
93
+ fingerprint: createHash('sha256')
94
+ .update(JSON.stringify({ header: detachedHeader, inheritedEventCount, events: detachedEvents }))
95
+ .digest('base64url'),
96
+ }
97
+ }
98
+
99
+ function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
100
+ return a.id === b.id
101
+ && a.createdAt === b.createdAt
102
+ && a.cwd === b.cwd
103
+ && a.parentSession === b.parentSession
104
+ && a.isSeeded === b.isSeeded
105
+ && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
106
+ && a.agentPreset === b.agentPreset
107
+ }
108
+
109
+ function materializePersistenceSnapshots(snapshots: readonly SessionPersistenceSnapshot[]): Map<SessionId, ObservedPersistedSession> {
110
+ if (!Array.isArray(snapshots)) throw new Error('persistence snapshots must be an array')
111
+ const result = new Map<SessionId, ObservedPersistedSession>()
112
+ for (const snapshot of snapshots) {
113
+ if (typeof snapshot.revision !== 'string') {
114
+ throw new Error('persistence snapshot revision must be a string')
115
+ }
116
+ const header = structuredClone(snapshot.header)
117
+ if (result.has(header.id)) {
118
+ throw new Error(`persistence listed duplicate session "${header.id}"`)
119
+ }
120
+ result.set(header.id, { header, revision: snapshot.revision })
121
+ }
122
+ return result
123
+ }
124
+
125
+ function samePersistenceSnapshots(before: ReadonlyMap<SessionId, ObservedPersistedSession>, after: ReadonlyMap<SessionId, ObservedPersistedSession>): boolean {
126
+ if (before.size !== after.size) return false
127
+ for (const [id, first] of before) {
128
+ const second = after.get(id)
129
+ if (second === undefined || first.revision !== second.revision || !sameHeader(first.header, second.header)) return false
130
+ }
131
+ return true
132
+ }
133
+
134
+ /**
135
+ * The skip-tolerant observation pass: structurally the upstream loop, with
136
+ * the per-source cold read wrapped so one unreadable session degrades to a
137
+ * warning instead of failing every search. Exported for unit tests with an
138
+ * injectable cold reader.
139
+ */
140
+ export async function observeStableWithSkip(
141
+ engine: EngineSurface,
142
+ indexed: ReadonlyMap<SessionId, { revision: SessionPersistenceRevision }>,
143
+ signal: AbortSignal | undefined,
144
+ readCold: ColdRead = readColdSessionLog as unknown as ColdRead,
145
+ ): Promise<{ persistenceBinding: EngineSurface['_persistenceBinding']; persisted: Map<SessionId, ObservedPersistedSession>; live: Map<SessionId, ObservedSession> }> {
146
+ for (let attempt = 0; attempt < STABLE_OBSERVATION_ATTEMPTS; attempt += 1) {
147
+ assertNotAborted(signal)
148
+ const persistenceBinding = engine._persistenceBinding
149
+ const persistence = persistenceBinding.service
150
+ const initiallyLive = new Set(engine.ctx.sessions.list().map(session => session.id))
151
+ let persisted = new Map<SessionId, ObservedPersistedSession>()
152
+ if (persistence !== undefined) {
153
+ try {
154
+ const canReuseIndexed = engine._lastPersistenceIdentity === undefined
155
+ || engine._lastPersistenceIdentity === persistenceBinding.identity
156
+ const listOptions = signal === undefined ? undefined : { signal }
157
+ const before = await persistence.list(listOptions)
158
+ assertNotAborted(signal)
159
+ persisted = materializePersistenceSnapshots(before)
160
+ for (const entry of persisted.values()) {
161
+ if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue
162
+ if (initiallyLive.has(entry.header.id) || engine.ctx.sessions.get(entry.header.id) !== undefined) continue
163
+ assertNotAborted(signal)
164
+ // The skip: a session whose cold log fails to migrate or decode
165
+ // stays OUT of the index with one warning; the remaining corpus
166
+ // indexes normally. `loaded` stays undefined exactly like a
167
+ // not-yet-read entry, so the stable-snapshot comparison and the
168
+ // live-preferred merge below are unaffected.
169
+ try {
170
+ const loaded = await readCold(persistence, entry.header.id, signal)
171
+ assertNotAborted(signal)
172
+ assertSessionHeadersCompatible(entry.header, loaded.header)
173
+ entry.loaded = observeSession(loaded.header, loaded.inheritedEventCount, loaded.events)
174
+ } catch (error: unknown) {
175
+ if (isAbort(error) || signal?.aborted) throw error
176
+ if (engine._persistenceBinding !== persistenceBinding) break
177
+ entry.unreadable = errorMessage(error)
178
+ engine.ctx.logger?.warn(
179
+ 'session-search skipped unreadable session %s: %s',
180
+ entry.header.id,
181
+ entry.unreadable,
182
+ )
183
+ }
184
+ }
185
+ assertNotAborted(signal)
186
+ const afterSnapshots = await persistence.list(listOptions)
187
+ assertNotAborted(signal)
188
+ const after = materializePersistenceSnapshots(afterSnapshots)
189
+ if (!samePersistenceSnapshots(persisted, after)) continue
190
+ if (engine._persistenceBinding !== persistenceBinding) continue
191
+ } catch (error: unknown) {
192
+ if (isAbort(error) || signal?.aborted) {
193
+ throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED', { cause: error })
194
+ }
195
+ if (engine._persistenceBinding !== persistenceBinding) continue
196
+ if (error instanceof SessionQueryError) throw error
197
+ throw new SessionQueryError(
198
+ `session-search persistence observation failed: ${errorMessage(error)}`,
199
+ 'SESSION_QUERY_PERSISTENCE_FAILED',
200
+ { cause: error },
201
+ )
202
+ }
203
+ }
204
+ const live = new Map<SessionId, ObservedSession>()
205
+ for (const session of engine.ctx.sessions.list()) {
206
+ const observed = observeSession(session.header, session.inheritedEventCount, session.snapshotEvents())
207
+ const durable = persisted.get(session.id)
208
+ if (durable !== undefined && durable.loaded === undefined) {
209
+ // A live owner always wins over a skipped durable copy.
210
+ live.set(session.id, observed)
211
+ continue
212
+ }
213
+ if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header as SessionHeader)
214
+ live.set(session.id, observed)
215
+ }
216
+ const sameLive = initiallyLive.size === live.size && [...initiallyLive].every(id => live.has(id))
217
+ if (!sameLive) continue
218
+ return { persistenceBinding, persisted, live }
219
+ }
220
+ throw new SessionQueryError('session-search persistence observation did not stabilize after one retry', 'SESSION_QUERY_PERSISTENCE_FAILED')
221
+ }
222
+
223
+ // The opaque-base cast keeps the private `_observeStable` override legal in
224
+ // TypeScript while inheriting every runtime static (inject, Config, Service
225
+ // metadata) from the real engine class.
226
+ const EngineBase = SqliteSessionQueryEngine as unknown as abstract new (ctx: never, config: Config) => EngineSurface & object
227
+
228
+ /** The engine this bundle mounts in place of the base `session-query-sqlite` row. */
229
+ export class SkipTolerantSessionQueryEngine extends EngineBase {
230
+ async _observeStable(indexed: ReadonlyMap<SessionId, { revision: SessionPersistenceRevision }>, signal: AbortSignal | undefined): Promise<unknown> {
231
+ return await observeStableWithSkip(this as unknown as EngineSurface, indexed, signal)
232
+ }
233
+ }
234
+
235
+ export default SkipTolerantSessionQueryEngine as unknown as typeof SqliteSessionQueryEngine