dsh-code 1.0.3 → 1.0.4

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.
@@ -34,6 +34,21 @@ interface LlmFace {
34
34
  readonly settingsPath: readonly string[]
35
35
  readonly declared?: boolean
36
36
  }[]
37
+ /**
38
+ * Registered endpoint model discovery; absent on an older service. The
39
+ * request is a draft (provider route and/or baseURL, optional one-shot
40
+ * key); the reply is candidate metadata for adoption, never a write.
41
+ */
42
+ discoverModels?(
43
+ settingsNs: string,
44
+ request: { readonly provider?: string; readonly baseURL?: string; readonly api?: string; readonly apiKey?: string },
45
+ signal?: AbortSignal,
46
+ ): Promise<readonly {
47
+ readonly id: string
48
+ readonly name?: string
49
+ readonly contextWindow?: number
50
+ readonly maxTokens?: number
51
+ }[]>
37
52
  }
38
53
 
39
54
  /** One redacted settings descriptor (subset of `SettingsDescriptor`). */
@@ -71,11 +86,19 @@ interface ProviderEventsFace {
71
86
  on(event: string, listener: (...args: unknown[]) => void): () => void
72
87
  }
73
88
 
89
+ /** One resolved credential value; never rendered, never persisted by callers. */
90
+ interface CredentialValueFace {
91
+ readonly value: string
92
+ readonly source: string
93
+ }
94
+
74
95
  /** The subset of the `credentials` service this module reads and writes. */
75
96
  interface CredentialsFace {
76
97
  describe(ref: string): Promise<CredentialFactsFace>
77
98
  set(ref: string, value: string): Promise<void>
78
99
  unset(ref: string): Promise<void>
100
+ /** Same-process value resolution; absent on an older service. */
101
+ resolve?(ref: string): Promise<CredentialValueFace | undefined>
79
102
  }
80
103
 
81
104
  /* ------------------------------------------------------------------ *
@@ -152,6 +175,7 @@ function configurationOf(profile: unknown): ProviderConfiguration {
152
175
  })
153
176
  return {
154
177
  ...(typeof record.baseURL === 'string' && record.baseURL.trim() !== '' ? { baseURL: record.baseURL } : {}),
178
+ ...(typeof record.api === 'string' && record.api.trim() !== '' ? { api: record.api } : {}),
155
179
  models,
156
180
  }
157
181
  }
@@ -211,9 +235,100 @@ export interface ProviderModelSettings {
211
235
  /** The small, portable subset of a provider profile the terminal edits. */
212
236
  export interface ProviderConfiguration {
213
237
  readonly baseURL?: string
238
+ /**
239
+ * Wire protocol the stored profile names (e.g. `openai-responses`), when it
240
+ * names one. Load-only: the editor never writes it, but endpoint discovery
241
+ * passes it so the listing speaks the same protocol as real requests.
242
+ */
243
+ readonly api?: string
214
244
  readonly models: readonly ProviderModelSettings[]
215
245
  }
216
246
 
