mixdog 0.9.123 → 0.9.124

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.123",
3
+ "version": "0.9.124",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -63,7 +63,7 @@
63
63
  "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/release-gate-test.mjs && node --test scripts/release-gate-test.mjs",
64
64
  "test:release-focused": "npm run test:release-critical && npm run test:compact && npm run test:shellhardening && npm run test:tool-contracts && npm run test:session && npm run test:session-transport",
65
65
  "test:release-critical": "npm run test:release-assets && npm run smoke:patch && npm run test:providers",
66
- "test:session-transport": "node --test scripts/session-transport-test.mjs",
66
+ "test:session-transport": "node --test scripts/session-transport-test.mjs scripts/daemon-bootstrap-test.mjs",
67
67
  "test:session": "node --test scripts/runtime-turn-contract-test.mjs scripts/session-save-fault-store-test.mjs",
68
68
  "test:media": "node --test src/runtime/media/store.test.mjs src/runtime/media/renditions.test.mjs src/runtime/media/adapters/codex-image.test.mjs",
69
69
  "failures": "node scripts/tool-failures.mjs",
@@ -10,7 +10,5 @@
10
10
  - Ask only for decisions.
11
11
  - Build only the requested scope; trust internal and framework guarantees.
12
12
  - Mid-task: replacement supersedes; addition folds in; status gets a brief
13
- answer while work continues. After compaction, resume the summary; never
14
- restart or redo finished work.
15
- - Final text ends the turn only when done. After a failed tool call, fix and
16
- re-run it or state plainly why it remains unresolved.
13
+ answer while work continues. After compaction, resume the summary.
14
+ - Final text ends the turn only when done.
@@ -21,20 +21,27 @@
21
21
  - Plan the fewest dependent rounds, then the fewest calls. Known state —
22
22
  anything the task supplied, a tool returned (applied patches and envelope
23
23
  hints included), or a check already proved — is never re-found,
24
- re-derived, or re-verified; a change to its subject re-opens it. Batch
25
- calls iff none needs another's output or can change another's
26
- inputs/state; otherwise serialize. Before each batch, deduplicate the
27
- facets still required by the request, route each once to the cheapest
24
+ re-derived, or re-verified; a change re-opens only affected evidence and
25
+ postconditions. Batch calls iff none needs another's output or can change
26
+ another's inputs/state; otherwise serialize. Before each batch, deduplicate
27
+ the facets still required by the request, route each once to the cheapest
28
28
  sufficient tool with all required variants/scopes, and launch every
29
- independent call together never split or duplicate a facet across
30
- tools, mutate merely to widen retrieval, reserve known work, or cap
31
- fanout. Guesses go wide in one batch, scopes narrow only on verified
32
- cues returned siblings/conventions or known literalsand returned
33
- output is fully mined before the next round. Symbol relations end at
29
+ independent call, diagnostic, experiment variant, and validation case
30
+ together in one batch or script never split or duplicate a facet across
31
+ tools, mutate merely to widen retrieval, reserve known work, or cap fanout.
32
+ Guesses go wide in one batch, scopes narrow only on verified cues — returned
33
+ siblings/conventions or known literals and returned output is fully mined
34
+ before the next round. Symbol relations end at
34
35
  `code_graph`; values/locations end at the context grep returns; `read`
35
36
  covers only what returned spans cannot, as an anchored offset/limit
36
37
  window. A conclusive result ends its facet; evidence that determines the
37
38
  answer, edit, or deliverable ends retrieval — patch if needed.
39
+ - Converge before opening another round: collect all known failures, make one
40
+ cohesive correction, then one integrated verification. Passed postconditions
41
+ stay closed unless affected. Rerun a failed check or command only after a
42
+ change that can alter its outcome; start a new experiment or dependent round
43
+ only from evidence produced by the prior result. Else switch route or report
44
+ it unresolved.
38
45
  - If inspection can change evidence or durable state, use read-only means;
39
46
  mutate only when the deliverable requires it, first preserving evidence
40
47
  at risk. Never mutate merely to clear an obstacle or unexpected state;
@@ -52,9 +59,7 @@
52
59
  echoing a claim; changes made through `shell` verify under the same
53
60
  one-batch contract — one script proving every postcondition, value-level
54
61
  included, never one check per round; a postcondition that did not
55
- actually run is unresolved, not passed. Retry only failed envelopes;
56
- rerun a failed check only after a change that can alter its outcome —
57
- commands alike; else switch route or report it unresolved.
62
+ actually run is unresolved, not passed. Retry only failed envelopes.
58
63
  Hand-authored text is edited only with `apply_patch`; computed artifacts
59
64
  (data/reports/derived values) come from `shell` computation, never
60
65
  hand-transcribed numbers. Earlier `shell` is only for runtime/state
@@ -327,6 +327,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
327
327
  const providerMetadata = resp.providerMetadata && typeof resp.providerMetadata === 'object'
328
328
  ? resp.providerMetadata
329
329
  : null;
330
+ const stopReason = resp.stopReason ?? resp.stop_reason ?? null;
331
+ const terminationReason = resp.terminationReason ?? null;
330
332
  // Anthropic native server-tool turns (web search / code execution /
331
333
  // native MCP) carry `server_tool_use` + `*_tool_result` blocks that
332
334
  // exist ONLY in this ordered verbatim list — they cannot be rebuilt
@@ -358,6 +360,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
358
360
  ...(reasoningItems ? { reasoningItems } : {}),
359
361
  ...(reasoningContent ? { reasoningContent } : {}),
360
362
  ...(providerMetadata ? { providerMetadata } : {}),
363
+ ...(stopReason ? { stopReason } : {}),
364
+ ...(terminationReason ? { terminationReason } : {}),
361
365
  }, opts);
362
366
  messages.push(message);
363
367
  try { opts.onAssistantMessageCommitted?.(message); } catch {}
@@ -832,12 +836,22 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
832
836
  };
833
837
  break;
834
838
  }