247
+ /** One model an endpoint reported about itself (mirrors `LlmDiscoveredModel`). */
248
+ export interface DiscoveredModelView {
249
+ /** Model id the endpoint accepts. */
250
+ readonly id: string
251
+ /** Human-readable name when the endpoint supplies one. */
252
+ readonly name?: string
253
+ /** Context window when disclosed; adoption still owes it if absent. */
254
+ readonly contextWindow?: number
255
+ /** Output cap when disclosed. */
256
+ readonly maxTokens?: number
257
+ }
258
+
259
+ /** One model an endpoint reported about itself (mirrors LlmDiscoveredModel). */
260
+ export interface DiscoveredModelView {
261
+ /** Model id the endpoint accepts. */
262
+ readonly id: string
263
+ /** Human-readable name when the endpoint supplies one. */
264
+ readonly name?: string
265
+ /** Context window when disclosed; adoption still owes it if absent. */
266
+ readonly contextWindow?: number
267
+ /** Output cap when disclosed. */
268
+ readonly maxTokens?: number
269
+ }
270
+
271
+ /**
272
+ * The seven canonical reasoning levels a reasoningEfforts key may name -
273
+ * pi-ai's THINKING_LEVELS. A pi-ai upgrade that adds or removes one fails
274
+ * upstream's own drift gate; this mirror exists so the terminal editor can
275
+ * validate drafts without importing the pi-ai package.
276
+ */
277
+ export const REASONING_EFFORT_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
278
+
279
+ /**
280
+ * One stored reasoningEfforts declaration: a display-level to wire-value map
281
+ * (null sends no reasoning parameter), an explicit false disabling the
282
+ * picker, or undefined leaving the entry to inherit.
283
+ */
284
+ export type ReasoningEffortsValue = Record<string, string | null> | false | undefined
285
+
286
+ /** Whether a raw extras value is a declared efforts dict (non-empty, non-false). */
287
+ export function isDeclaredReasoningEfforts(value: unknown): value is Record<string, string | null> {
288
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0
289
+ }
290
+
291
+ /**
292
+ * Parse the setup page's compact efforts draft into a storable declaration.
293
+ * Grammar: empty = clear back to inherit; the single token "false" = disable
294
+ * the picker; otherwise space-separated level:wire pairs where level is one
295
+ * of REASONING_EFFORT_LEVELS and wire is any non-empty string or the literal
296
+ * "null" (send no parameter).
297
+ */
298
+ export function parseReasoningEffortsDraft(draft: string):
299
+ | { readonly ok: true; readonly value: ReasoningEffortsValue }
300
+ | { readonly ok: false; readonly error: string } {
301
+ const text = draft.trim()
302
+ if (text === '') return { ok: true, value: undefined }
303
+ if (text === 'false') return { ok: true, value: false }
304
+ const value: Record<string, string | null> = {}
305
+ for (const token of text.split(/\s+/u)) {
306
+ const split = token.indexOf(':')
307
+ if (split <= 0 || split === token.length - 1) {
308
+ return { ok: false, error: 'each entry needs level:wire, got "' + token + '"' }
309
+ }
310
+ const level = token.slice(0, split)
311
+ const wire = token.slice(split + 1)
312
+ if (!(REASONING_EFFORT_LEVELS as readonly string[]).includes(level)) {
313
+ return { ok: false, error: '"' + level + '" is not a level; use one of ' + REASONING_EFFORT_LEVELS.join('/') }
314
+ }
315
+ if (level in value) {
316
+ return { ok: false, error: 'level "' + level + '" appears twice' }
317
+ }
318
+ value[level] = wire === 'null' ? null : wire
319
+ }
320
+ return { ok: true, value }
321
+ }
322
+
323
+ /** Serialize a stored declaration back to the compact draft form (stored key order preserved). */
324
+ export function serializeReasoningEfforts(value: unknown): string {
325
+ if (value === false) return 'false'
326
+ if (!isDeclaredReasoningEfforts(value)) return ''
327
+ return Object.entries(value)
328
+ .map(([level, wire]) => level + ':' + (wire === null ? 'null' : String(wire)))
329
+ .join(' ')
330
+ }
331
+
217
332
  /**
218
333
  * One provider row in the TUI provider-management panel: the configurable
219
334
  * directory entry joined with its settings profile and credential facts.
@@ -551,6 +666,95 @@ export async function saveProviderConfiguration(
551
666
  }
552
667
  }
553
668
 
669
+ /**
670
+ * Interrogate a provider endpoint for the models it really serves, through
671
+ * the model-discovery capability the provider's settings namespace
672
+ * registered — the same pipe the official Web Models page uses. The request
673
+ * is a draft: a typed key forces direct endpoint interrogation (gateway
674
+ * truth), while an empty key lets the harness resolve the route's stored
675
+ * credential; with neither baseURL nor route the adapter answers from its
676
+ * own knowledge.
677
+ * @param ctx - context carrying the `llm` service (optional discovery).
678
+ * @param target - provider row whose settings namespace serves the draft.
679
+ * @param request - typed key and/or endpoint override for this one probe.
680
+ * @param signal - caller cancellation (panel navigation aborts the probe).
681
+ * @returns the advertised models in endpoint order, deduplicated.
682
+ */
683
+ export async function discoverProviderModels(
684
+ ctx: Context,
685
+ target: ProviderTargetView,
686
+ request: { readonly apiKey?: string; readonly baseURL?: string },
687
+ signal?: AbortSignal,
688
+ ): Promise<readonly DiscoveredModelView[]> {
689
+ if (target.settingsNs.length === 0) {
690
+ throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings namespace; its models cannot be discovered here`)
691
+ }
692
+ const llm = ctx.get('llm') as LlmFace | undefined
693
+ if (llm?.discoverModels === undefined) {
694
+ throw new ProviderSettingsError('model discovery is unavailable in this profile; enter models by hand')
695
+ }
696
+ const typedKey = request.apiKey?.trim()
697
+ const baseURL = request.baseURL?.trim()
698
+ const hasUrl = baseURL !== undefined && baseURL !== ''
699
+ let oneShotKey = typedKey
700
+ // A filled endpoint means the user wants THAT endpoint's real list: sending
701
+ // the route id alongside would make the adapter short-circuit to its
702
+ // installed catalog (official providers) and ignore the URL entirely. The
703
+ // probe therefore goes out as a draft — with the typed key, or with the
704
+ // stored credential resolved once for this request (never displayed,
705
+ // never persisted; exactly what provider-mode resolution does internally).
706
+ if (hasUrl && (oneShotKey === undefined || oneShotKey === '')) {
707
+ const credentialsService = ctx.get('credentials') as CredentialsFace | undefined
708
+ const ref = target.credentialRef ?? target.suggestedRef
709
+ if (credentialsService?.resolve !== undefined && ref !== undefined) {
710
+ try {
711
+ // Call resolve AS A METHOD on the service: destructured off it the
712
+ // call loses `this` and throws on the provider's first field read
713
+ // (the same lesson loadProviderSettings documents for listProviders).
714
+ const resolved = await credentialsService.resolve(ref)
715
+ oneShotKey = resolved?.value
716
+ } catch {
717
+ // A failed resolution degrades to an unauthenticated probe; the
718
+ // endpoint's own 401 names the problem better than we can.
719
+ }
720
+ }
721
+ }
722
+ const draft = hasUrl
723
+ ? {
724
+ ...(oneShotKey !== undefined && oneShotKey !== '' ? { apiKey: oneShotKey } : {}),
725
+ baseURL: baseURL!,
726
+ ...target.configuration.api === undefined ? {} : { api: target.configuration.api },
727
+ }
728
+ : {
729
+ // No endpoint override: the route's own knowledge answers (the official
730
+ // catalog for builtin providers — richer than any listing).
731
+ provider: target.provider,
732
+ }
733
+ try {
734
+ const discovered = signal === undefined
735
+ ? await llm.discoverModels(target.settingsNs, draft)
736
+ : await llm.discoverModels(target.settingsNs, draft, signal)
737
+ // Defensive dedupe in endpoint order (the service dedupes too; an older
738
+ // one must not leak duplicate rows into the checkable list).
739
+ const seen = new Set<string>()
740
+ const rows: DiscoveredModelView[] = []
741
+ for (const model of discovered) {
742
+ if (typeof model.id !== 'string' || model.id.trim() === '' || seen.has(model.id)) continue
743
+ seen.add(model.id)
744
+ rows.push({
745
+ id: model.id,
746
+ ...model.name === undefined ? {} : { name: model.name },
747
+ ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
748
+ ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
749
+ })
750
+ }
751
+ return rows
752
+ } catch (error) {
753
+ if (signal?.aborted === true) throw error
754
+ throw new ProviderSettingsError(singleLine(messageOf(error)))
755
+ }
756
+ }
757
+
554
758
  /**
555
759
  * Remove the currently named credential without touching the provider
556
760
  * profile. Only the resolved profile's own reference is unset; a dormant or
package/src/questions.ts CHANGED
@@ -80,6 +80,26 @@ export function mountQuestionProvider(ctx: Context): QuestionStore {
80
80
  }
81
81
 
82
82
  if (service !== undefined) {
83
+ // Capability guard against contract drift: the pinned release exposes
84
+ // registerProvider, but an upstream alignment may replace it with the
85
+ // 'user-questions/request' waterfall. Degrade to the permanently-empty
86
+ // store (the no-service path) instead of failing startup with a
87
+ // TypeError on a missing method.
88
+ if (typeof (service as unknown as { registerProvider?: unknown }).registerProvider !== 'function') {
89
+ return {
90
+ subscribe(listener: () => void): () => void {
91
+ listeners.add(listener)
92
+ return () => {
93
+ listeners.delete(listener)
94
+ }
95
+ },
96
+ getSnapshot(): QuestionSnapshot {
97
+ return snapshot
98
+ },
99
+ submit(): void {},
100
+ cancel(): void {},
101
+ }
102
+ }
83
103
  service.registerProvider({
84
104
  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
85
105
  return new Promise((resolve, reject) => {
@@ -3,6 +3,7 @@
3
3
  import { promptDisplayText, type TranscriptEntry } from './projection.ts'
4
4
  import type { ToolDetail } from './tool-detail.ts'
5
5
  import { renderMarkdown, visibleColumns, type MdStyle } from './markdown.ts'
6
+ import { graphemeWidth, splitGraphemes } from './width.ts'
6
7
  import { formatTokens } from './status.ts'
7
8
  import { displayText, truncateColumns } from './text.ts'
8
9
 
@@ -53,14 +54,17 @@ export function styledLines(segments: readonly StyledSegment[], columns: number)
53
54
 
54
55
  for (const segment of segments) {
55
56
  const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
56
- for (const char of safe) {
57
- if (char === '\n') {
57
+ // Grapheme clusters, never bare code points: a ZWJ family or a flag is
58
+ // one terminal cell run, and splitting it would both split the glyph
59
+ // across rows and double-count its width against the budget.
60
+ for (const cluster of splitGraphemes(safe)) {
61
+ if (cluster === '\n') {
58
62
  flush()
59
63
  continue
60
64
  }
61
- const cells = visibleColumns(char)
65
+ const cells = graphemeWidth(cluster)
62
66
  if (used > 0 && used + cells > width) flush()
63
- appendSegment(current, char, segment.style)
67
+ appendSegment(current, cluster, segment.style)
64
68
  used += cells
65
69
  }
66
70
  }
@@ -76,7 +80,10 @@ export function textLines(text: string, columns: number, style: LineStyle = 'pla
76
80
  /** Prefix every wrapped physical row without exceeding the column budget. */
77
81
  function prefixedStyledLines(segments: readonly StyledSegment[], columns: number, prefix: string, prefixStyle: LineStyle = 'plain'): readonly StyledLine[] {
78
82
  const width = Math.max(1, Math.floor(columns))
79
- const prefixWidth = Math.min(width, visibleColumns(prefix))
83
+ // Keep one column for the body even when the prefix alone would fill the
84
+ // row: a prefix allowed to claim the whole width pushed prefix+body one
85
+ // column past the budget on very narrow terminals.
86
+ const prefixWidth = Math.min(width - 1, visibleColumns(prefix))
80
87
  const bodyWidth = Math.max(1, width - prefixWidth)
81
88
  return styledLines(segments, bodyWidth).map(line => ({
82
89
  segments: [lineSegment(prefix, prefixStyle), ...line.segments],
@@ -120,14 +127,14 @@ function hangingStyledLines(
120
127
  }
121
128
  for (const segment of segments) {
122
129
  const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
123
- for (const char of safe) {
124
- if (char === '\n') {
130
+ for (const cluster of splitGraphemes(safe)) {
131
+ if (cluster === '\n') {
125
132
  flush()
126
133
  continue
127
134
  }
128
- const cells = visibleColumns(char)
135
+ const cells = graphemeWidth(cluster)
129
136
  if (used > 0 && used + cells > budget) flush()
130
- appendSegment(current, char, segment.style)
137
+ appendSegment(current, cluster, segment.style)
131
138
  used += cells
132
139
  }
133
140
  }
@@ -155,7 +162,10 @@ function hangingTextLines(
155
162
  /** Markdown rows re-hardened so a single long word cannot escape the budget. */
156
163
  export function markdownLines(text: string, columns: number): readonly StyledLine[] {
157
164
  const width = Math.max(1, Math.floor(columns))
158
- const parsed = renderMarkdown(displayText(text), Math.max(10, width))
165
+ // The markdown pass formats at the real width — a 10-column floor on a
166
+ // narrower terminal silently pushed rows past the budget (styledLines
167
+ // re-hardens long words at `width` either way).
168
+ const parsed = renderMarkdown(displayText(text), width)
159
169
  return parsed.flatMap(line => styledLines(
160
170
  line.segments.map(segment => lineSegment(segment.text, segment.style)),
161
171
  width,
@@ -280,7 +290,7 @@ export function transcriptEntryLines(
280
290
  // Every reply row carries the composer's two-column gutter, so reply
281
291
  // text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
282
292
  // wrap budget shrinks by the same amount so no line double-wraps.
283
- const body = markdownLines(entry.text, Math.max(10, width - 2))
293
+ const body = markdownLines(entry.text, Math.max(1, width - 2))
284
294
  .map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
285
295
  // A cancelled stream's delivered prefix settles as this entry; one
286
296
  // bounded dim marker row distinguishes it from a completed reply.
@@ -330,7 +340,9 @@ export function transcriptEntryLines(
330
340
  : ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
331
341
  case 'retry':
332
342
  return textLines(
333
- ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
343
+ entry.mode === 'always'
344
+ ? ` ↻ retry ${entry.attempt} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`
345
+ : ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
334
346
  width,
335
347
  entry.state === 'running' ? 'warn' : 'dim',
336
348
  )
@@ -9,7 +9,7 @@
9
9
  * @module @deepseek-ai/dsh-code/render/markdown
10
10
  */
11
11
 
12
- import { stringWidth } from './width.ts'
12
+ import { graphemeWidth, splitGraphemes, stringWidth } from './width.ts'
13
13
 
14
14
  /** Style classes the renderer emits; the app maps them to colors/props. */
15
15
  export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'accentBold' | 'dim' | 'strike'
@@ -60,15 +60,17 @@ function wrapUnits(segments: readonly MdSegment[]): readonly WrapUnit[] {
60
60
  if (word !== '') units.push({ text: word, style: segment.style })
61
61
  word = ''
62
62
  }
63
- for (const char of segment.text) {
64
- if (char === ' ') {
63
+ // Grapheme clusters keep ZWJ families, flags, and combining sequences
64
+ // whole; a cluster wider than one cell is its own break opportunity.
65
+ for (const cluster of splitGraphemes(segment.text)) {
66
+ if (cluster === ' ') {
65
67
  flushWord()
66
- units.push({ text: char, style: segment.style })
67
- } else if (visibleColumns(char) > 1) {
68
+ units.push({ text: cluster, style: segment.style })
69
+ } else if (graphemeWidth(cluster) > 1) {
68
70
  flushWord()
69
- units.push({ text: char, style: segment.style })
71
+ units.push({ text: cluster, style: segment.style })
70
72
  } else {
71
- word += char
73
+ word += cluster
72
74
  }
73
75
  }
74
76
  flushWord()
@@ -99,7 +101,7 @@ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (
99
101
  const append = (unit: WrapUnit): void => {
100
102
  const columns = visibleColumns(unit.text)
101
103
  if (used === 0 && columns > limit) {
102
- for (const char of unit.text) appendAtom({ text: char, style: unit.style })
104
+ for (const cluster of splitGraphemes(unit.text)) appendAtom({ text: cluster, style: unit.style })
103
105
  return
104
106
  }
105
107
  if (used + columns <= limit || current.length === 0) {
@@ -121,7 +123,7 @@ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (
121
123
  }
122
124
  flush()
123
125
  if (columns > limit) {
124
- for (const char of unit.text) appendAtom({ text: char, style: unit.style })
126
+ for (const cluster of splitGraphemes(unit.text)) appendAtom({ text: cluster, style: unit.style })
125
127
  } else {
126
128
  appendAtom(unit)
127
129
  }
@@ -336,12 +338,12 @@ function hardWrapSegments(segments: readonly MdSegment[], width: number): readon
336
338
  used = 0
337
339
  }
338
340
  for (const segment of segments) {
339
- for (const char of segment.text) {
340
- const cells = visibleColumns(char)
341
+ for (const cluster of splitGraphemes(segment.text)) {
342
+ const cells = graphemeWidth(cluster)
341
343
  if (used > 0 && used + cells > width) flush()
342
344
  const previous = current.at(-1)
343
- if (previous?.style === segment.style) previous.text += char
344
- else current.push({ text: char, style: segment.style })
345
+ if (previous?.style === segment.style) previous.text += cluster
346
+ else current.push({ text: cluster, style: segment.style })
345
347
  used += cells
346
348
  }
347
349
  }
@@ -159,6 +159,8 @@ export interface RetryEntry {
159
159
  kind: 'retry'
160
160
  /** Correlation id shared with the matching `llm/retry-started`. */
161
161
  retryId: string
162
+ /** Retry policy mode from the event: `always` has no attempt cap. */
163
+ mode: 'normal' | 'always'
162
164
  /** Attempt ordinal and its cap. */
163
165
  attempt: number
164
166
  max: number
@@ -166,7 +168,11 @@ export interface RetryEntry {
166
168
  code: string
167
169
  /** Backoff wait before the next attempt, in ms. */
168
170
  delayMs: number
169
- /** `running` while the backoff waits, `done` once the attempt started. */
171
+ /**
172
+ * `running` while the backoff waits, `done` once the attempt started — or
173
+ * when the turn ended first (the turn-end sweep finalizes orphans so they
174
+ * never pin the settled boundary).
175
+ */
170
176
  state: 'running' | 'done'
171
177
  }
172
178
 
@@ -328,6 +334,25 @@ function textOf(content: readonly ContentBlock[]): string {
328
334
  return content.filter(block => block.type === 'text').map(block => block.text).join('')
329
335
  }
330
336
 
337
+ /**
338
+ * Snapshot-isolate one anchors block (Maps and their nested Sets): a view
339
+ * already handed to the renderer must never observe a later fold through a
340
+ * shared container. The collections are small and turn-bounded, so cloning
341
+ * per event is cheap next to the entries copy the reducer already makes.
342
+ */
343
+ function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
344
+ return {
345
+ stepStart: new Map(anchors.stepStart),
346
+ toolStart: new Map(anchors.toolStart),
347
+ firstChunkAt: new Map(anchors.firstChunkAt),
348
+ compactionTokens: new Map(anchors.compactionTokens),
349
+ lastPruneTokens: anchors.lastPruneTokens,
350
+ turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
351
+ turnSteps: new Map(anchors.turnSteps),
352
+ turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
353
+ }
354
+ }
355
+
331
356
  /** Durable image references in their model-visible order. */
332
357
  function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
333
358
  return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
@@ -410,6 +435,10 @@ function pendingText(content: readonly ContentBlock[]): string {
410
435
  * @returns the view after the event; the input view is never mutated.
411
436
  */
412
437
  export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
438
+ // Fold against a private anchors block so the documented contract holds —
439
+ // "the input view is never mutated" — even for the in-place anchor sweeps
440
+ // below; without this, every handed-out view shared live Maps.
441
+ view = { ...view, anchors: cloneViewAnchors(view.anchors) }
413
442
  switch (event.type) {
414
443
  case 'user/message': {
415
444
  // A queued row retires when its durable user message lands (the agent
@@ -467,10 +496,16 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
467
496
  const { target, start, removedCount = 0, inserted } = event.data
468
497
  const ids = view.pending[target]
469
498
  const removed = ids.slice(start, start + removedCount)
499
+ // In-place upstream semantics: the kernel's authoritative fold is
500
+ // `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
501
+ // AT the splice position (prepend/replace shapes), never at the tail.
502
+ // A tail append diverged the id order, so later coordinate-based events
503
+ // (next-turn head claims, positioned remove/replace) tombstoned the
504
+ // wrong pending row.
470
505
  const nextIds = [
471
506
  ...ids.slice(0, start),
472
- ...ids.slice(start + removedCount),
473
507
  ...inserted.map(message => message.id),
508
+ ...ids.slice(start + removedCount),
474
509
  ]
475
510
  let entries = view.entries
476
511
  if (removed.length > 0) {
@@ -714,8 +749,26 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
714
749
  for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
715
750
  view.anchors.turnTools.delete(event.data.turn)
716
751
  }
752
+ // Orphaned retry/command rows can never be resolved after the turn
753
+ // ends: an aborted retry backoff returns upstream without its
754
+ // `llm/retry-started`, and crash repair synthesizes only tool/step/
755
+ // turn closers. Left `running` they pin the settled boundary forever,
756
+ // so the turn end finalizes them exactly like the anchor sweep above.
757
+ let orphans = false
758
+ const swept = view.entries.map((entry) => {
759
+ if (entry.kind === 'retry' && entry.state === 'running') {
760
+ orphans = true
761
+ return { ...entry, state: 'done' as const }
762
+ }
763
+ if (entry.kind === 'command' && entry.state === 'running') {
764
+ orphans = true
765
+ return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
766
+ }
767
+ return entry
768
+ })
769
+ const entries = orphans ? swept : view.entries
717
770
  if (appended.length === 0) {
718
- return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '' }
771
+ return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
719
772
  }
720
773
  return {
721
774
  ...view,
@@ -723,7 +776,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
723
776
  busySince: 0,
724
777
  streaming: '',
725
778
  streamingReasoning: '',
726
- entries: [...view.entries, ...appended],
779
+ entries: [...entries, ...appended],
727
780
  }
728
781
  }