835
- if (!hasContent && stopReason === 'refusal') {
839
+ if (stopReason === 'refusal') {
836
840
  if (_refusalRetryUsed) {
837
841
  process.stderr.write(`[loop] safety-classifier refusal persisted after one context-changing retry (sess=${sessionId || 'unknown'}); ending loop as refusal termination.\n`);
838
842
  break;
839
843
  }
840
844
  _refusalRetryUsed = true;
845
+ // A provider may emit harmless narration before its safety
846
+ // classifier terminates the turn. Preserve that partial turn
847
+ // and its stop reason, but never mistake the non-empty text for
848
+ // a successful completion.
849
+ if (hasContent && pushIntermediateAssistantResponse(response)) {
850
+ if (!suppressMidTurnText) {
851
+ _committedTextParts.push(response.content);
852
+ try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
853
+ }
854
+ }
841
855
  messages.push({
842
856
  role: 'user',
843
857
  content: '[mixdog-runtime] The previous completion was refused by the provider safety classifier (stopReason=refusal). Do not repeat it. Complete your assigned output within policy by omitting or reframing disallowed content; if no compliant output is possible, briefly state the refusal.',
@@ -807,6 +807,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
807
807
  const persistedAssistantContent = typeof result.historyContent === 'string'
808
808
  ? result.historyContent
809
809
  : (result.content || '');
810
+ const _terminalStop = result?.stopReason ?? result?.stop_reason ?? null;
810
811
  session.messages.push({
811
812
  role: 'assistant',
812
813
  // Keep content as-is in memory (model-visible). Image bytes,
@@ -820,6 +821,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
820
821
  ...(result.providerMetadata && typeof result.providerMetadata === 'object'
821
822
  ? { providerMetadata: result.providerMetadata }
822
823
  : {}),
824
+ // Keep terminal provider evidence for non-empty turns too.
825
+ // A safety classifier can emit narration and then refuse;
826
+ // omitting this metadata made that shape indistinguishable
827
+ // from an ordinary successful final response.
828
+ ...(_terminalStop ? { stopReason: _terminalStop } : {}),
829
+ ...(result?.terminationReason ? { terminationReason: result.terminationReason } : {}),
830
+ iterations: result?.iterations ?? null,
831
+ toolCallsTotal: result?.toolCallsTotal ?? null,
823
832
  });
824
833
  } else {
825
834
  // Empty terminal turn: still persist a forensic record so
@@ -66,6 +66,7 @@ import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
66
66
  import { computeEntryScore } from './lib/memory-score.mjs'
67
67
  import { runFullBackfill } from './lib/memory-ops-policy.mjs'
68
68
  import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandidates, promoteCoreCandidate, dismissCoreCandidate } from './lib/core-memory-store.mjs'
69
+ import { refreshCoreMemoryFile } from './lib/core-memory-file.mjs'
69
70
  import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
70
71
  import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
71
72
  import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
@@ -375,6 +376,17 @@ async function setCycleLastRun(kind, ts) {
375
376
  await mergeMetaValue(db, CYCLE_LAST_RUN_KEY, { [kind]: ts })
376
377
  }
377
378
 
379
+ async function refreshCoreMemorySnapshot(reason = 'mutation') {
380
+ try {
381
+ const result = await refreshCoreMemoryFile(db, DATA_DIR)
382
+ memoryProfile('core-memory:file-refreshed', { reason, revision: result.revision, written: result.written })
383
+ return result
384
+ } catch (error) {
385
+ __mixdogMemoryLog(`[core-memory] file refresh failed (${reason}): ${error?.message || error}\n`)
386
+ return null
387
+ }
388
+ }
389
+
378
390
 
379
391
  // ── Cycle scheduling cluster (extracted to lib/cycle-scheduler.mjs) ────────
380
392
  // The mutually-referential cycle machinery (health ledger, cycle1 outer
@@ -413,6 +425,7 @@ const _cycleScheduler = createCycleScheduler({
413
425
  scheduledCycle2Signature,
414
426
  scheduledCycle3Signature,
415
427
  cycleStateFile: CYCLE_STATE_FILE,
428
+ onCoreMemoryChanged: refreshCoreMemorySnapshot,
416
429
  })
417
430
  // Cycle1 run primitives + cycle2 finalize used by MCP action handlers below.
418
431
  const _startCycle1Run = _cycleScheduler.startCycle1Run
@@ -444,6 +457,10 @@ async function _initRuntime() {
444
457
  const compactStartedAt = performance.now()
445
458
  await compactCoreIds(DATA_DIR)
446
459
  memoryProfile('core-ids:compact:done', { ms: (performance.now() - compactStartedAt).toFixed(1) })
460
+ // First boot migrates existing PG core data; later boots reconcile any
461
+ // crash window between a committed PG mutation and its atomic file refresh.
462
+ // The snapshot is not part of memory readiness.
463
+ void refreshCoreMemorySnapshot('boot-migration')
447
464
  // Memory module is always-on: the transcript watcher/ingest runs
448
465
  // unconditionally except in secondary mode (secondary attaches to a primary's
449
466
  // PG and must not double-ingest). The recap toggle only gates whether the
@@ -517,6 +534,7 @@ const _actionHandlers = createMemoryActionHandlers({
517
534
  finalizeCycle2Run: _finalizeCycle2Run,
518
535
  finalizeCycle3Run: _finalizeCycle3Run,
519
536
  requestCycle3Review: _cycleScheduler.requestCycle3Review,
537
+ refreshCoreMemoryFile: refreshCoreMemorySnapshot,
520
538
  getSchedulerCycle1InFlight: () => _cycleScheduler.getCycle1InFlight(),
521
539
  getCycle2CallLlm,
522
540
  getCycle3CallLlm,
@@ -0,0 +1,161 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { updateJsonAtomic } from '../../shared/atomic-file.mjs'
4
+ import { resolveProjectScope } from './project-id-resolver.mjs'
5
+
6
+ export const CORE_MEMORY_FILE_VERSION = 1
7
+ export const CORE_MEMORY_FILE_NAME = 'core-memory.json'
8
+
9
+ const reservedRevisions = new Map()
10
+
11
+ function filePath(dataDir) {
12
+ return join(dataDir, CORE_MEMORY_FILE_NAME)
13
+ }
14
+
15
+ function finiteNumber(value, fallback = 0) {
16
+ const number = Number(value)
17
+ return Number.isFinite(number) ? number : fallback
18
+ }
19
+
20
+ function normalizeProjectId(value) {
21
+ if (value == null) return null
22
+ const text = String(value).trim()
23
+ return text || null
24
+ }
25
+
26
+ function normalizeCuratedEntry(row) {
27
+ const summary = String(row?.summary || '').replace(/\s+/g, ' ').trim()
28
+ if (!summary) return null
29
+ return {
30
+ id: finiteNumber(row?.id),
31
+ summary,
32
+ projectId: normalizeProjectId(row?.projectId ?? row?.project_id),
33
+ updatedAt: finiteNumber(row?.updatedAt ?? row?.updated_at),
34
+ }
35
+ }
36
+
37
+ function normalizeGeneratedEntry(row) {
38
+ const summary = String(row?.summary ?? row?.core_summary ?? '').replace(/\s+/g, ' ').trim()
39
+ if (!summary) return null
40
+ return {
41
+ summary,
42
+ projectId: normalizeProjectId(row?.projectId ?? row?.project_id),
43
+ score: finiteNumber(row?.score),
44
+ lastSeenAt: finiteNumber(row?.lastSeenAt ?? row?.last_seen_at),
45
+ }
46
+ }
47
+
48
+ function normalizeSnapshot(snapshot = {}) {
49
+ return {
50
+ curated: (Array.isArray(snapshot.curated) ? snapshot.curated : [])
51
+ .map(normalizeCuratedEntry)
52
+ .filter(Boolean),
53
+ generated: (Array.isArray(snapshot.generated) ? snapshot.generated : [])
54
+ .map(normalizeGeneratedEntry)
55
+ .filter(Boolean),
56
+ }
57
+ }
58
+
59
+ export function readCoreMemoryFile(dataDir) {
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(filePath(dataDir), 'utf8'))
62
+ if (parsed?.version !== CORE_MEMORY_FILE_VERSION) return null
63
+ const snapshot = normalizeSnapshot(parsed)
64
+ return {
65
+ version: CORE_MEMORY_FILE_VERSION,
66
+ revision: Math.max(0, finiteNumber(parsed.revision)),
67
+ updatedAt: Math.max(0, finiteNumber(parsed.updatedAt)),
68
+ ...snapshot,
69
+ }
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ function reserveRevision(dataDir) {
76
+ const path = filePath(dataDir)
77
+ const diskRevision = readCoreMemoryFile(dataDir)?.revision || 0
78
+ const revision = Math.max(diskRevision, reservedRevisions.get(path) || 0) + 1
79
+ reservedRevisions.set(path, revision)
80
+ return revision
81
+ }
82
+
83
+ export async function writeCoreMemoryFileSnapshot(dataDir, snapshot, { revision = reserveRevision(dataDir), now = Date.now() } = {}) {
84
+ const normalized = normalizeSnapshot(snapshot)
85
+ const requestedRevision = Math.max(1, finiteNumber(revision, 1))
86
+ const result = await updateJsonAtomic(filePath(dataDir), (current) => {
87
+ const currentRevision = Math.max(0, finiteNumber(current?.revision))
88
+ // A slower refresh may finish after a newer one. Never let its older PG
89
+ // snapshot overwrite the newer file.
90
+ if (current?.version === CORE_MEMORY_FILE_VERSION && currentRevision >= requestedRevision) {
91
+ return undefined
92
+ }
93
+ return {
94
+ version: CORE_MEMORY_FILE_VERSION,
95
+ revision: requestedRevision,
96
+ updatedAt: finiteNumber(now, Date.now()),
97
+ ...normalized,
98
+ }
99
+ }, { secret: true, compact: true })
100
+ return {
101
+ revision: Math.max(0, finiteNumber(result?.revision)),
102
+ written: finiteNumber(result?.revision) === requestedRevision,
103
+ }
104
+ }
105
+
106
+ export async function refreshCoreMemoryFile(db, dataDir) {
107
+ // Reserve before querying: if concurrent refreshes complete out of order,
108
+ // the atomic revision guard rejects the stale result.
109
+ const revision = reserveRevision(dataDir)
110
+ const [curatedResult, generatedResult] = await Promise.all([
111
+ db.query(`
112
+ SELECT id, summary, project_id, updated_at
113
+ FROM core_entries
114
+ WHERE status IS NULL OR status = 'active'
115
+ ORDER BY project_id NULLS FIRST, id ASC
116
+ `),
117
+ db.query(`
118
+ SELECT core_summary, project_id, score, last_seen_at
119
+ FROM (
120
+ SELECT core_summary, project_id, score, last_seen_at,
121
+ ROW_NUMBER() OVER (
122
+ PARTITION BY project_id
123
+ ORDER BY score DESC, last_seen_at DESC
124
+ ) AS scope_rank
125
+ FROM entries
126
+ WHERE is_root = 1
127
+ AND status = 'active'
128
+ AND core_summary IS NOT NULL
129
+ ) ranked
130
+ WHERE scope_rank <= 40
131
+ ORDER BY project_id NULLS FIRST, scope_rank ASC
132
+ `),
133
+ ])
134
+ return await writeCoreMemoryFileSnapshot(dataDir, {
135
+ curated: curatedResult?.rows || [],
136
+ generated: generatedResult?.rows || [],
137
+ }, { revision })
138
+ }
139
+
140
+ export function readSessionCoreMemoryPayload(dataDir, cwd) {
141
+ const file = readCoreMemoryFile(dataDir)
142
+ if (!file) return null
143
+ const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
144
+ const inScope = (entry) => entry.projectId === null || entry.projectId === projectId
145
+ const curated = file.curated
146
+ .filter(inScope)
147
+ .sort((a, b) => {
148
+ if (a.projectId === null && b.projectId !== null) return -1
149
+ if (a.projectId !== null && b.projectId === null) return 1
150
+ return a.id - b.id
151
+ })
152
+ const generated = file.generated
153
+ .filter(inScope)
154
+ .sort((a, b) => b.score - a.score || b.lastSeenAt - a.lastSeenAt)
155
+ return {
156
+ projectId,
157
+ revision: file.revision,
158
+ userLines: curated.map((entry) => entry.summary),
159
+ dbLines: generated.map((entry) => entry.summary),
160
+ }
161
+ }
@@ -69,6 +69,7 @@ export function createCycleScheduler(deps) {
69
69
  scheduledCycle2Signature,
70
70
  scheduledCycle3Signature,
71
71
  cycleStateFile,
72
+ onCoreMemoryChanged = async () => {},
72
73
  } = deps
73
74
 
74
75
  // ── Cycle health state ────────────────────────────────────────────────────
@@ -342,6 +343,7 @@ export function createCycleScheduler(deps) {
342
343
  if (result?.error) { markCycleDone('cycle3', false, result.error); return }
343
344
  await setCycleLastRun('cycle3', Date.now())
344
345
  markCycleDone('cycle3', true)
346
+ await onCoreMemoryChanged('cycle3')
345
347
  },
346
348
  }
347
349
  if (typeof c3Options?.callLlm !== 'function') {
@@ -382,6 +384,7 @@ export function createCycleScheduler(deps) {
382
384
  await setCycleLastRun('cycle2_last_error', '')
383
385
  log('[cycle2] completed\n')
384
386
  markCycleDone('cycle2', true)
387
+ await onCoreMemoryChanged('cycle2')
385
388
  } else {
386
389
  const err = gateFailed ? 'gate_failed' : (result.error || 'unknown error')
387
390
  await setCycleLastRun('cycle2_last_error', err)
@@ -566,6 +569,7 @@ export function createCycleScheduler(deps) {
566
569
  if (result.error) { markCycleDone('cycle3', false, result.error); return }
567
570
  await setCycleLastRun('cycle3', Date.now())
568
571
  markCycleDone('cycle3', true)
572
+ await onCoreMemoryChanged('cycle3')
569
573
  },
570
574
  requestCycle3Review,
571
575
  periodicCycle1Config,
@@ -62,6 +62,7 @@ export function createMemoryActionHandlers({
62
62
  cwdFromTranscriptPath,
63
63
  addCoreImpl = addCore,
64
64
  editCoreImpl = editCore,
65
+ refreshCoreMemoryFile = async () => {},
65
66
  }) {
66
67
  const DATA_DIR = dataDir
67
68
 
@@ -645,6 +646,7 @@ export function createMemoryActionHandlers({
645
646
  }
646
647
  if (op === 'promote') {
647
648
  const entry = await promoteCoreCandidate(coreDataDir, args.id, { ...args, scope })
649
+ await refreshCoreMemoryFile('core-promote')
648
650
  await queueCoreReview('core-promote')
649
651
  const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
650
652
  return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: ${entry.element}` }
@@ -724,16 +726,19 @@ export function createMemoryActionHandlers({
724
726
  }
725
727
  if (op === 'add') {
726
728
  const entry = await addCoreImpl(coreDataDir, args, projectId)
729
+ await refreshCoreMemoryFile('core-add')
727
730
  await queueCoreReview('core-add')
728
731
  return { text: `core added (id=${entry.id}): ${entry.element} — ${entry.summary.slice(0, 200)}` }
729
732
  }
730
733
  if (op === 'edit') {
731
734
  const entry = await editCoreImpl(coreDataDir, args.id, args)
735
+ await refreshCoreMemoryFile('core-edit')
732
736
  await queueCoreReview('core-edit')
733
737
  return { text: `core edited (id=${entry.id}): ${entry.element} — ${entry.summary.slice(0, 200)}` }
734
738
  }
735
739
  if (op === 'delete') {
736
740
  const removed = await deleteCore(coreDataDir, args.id)
741
+ await refreshCoreMemoryFile('core-delete')
737
742
  return { text: `core deleted (id=${removed.id}): ${removed.element}` }
738
743
  }
739
744
  } catch (e) {
@@ -1,5 +1,6 @@
1
1
  import { discoverPluginMcp } from './plugin-mcp.mjs';
2
2
  import { featureEnvOverride, memoryToolsEnabled } from './config-helpers.mjs';
3
+ import { readSessionCoreMemoryPayload } from '../runtime/memory/lib/core-memory-file.mjs';
3
4
 
4
5
  // cwd-plugins.mjs — cwd resolution/apply + plugins-status + core-memory context,
5
6
  // extracted from mixdog-session-runtime.mjs. Dependency-injected factory that
@@ -244,33 +245,27 @@ export function createCwdPlugins({
244
245
  bootProfile('core-memory:disabled');
245
246
  return '';
246
247
  }
247
- // Explicit opt-out (MIXDOG_BOOT_CORE_MEMORY=0/false/no/off) skips the
248
- // memory/PG startup cost; recall and memory tools still initialize the
249
- // memory service on first use.
248
+ // Explicit opt-out (MIXDOG_BOOT_CORE_MEMORY=0/false/no/off) skips this
249
+ // file-backed prompt block. Recall and memory tools remain available.
250
250
  const bootFlag = String(process.env.MIXDOG_BOOT_CORE_MEMORY ?? '').trim().toLowerCase();
251
251
  if (bootFlag === '0' || bootFlag === 'false' || bootFlag === 'no' || bootFlag === 'off') {
252
252
  bootProfile('core-memory:skipped');
253
253
  return '';
254
254
  }
255
255
  const startedAt = performance.now();
256
- let timer = null;
257
- const timeout = new Promise((resolveTimeout) => {
258
- timer = setTimeout(() => resolveTimeout(''), 2000);
259
- timer.unref?.();
260
- });
261
256
  try {
262
- return await Promise.race([
263
- (async () => {
264
- const memoryMod = await getMemoryModule();
265
- if (typeof memoryMod?.buildSessionCoreMemoryPayload !== 'function') return '';
266
- return formatCoreMemoryLines(await memoryMod.buildSessionCoreMemoryPayload(getCurrentCwd()));
267
- })(),
268
- timeout,
269
- ]);
257
+ // The prompt path never starts or waits for PG/embedding/IPC. Memory
258
+ // runtime maintains this atomic snapshot independently.
259
+ const dataDir = process.env.MIXDOG_DATA_DIR || cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
260
+ const payload = readSessionCoreMemoryPayload(dataDir, getCurrentCwd());
261
+ if (!payload) {
262
+ bootProfile('core-memory:file-missing');
263
+ return '';
264
+ }
265
+ return formatCoreMemoryLines(payload);
270
266
  } catch {
271
267
  return '';
272
268
  } finally {
273
- if (timer) clearTimeout(timer);
274
269
  bootProfile('core-memory:done', { ms: (performance.now() - startedAt).toFixed(1) });
275
270
  }
276
271
  }
@@ -55,6 +55,7 @@ export function createChannelTransport({
55
55
  remoteStatePath = null,
56
56
  remoteIntentPath = null,
57
57
  onClientRegistered = null,
58
+ onRemoteOwnerStateChange = null,
58
59
  agentBroker = null,
59
60
  } = {}) {
60
61
  if (typeof handleCall !== 'function') throw new Error('handleCall is required');
@@ -243,7 +244,6 @@ export function createChannelTransport({
243
244
  }
244
245
 
245
246
  function publishRemoteOwnerState() {
246
- if (!resolvedRemoteStatePath) return;
247
247
  const owner = pointerToken ? clients.get(pointerToken) : null;
248
248
  const sessionId = String(owner?.remoteSessionId || '');
249
249
  const state = {
@@ -263,6 +263,11 @@ export function createChannelTransport({
263
263
  ]);
264
264
  if (signature === remoteStateSignature) return;
265
265
  remoteStateSignature = signature;
266
+ if (typeof onRemoteOwnerStateChange === 'function') {
267
+ try { onRemoteOwnerStateChange(state); }
268
+ catch (err) { log(`remote owner state listener failed: ${err?.message || err}`); }
269
+ }
270
+ if (!resolvedRemoteStatePath) return;
266
271
  try {
267
272
  writeJsonAtomicSync(resolvedRemoteStatePath, state, { compact: true });
268
273
  } catch (err) {
@@ -243,6 +243,7 @@ let sessionRuntimePool = null;
243
243
  let localSessionBridge = null;
244
244
  let memoryRuntime = null;
245
245
  let agentDispatchBroker = null;
246
+ let remoteOwnerState = { enabled: false, sessionId: null };
246
247
  let shuttingDown = false;
247
248
  let shutdownRecheckTimer = null;
248
249
  let replacementRequested = null;
@@ -250,6 +251,27 @@ const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
250
251
  eventLoopDelay.enable();
251
252
  let eventLoopLagTimer = null;
252
253
 
254
+ function startMemoryRuntimeEarly() {
255
+ // Start the isolated memory process immediately after singleton ownership is
256
+ // established. Do not await it: daemon front-door readiness remains
257
+ // independent while PG/embedding cold-start overlaps all other boot work.
258
+ if (process.env.MIXDOG_DAEMON_SKIP_MEMORY === '1') {
259
+ log('memory runtime skipped (MIXDOG_DAEMON_SKIP_MEMORY=1)');
260
+ return;
261
+ }
262
+ if (memoryRuntime) return;
263
+ try {
264
+ memoryRuntime = getStandaloneMemoryRuntime({
265
+ entry: MEMORY_ENTRY,
266
+ dataDir: DATA_DIR,
267
+ cwd: CWD,
268
+ });
269
+ void memoryRuntime.init()
270
+ .then(() => log('memory runtime ready in isolated process'))
271
+ .catch((e) => log(`memory.start failed (non-fatal): ${e?.message || e}`));
272
+ } catch (e) { log(`memory.start setup failed (non-fatal): ${e?.message || e}`); }
273
+ }
274
+
253
275
  function eventLoopStatus() {
254
276
  const milliseconds = (value) => Number.isFinite(value) ? Math.round(value / 1e6) : 0;
255
277
  return {
@@ -387,6 +409,7 @@ async function main() {
387
409
  process.exit(0);
388
410
  }
389
411
  process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
412
+ startMemoryRuntimeEarly();
390
413
  agentDispatchBroker = createAgentDispatchBroker({
391
414
  loadConfig: loadAgentConfig,
392
415
  initProviders,
@@ -458,6 +481,17 @@ async function main() {
458
481
  onClientsEmpty: () => { maybeSelfShutdown('no live channel clients'); },
459
482
  // First channels client in: bring the channels runtime up (see startChannels).
460
483
  onClientRegistered: () => { startChannels(); },
484
+ onRemoteOwnerStateChange: (state) => {
485
+ remoteOwnerState = {
486
+ enabled: state?.enabled === true,
487
+ sessionId: state?.enabled === true && state?.sessionId
488
+ ? String(state.sessionId)
489
+ : null,
490
+ };
491
+ const frame = { type: 'remote-owner-state', ...remoteOwnerState };
492
+ localSessionBridge?.publish(frame);
493
+ sessionTransport?.broadcast(frame);
494
+ },
461
495
  });
462
496
  setChannelNotifySink((method, params) => transport.notify(method, params));
463
497
  const { port, token } = await transport.start();
@@ -571,6 +605,7 @@ async function main() {
571
605
  refreshFromStorage: options.refreshFromStorage === true,
572
606
  });
573
607
  },
608
+ getRemoteOwnerState: () => remoteOwnerState,
574
609
  desktopRuntime,
575
610
  onFrame: (frame, targetTokens) => {
576
611
  localSessionBridge?.publish(frame, targetTokens);
@@ -681,27 +716,6 @@ async function main() {
681
716
  // client — see the transport's onClientRegistered hook.
682
717
  if (process.env.MIXDOG_DAEMON_SPAWNED_FOR !== 'session') startChannels();
683
718
 
684
- // Memory owns a separate process/event loop. Initialization stays
685
- // asynchronous because DB/embedding startup must not delay the front-door
686
- // ready handshake. The proxy retains it for this daemon pid and its
687
- // client-grace lifecycle reaps it only after the daemon is gone.
688
- // Isolated test roots opt out (MIXDOG_DAEMON_SKIP_MEMORY=1): spinning a
689
- // throwaway Postgres cluster per test run is pure cost, and a hard-killed
690
- // daemon would orphan it.
691
- if (process.env.MIXDOG_DAEMON_SKIP_MEMORY === '1') {
692
- log('memory runtime skipped (MIXDOG_DAEMON_SKIP_MEMORY=1)');
693
- return;
694
- }
695
- try {
696
- memoryRuntime = getStandaloneMemoryRuntime({
697
- entry: MEMORY_ENTRY,
698
- dataDir: DATA_DIR,
699
- cwd: CWD,
700
- });
701
- void memoryRuntime.init()
702
- .then(() => log('memory runtime ready in isolated process'))
703
- .catch((e) => log(`memory.start failed (non-fatal): ${e?.message || e}`));
704
- } catch (e) { log(`memory.start setup failed (non-fatal): ${e?.message || e}`); }
705
719
  }
706
720
 
707
721
  process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
@@ -24,6 +24,7 @@ import {
24
24
  SESSION_READ_ACTIONS,
25
25
  SESSION_READ_ACTION_SET,
26
26
  } from './session-protocol.mjs';
27
+ import { readSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
27
28
 
28
29
  function runtimeRoot() {
29
30
  return process.env.MIXDOG_RUNTIME_ROOT
@@ -35,6 +36,13 @@ export function sessionDiscoveryPath() {
35
36
  return path.join(runtimeRoot(), 'daemon.json');
36
37
  }
37
38
 
39
+ function daemonOwnerPath() {
40
+ const dataDir = process.env.MIXDOG_DATA_DIR
41
+ ? path.resolve(process.env.MIXDOG_DATA_DIR)
42
+ : path.join(process.env.MIXDOG_HOME || path.join(os.homedir(), '.mixdog'), 'data');
43
+ return path.join(dataDir, 'daemon-owner.json');
44
+ }
45
+
38
46
  function daemonEntry() {
39
47
  // ONE daemon: the channels/memory host also owns the session pool, so both
40
48
  // spawn paths converge on the same singleton process.
@@ -61,13 +69,14 @@ const URGENT_CALLS = new Set([
61
69
  'desktop.control',
62
70
  'desktop.unsubscribe',
63
71
  ]);
64
- const EVENT_STREAM_RECONNECT_BASE_MS = 100;
65
- const EVENT_STREAM_RECONNECT_MAX_MS = 1_000;
66
- const EVENT_STREAM_HEALTH_GRACE_MS = 750;
72
+ const EVENT_STREAM_RECONNECT_BASE_MS = 1_000;
73
+ const EVENT_STREAM_RECONNECT_MAX_MS = 30_000;
67
74
  // Claude Code transport parity: keepalive silence is detected independently
68
75
  // from TCP close, and a continuously failing reconnect storm is bounded.
69
76
  const EVENT_STREAM_LIVENESS_TIMEOUT_MS = 45_000;
70
77
  const EVENT_STREAM_RECONNECT_BUDGET_MS = 10 * 60_000;
78
+ const DEFAULT_DAEMON_READY_TIMEOUT_MS = 15_000;
79
+ const DAEMON_OWNER_POLL_MS = 50;
71
80
  // A daemon that restarted needs a moment to rebind its port, so one immediate
72
81
  // re-attach often lands in the same gap the first call died in. Mirror the
73
82
  // event-stream policy above with a short bounded ladder; `callId` keeps a
@@ -127,17 +136,20 @@ export async function probeSessionHealth({ port, token, timeoutMs = 800 } = {})
127
136
  } catch { return null; }
128
137
  }
129
138
 
139
+ function isPidAlive(pid) {
140
+ const value = Number(pid);
141
+ if (!Number.isInteger(value) || value <= 0) return false;
142
+ try { process.kill(value, 0); return true; }
143
+ catch (error) { return error?.code === 'EPERM'; }
144
+ }
145
+
130
146
  export function readSessionDiscovery(discoveryPath = sessionDiscoveryPath()) {
131
- const pidAlive = (pid) => {
132
- try { process.kill(Number(pid), 0); return true; }
133
- catch (error) { return error?.code === 'EPERM'; }
134
- };
135
147
  const readUnified = (candidate) => {
136
148
  const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
137
149
  const endpoint = parsed?.endpoints?.session;
138
150
  const channel = parsed?.endpoints?.channel;
139
151
  const pid = parsed?.pid;
140
- if (!endpoint?.port || !endpoint?.token || !pid || !pidAlive(pid)) return null;
152
+ if (!endpoint?.port || !endpoint?.token || !pid || !isPidAlive(pid)) return null;
141
153
  return {
142
154
  ...endpoint,
143
155
  pid,
@@ -229,19 +241,29 @@ async function replaceLowerDaemon(discovery, initialHealth, { log }) {
229
241
  /** Fork one daemon candidate DETACHED (it outlives this client — machine
230
242
  * global) and resolve when it reports ready OR exits (race loss/crash); the
231
243
  * caller then re-reads discovery and attaches to whoever won. */
232
- function spawnDaemonCandidate({ cwd, log }) {
244
+ function spawnDaemonCandidate({ cwd, log, timeoutMs = 30_000 }) {
233
245
  return new Promise((resolve) => {
234
246
  let settled = false;
235
- const done = () => { if (!settled) { settled = true; resolve(); } };
247
+ let timer = null;
248
+ const done = () => {
249
+ if (settled) return;
250
+ settled = true;
251
+ if (timer) clearTimeout(timer);
252
+ resolve();
253
+ };
236
254
  let child;
237
255
  try {
238
256
  child = fork(daemonEntry(), [], {
239
257
  cwd,
240
258
  stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
241
- detached: process.platform !== 'win32',
259
+ // The machine-global daemon must not share Electron's Windows process
260
+ // lifetime. A GPU/Crashpad failure in the desktop must leave this Node
261
+ // host and every live session untouched.
262
+ detached: true,
242
263
  windowsHide: true,
243
264
  env: {
244
265
  ...process.env,
266
+ ELECTRON_RUN_AS_NODE: '1',
245
267
  MIXDOG_DAEMON_HOST: '1',
246
268
  // Session-only spawn: the daemon stays dormant on the channels side
247
269
  // until a channels client registers.
@@ -269,14 +291,32 @@ function spawnDaemonCandidate({ cwd, log }) {
269
291
  });
270
292
  child.once('exit', done);
271
293
  child.once('error', (err) => { log(`daemon spawn error: ${err?.message || err}`); done(); });
272
- const timer = setTimeout(done, 30_000);
294
+ timer = setTimeout(done, Math.max(1, timeoutMs));
273
295
  timer.unref?.();
274
296
  });
275
297
  }
276
298
 
277
299
  /** Spawn-or-attach discovery for the machine-global daemon. */
278
- export async function ensureDaemon({ cwd = process.cwd(), log = () => {}, attempts = 5 } = {}) {
279
- for (let attempt = 0; attempt < attempts; attempt += 1) {
300
+ export async function ensureDaemon({
301
+ cwd = process.cwd(),
302
+ log = () => {},
303
+ attempts = 5,
304
+ readyTimeoutMs = null,
305
+ } = {}) {
306
+ const configuredTimeoutMs = Number(process.env.MIXDOG_DAEMON_READY_TIMEOUT_MS);
307
+ const timeoutMs = Math.max(
308
+ 1,
309
+ Number.isFinite(Number(readyTimeoutMs)) && Number(readyTimeoutMs) > 0
310
+ ? Number(readyTimeoutMs)
311
+ : configuredTimeoutMs > 0
312
+ ? configuredTimeoutMs
313
+ : DEFAULT_DAEMON_READY_TIMEOUT_MS,
314
+ );
315
+ const deadline = Date.now() + timeoutMs;
316
+ const maxSpawnAttempts = Math.max(0, Math.floor(Number(attempts) || 0));
317
+ let spawnAttempts = 0;
318
+ let waitingOwnerPid = 0;
319
+ while (Date.now() < deadline) {
280
320
  const discovery = readSessionDiscovery();
281
321
  if (discovery) {
282
322
  const health = await probeSessionHealth({ port: discovery.port, token: discovery.token });
@@ -294,8 +334,27 @@ export async function ensureDaemon({ cwd = process.cwd(), log = () => {}, attemp
294
334
  throw err;
295
335
  }
296
336
  }
297
- await spawnDaemonCandidate({ cwd, log });
298
- await delay(100);
337
+ // A concurrent launcher can win the owner lock before it publishes
338
+ // daemon.json. That is a healthy singleton boot, not a reason to spawn five
339
+ // doomed contenders and report "endpoint unavailable" after ~500 ms.
340
+ const ownerState = readSingletonOwner(daemonOwnerPath());
341
+ const ownerPid = ownerState.alive ? Number(ownerState.owner?.pid) || 0 : 0;
342
+ if (ownerPid) {
343
+ if (ownerPid !== waitingOwnerPid) {
344
+ waitingOwnerPid = ownerPid;
345
+ log(`waiting for daemon owner pid=${ownerPid} to publish its session endpoint`);
346
+ }
347
+ await delay(Math.min(DAEMON_OWNER_POLL_MS, Math.max(1, deadline - Date.now())));
348
+ continue;
349
+ }
350
+ waitingOwnerPid = 0;
351
+ if (spawnAttempts >= maxSpawnAttempts) break;
352
+ spawnAttempts += 1;
353
+ await spawnDaemonCandidate({
354
+ cwd,
355
+ log,
356
+ timeoutMs: Math.max(1, deadline - Date.now()),
357
+ });
299
358
  }
300
359
  throw new Error('daemon session endpoint is unavailable');
301
360
  }
@@ -314,7 +373,6 @@ export async function attachSession({
314
373
  onStreamReconnect = () => {},
315
374
  streamReconnectBaseMs = EVENT_STREAM_RECONNECT_BASE_MS,
316
375
  streamReconnectMaxMs = EVENT_STREAM_RECONNECT_MAX_MS,
317
- streamReconnectHealthGraceMs = EVENT_STREAM_HEALTH_GRACE_MS,
318
376
  streamLivenessTimeoutMs = EVENT_STREAM_LIVENESS_TIMEOUT_MS,
319
377
  streamReconnectBudgetMs = EVENT_STREAM_RECONNECT_BUDGET_MS,
320
378
  log = () => {},
@@ -364,10 +422,6 @@ export async function attachSession({
364
422
  reconnectBaseMs,
365
423
  Number(streamReconnectMaxMs) || EVENT_STREAM_RECONNECT_MAX_MS,
366
424
  );
367
- const healthGraceMs = Math.max(
368
- reconnectBaseMs,
369
- Number(streamReconnectHealthGraceMs) || EVENT_STREAM_HEALTH_GRACE_MS,
370
- );
371
425
  const livenessMs = Math.max(
372
426
  1,
373
427
  Number(streamLivenessTimeoutMs) || EVENT_STREAM_LIVENESS_TIMEOUT_MS,
@@ -426,15 +480,40 @@ export async function attachSession({
426
480
  signalFatal(`${lastLossReason}; reconnect budget exhausted after ${downtimeMs}ms`);
427
481
  return;
428
482
  }
429
- if (downtimeMs >= healthGraceMs) {
430
- const health = await probeSessionHealth({ port, token: serverToken, timeoutMs: 500 });
431
- if (!health || Number(health.pid) !== Number(discovery.pid)) {
432
- signalFatal(`${lastLossReason}; daemon unavailable or replaced`);
483
+ const current = readSessionDiscovery();
484
+ const replaced = current
485
+ && (
486
+ Number(current.pid) !== Number(discovery.pid)
487
+ || Number(current.port) !== Number(port)
488
+ || String(current.token || '') !== String(serverToken)
489
+ );
490
+ if (replaced) {
491
+ const health = await probeSessionHealth({
492
+ port: current.port,
493
+ token: current.token,
494
+ timeoutMs: 800,
495
+ });
496
+ if (health && Number(health.pid) === Number(current.pid)) {
497
+ signalFatal(
498
+ `${lastLossReason}; daemon replaced`
499
+ + ` oldPid=${Number(discovery.pid)} oldPort=${Number(port)}`
500
+ + ` newPid=${Number(current.pid)} newPort=${Number(current.port)}`,
501
+ );
433
502
  return;
434
503
  }
435
504
  }
505
+ if (!isPidAlive(discovery.pid)) {
506
+ signalFatal(
507
+ `${lastLossReason}; daemon exited`
508
+ + ` pid=${Number(discovery.pid)} port=${Number(port)}`,
509
+ );
510
+ return;
511
+ }
512
+ openStream();
513
+ })().catch((error) => {
514
+ log(`session event stream recovery probe failed: ${error?.message || error}`);
436
515
  openStream();
437
- })().catch(() => signalFatal(`${lastLossReason}; daemon health check failed`));
516
+ });
438
517
  }, delayMs);
439
518
  reconnectTimer.unref?.();
440
519
  }
@@ -98,6 +98,7 @@ export function createSessionService({
98
98
  sessionExists = null,
99
99
  readStoredSession = null,
100
100
  listSessions = null,
101
+ getRemoteOwnerState = null,
101
102
  desktopRuntime = null,
102
103
  publishIntervalMs = 16,
103
104
  onFrame = () => {},
@@ -721,7 +722,13 @@ export function createSessionService({
721
722
  throw new Error('session catalog is unavailable');
722
723
  }
723
724
  const sessions = await listSessions(options || {});
724
- return { sessions: sanitizeForWire(Array.isArray(sessions) ? sessions : []) };
725
+ const remoteOwner = typeof getRemoteOwnerState === 'function'
726
+ ? await getRemoteOwnerState()
727
+ : null;
728
+ return {
729
+ sessions: sanitizeForWire(Array.isArray(sessions) ? sessions : []),
730
+ remoteOwner: sanitizeForWire(remoteOwner) ?? null,
731
+ };
725
732
  }
726
733
 
727
734
  async function loadProjectStore() {
@@ -177,9 +177,7 @@ export function resolveAnchorScrollOffset({ anchor, items, curPrefix, totalRows,
177
177
  if (list[i] && list[i].id === anchor.id) { idx = i; break; }
178
178
  }
179
179
  if (idx < 0 || idx > curPrefix.length - 2) return null;
180
- const itemHeight = Math.max(0, transcriptRowAt(curPrefix, idx + 1) - transcriptRowAt(curPrefix, idx));
181
- const clampedOffset = Math.max(0, Math.min(Number(anchor.offset) || 0, itemHeight));
182
- const anchorRowCur = transcriptRowAt(curPrefix, idx) + clampedOffset;
180
+ const anchorRowCur = transcriptRowAt(curPrefix, idx) + (Number(anchor.offset) || 0);
183
181
  return Math.max(0, Math.min(maxRows, totalRows - viewRows - anchorRowCur));
184
182
  }
185
183
 
@@ -2,15 +2,16 @@ export const PROMPT_ESCAPE_CLEAR_WINDOW_MS = 800;
2
2
  export const PROMPT_ESCAPE_HINT_TIMEOUT_MS = 1000;
3
3
 
4
4
  /**
5
- * Claude Code-compatible chat Escape priority after overlays/selections have
6
- * already had a chance to consume the key:
7
- * active turn -> interrupt immediately, then (while idle) queued editable
8
- * messages -> restore for editing, idle non-empty draft -> double-press clear,
9
- * idle empty draft with history -> double-press message selector, otherwise
10
- * fallback.
5
+ * Claude Code-compatible chat Escape priority after overlays have already had
6
+ * a chance to consume the key:
7
+ * active turn -> interrupt immediately, selected draft -> collapse selection,
8
+ * then (while idle) queued editable messages -> restore for editing, idle
9
+ * non-empty draft -> double-press clear, idle empty draft with history ->
10
+ * double-press message selector, otherwise fallback.
11
11
  */
12
12
  export function classifyPromptEscape({
13
13
  interruptActive = false,
14
+ hasSelection = false,
14
15
  hasQueuedMessages = false,
15
16
  hasMessages = false,
16
17
  value = '',
@@ -18,6 +19,7 @@ export function classifyPromptEscape({
18
19
  now = Date.now(),
19
20
  } = {}) {
20
21
  if (interruptActive) return { action: 'interrupt', nextClearPressAt: 0 };
22
+ if (hasSelection) return { action: 'collapse-selection', nextClearPressAt: 0 };
21
23
  if (hasQueuedMessages) return { action: 'restore-queue', nextClearPressAt: 0 };
22
24
 
23
25
  const current = Number(now);
@@ -6113,6 +6113,7 @@ var PROMPT_ESCAPE_CLEAR_WINDOW_MS = 800;
6113
6113
  var PROMPT_ESCAPE_HINT_TIMEOUT_MS = 1e3;
6114
6114
  function classifyPromptEscape({
6115
6115
  interruptActive = false,
6116
+ hasSelection = false,
6116
6117
  hasQueuedMessages = false,
6117
6118
  hasMessages = false,
6118
6119
  value = "",
@@ -6120,6 +6121,7 @@ function classifyPromptEscape({
6120
6121
  now = Date.now()
6121
6122
  } = {}) {
6122
6123
  if (interruptActive) return { action: "interrupt", nextClearPressAt: 0 };
6124
+ if (hasSelection) return { action: "collapse-selection", nextClearPressAt: 0 };
6123
6125
  if (hasQueuedMessages) return { action: "restore-queue", nextClearPressAt: 0 };
6124
6126
  const current = Number(now);
6125
6127
  const previous = Number(lastClearPressAt);
@@ -11697,9 +11699,7 @@ function resolveAnchorScrollOffset({ anchor, items, curPrefix, totalRows, viewRo
11697
11699
  }
11698
11700
  }
11699
11701
  if (idx < 0 || idx > curPrefix.length - 2) return null;
11700
- const itemHeight = Math.max(0, transcriptRowAt(curPrefix, idx + 1) - transcriptRowAt(curPrefix, idx));
11701
- const clampedOffset = Math.max(0, Math.min(Number(anchor.offset) || 0, itemHeight));
11702
- const anchorRowCur = transcriptRowAt(curPrefix, idx) + clampedOffset;
11702
+ const anchorRowCur = transcriptRowAt(curPrefix, idx) + (Number(anchor.offset) || 0);
11703
11703
  return Math.max(0, Math.min(maxRows, totalRows - viewRows - anchorRowCur));
11704
11704
  }
11705
11705
  function fnv1a32(str) {