729
782
  case 'llm/retry': {
@@ -735,6 +788,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
735
788
  entries: [...view.entries, {
736
789
  kind: 'retry',
737
790
  retryId: data.retryId,
791
+ mode: data.mode,
738
792
  attempt: data.retry,
739
793
  max: 'maxRetries' in data ? data.maxRetries : data.retry,
740
794
  code: data.failure.code,
@@ -995,6 +1049,31 @@ function indexList(map: Map<string, number[]>, id: string): number[] {
995
1049
  return list
996
1050
  }
997
1051
 
1052
+ /**
1053
+ * Finalize replay rows the ended turn left `running`, mirroring the reducer's
1054
+ * turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
1055
+ * settles `error` with an interruption note. Only the id-indexed rows are
1056
+ * visited, so the sweep stays O(retries+commands of the log), never a scan.
1057
+ */
1058
+ function finalizeReplayOrphans(acc: ReplayAccumulator): void {
1059
+ for (const list of acc.retryIndex.values()) {
1060
+ for (const index of list) {
1061
+ const entry = acc.entries[index]
1062
+ if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
1063
+ acc.entries[index] = { ...entry, state: 'done' }
1064
+ }
1065
+ }
1066
+ }
1067
+ for (const list of acc.commandIndex.values()) {
1068
+ for (const index of list) {
1069
+ const entry = acc.entries[index]
1070
+ if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
1071
+ acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+
998
1077
  /**
999
1078
  * Apply an id-keyed update to every row that registered the id, mirroring the
1000
1079
  * copy-on-write reducer's full-array map semantics (all matching rows update,
@@ -1103,11 +1182,15 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1103
1182
  }
1104
1183
  }
1105
1184
  }
1185
+ // Mirror the reducer's in-place order: inserted ids join at the splice
1186
+ // position (upstream `splice(start, removedCount, ...inserted)`), never
1187
+ // at the tail — the id list must stay coordinate-compatible with every
1188
+ // later inbox event.
1189
+ ids.splice(start, 0, ...inserted.map(message => message.id))
1106
1190
  for (const message of inserted) {
1107
1191
  const images = imagesOf(message.content)
1108
1192
  appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }) })
1109
1193
  indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1110
- ids.push(message.id)
1111
1194
  acc.ops += 1
1112
1195
  }
1113
1196
  return true
@@ -1298,6 +1381,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1298
1381
  for (const callId of turnToolSet) acc.toolStart.delete(callId)
1299
1382
  acc.turnTools.delete(event.data.turn)
1300
1383
  }
1384
+ finalizeReplayOrphans(acc)
1301
1385
  acc.busy = false
1302
1386
  acc.busySince = 0
1303
1387
  for (const entry of appended) appendReplayEntry(acc, entry)
@@ -1310,6 +1394,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1310
1394
  appendReplayEntry(acc, {
1311
1395
  kind: 'retry',
1312
1396
  retryId: data.retryId,
1397
+ mode: data.mode,
1313
1398
  attempt: data.retry,
1314
1399
  max: 'maxRetries' in data ? data.maxRetries : data.retry,
1315
1400
  code: data.failure.code,
@@ -1474,15 +1559,17 @@ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): Transcrip
1474
1559
  goal: acc.goal,
1475
1560
  pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1476
1561
  stats: acc.stats,
1562
+ // Handed-out views get their own anchors snapshot: the accumulator keeps
1563
+ // folding its live containers, and no consumer may observe that.
1477
1564
  anchors: {
1478
- stepStart: acc.stepStart,
1479
- toolStart: acc.toolStart,
1480
- firstChunkAt: acc.firstChunkAt,
1481
- compactionTokens: acc.compactionTokens,
1565
+ stepStart: new Map(acc.stepStart),
1566
+ toolStart: new Map(acc.toolStart),
1567
+ firstChunkAt: new Map(acc.firstChunkAt),
1568
+ compactionTokens: new Map(acc.compactionTokens),
1482
1569
  lastPruneTokens: acc.lastPruneTokens,
1483
- turnFiles: acc.turnFiles,
1484
- turnSteps: acc.turnSteps,
1485
- turnTools: acc.turnTools,
1570
+ turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
1571
+ turnSteps: new Map(acc.turnSteps),
1572
+ turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
1486
1573
  },
1487
1574
  }
1488
1575
  }