@pellux/goodvibes-tui 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +7 -7
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/bundle-command.ts +1 -1
  6. package/src/cli/doctor.ts +7 -1
  7. package/src/cli/management.ts +1 -1
  8. package/src/cli/package-verification.ts +92 -7
  9. package/src/cli/status.ts +16 -0
  10. package/src/config/read-versioned.ts +115 -2
  11. package/src/core/active-model-identity.ts +153 -0
  12. package/src/core/conversation-event-navigation.ts +42 -0
  13. package/src/core/conversation-search-expansion.ts +67 -0
  14. package/src/core/conversation-splash-state.ts +58 -0
  15. package/src/core/conversation-user-receipts.ts +42 -0
  16. package/src/core/conversation.ts +43 -40
  17. package/src/core/model-routing-chip.ts +38 -18
  18. package/src/core/rewind-turn-anchors.ts +155 -3
  19. package/src/core/stream-event-wiring.ts +298 -17
  20. package/src/core/system-message-router.ts +22 -0
  21. package/src/core/transcript-journal.ts +145 -7
  22. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  23. package/src/daemon/handlers/inbox/index.ts +14 -1
  24. package/src/daemon/handlers/sqlite-store.ts +160 -6
  25. package/src/input/command-registry.ts +8 -0
  26. package/src/input/commands/local-runtime.ts +6 -1
  27. package/src/input/commands/schedule-runtime.ts +15 -2
  28. package/src/input/commands/shell-core.ts +87 -38
  29. package/src/input/composer-capture.ts +47 -0
  30. package/src/input/handler-command-route.ts +4 -0
  31. package/src/input/handler-content-actions.ts +9 -0
  32. package/src/input/handler-feed-routes.ts +1 -0
  33. package/src/input/handler-modal-routes.ts +12 -1
  34. package/src/input/handler-picker-routes.ts +43 -5
  35. package/src/input/handler-shortcuts.ts +1 -1
  36. package/src/input/handler-ui-state.ts +8 -3
  37. package/src/input/model-picker-items.ts +17 -2
  38. package/src/input/model-picker.ts +19 -2
  39. package/src/input/search.ts +183 -89
  40. package/src/main.ts +31 -38
  41. package/src/{cli → providers}/provider-classification.ts +12 -0
  42. package/src/providers/reasoning-effort-surface.ts +399 -0
  43. package/src/renderer/compositor.ts +68 -18
  44. package/src/renderer/header-line.ts +146 -0
  45. package/src/renderer/markdown-inline.ts +143 -0
  46. package/src/renderer/markdown-table.ts +459 -0
  47. package/src/renderer/markdown.ts +6 -335
  48. package/src/renderer/model-workspace.ts +48 -5
  49. package/src/renderer/shell-surface.ts +36 -0
  50. package/src/renderer/ui-factory.ts +25 -78
  51. package/src/runtime/bootstrap-command-parts.ts +36 -4
  52. package/src/runtime/bootstrap-core.ts +1 -1
  53. package/src/runtime/durability-housekeeping.ts +183 -0
  54. package/src/runtime/durability-services.ts +68 -11
  55. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  56. package/src/runtime/provider-fallback.ts +16 -2
  57. package/src/runtime/recovery-decisions.ts +295 -0
  58. package/src/runtime/recovery-prompt.ts +66 -6
  59. package/src/runtime/services.ts +9 -4
  60. package/src/runtime/session-liveness-marker.ts +142 -5
  61. package/src/runtime/workstream-draft-store.ts +198 -25
  62. package/src/runtime/workstream-services.ts +17 -2
  63. package/src/shell/ui-openers.ts +1 -1
  64. package/src/version.ts +1 -1
  65. package/src/work-plans/work-plan-store.ts +334 -16
@@ -5,8 +5,7 @@ import type { ToolCall, ToolResult } from '@pellux/goodvibes-sdk/platform/types'
5
5
  import type { ProviderMessage, ContentPart } from '@pellux/goodvibes-sdk/platform/providers';
6
6
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
7
7
  import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
8
- // SystemMessageKind imported from runtime directly to avoid cycle:
9
- // conversation.ts → system-message-router.ts → conversation.ts
8
+ // SystemMessageKind imported from runtime directly to avoid cycle: conversation.ts → system-message-router.ts → conversation.ts
10
9
  import type { SystemMessageKind } from '@/runtime/index.ts';
11
10
  import {
12
11
  ConversationManager as SdkConversationManager,
@@ -14,6 +13,10 @@ import {
14
13
  } from '@pellux/goodvibes-sdk/platform/core';
15
14
  import type { BlockMeta } from './conversation-types.ts';
16
15
  import { MessageLineCache } from './conversation-line-cache.ts';
16
+ import { UserReceiptIndices } from './conversation-user-receipts.ts';
17
+ import { SplashGateState } from './conversation-splash-state.ts';
18
+ import { resolveTranscriptEventLine } from './conversation-event-navigation.ts';
19
+ import { SearchExpansionTracker } from './conversation-search-expansion.ts';
17
20
  import {
18
21
  addConversationSplashScreen,
19
22
  conversationTextToLines,
@@ -110,6 +113,7 @@ export class ConversationManager extends SdkConversationManager {
110
113
  private _configManager: ConfigManager | null = null;
111
114
  /** Collapse state: stable key (msg_N) -> collapsed (true = collapsed). */
112
115
  private collapseState: Map<string, boolean> = new Map();
116
+ public readonly searchExpansion = new SearchExpansionTracker();
113
117
  /** Block registry: track rendered blocks for copy/apply. */
114
118
  protected blockRegistry: BlockMeta[] = [];
115
119
  /**
@@ -139,6 +143,8 @@ export class ConversationManager extends SdkConversationManager {
139
143
  private errorLineRegistry: number[] = [];
140
144
  /** Maps message index → SystemMessageKind for system-role messages. */
141
145
  private messageKindRegistry: Map<number, SystemMessageKind> = new Map();
146
+ /** See conversation-user-receipts.ts. */
147
+ private userReceiptIndices = new UserReceiptIndices();
142
148
  /** Streaming block start line in history buffer (for incremental streaming update). */
143
149
  private streamingStartLine = -1;
144
150
  /**
@@ -164,6 +170,8 @@ export class ConversationManager extends SdkConversationManager {
164
170
 
165
171
  public suppressSplash: boolean = false;
166
172
  public splashOptions: SplashOptions = {};
173
+ /** Run-sticky dismissal + the splash→transcript edge (conversation-splash-state.ts). */
174
+ private readonly splashGate = new SplashGateState();
167
175
 
168
176
  constructor(
169
177
  getWidth: () => number = () => process.stdout.columns || 80,
@@ -221,25 +229,21 @@ export class ConversationManager extends SdkConversationManager {
221
229
  // stale kind (e.g. 'operational') and silently mis-classify the new message.
222
230
  const nextIndex = this.getMessageSnapshot().length;
223
231
  this.messageKindRegistry.delete(nextIndex);
232
+ this.userReceiptIndices.delete(nextIndex);
224
233
  super.addSystemMessage(content);
225
234
  this.markDirty();
226
235
  }
227
236
 
228
237
  /**
229
- * addTypedSystemMessage - Add a system message with an explicit kind tag.
230
- * The kind is stored in messageKindRegistry (keyed by the message index that
231
- * will be assigned after the push) so that renderConversationSystemMessage
232
- * can use it instead of text-based pattern matching.
233
- *
234
- * Called by SystemMessageRouter.routeTypedSystemMessage when routing to the
235
- * conversation surface. Falls back to addSystemMessage for callers that do
236
- * not have kind information.
238
+ * addTypedSystemMessage - System message with an explicit kind tag, stored
239
+ * in messageKindRegistry. `isUserReceipt` (conversation-user-receipts.ts)
240
+ * additionally marks it for rebuildHistory()'s splash check — only for a
241
+ * direct receipt to an explicit user action, never ambient boot chatter.
237
242
  */
238
- public addTypedSystemMessage(content: string, kind: SystemMessageKind): void {
239
- // getMessageSnapshot().length is the index this message will receive after
240
- // addSystemMessage appends it to the messages array.
243
+ public addTypedSystemMessage(content: string, kind: SystemMessageKind, opts?: { isUserReceipt?: boolean }): void {
241
244
  const nextIndex = this.getMessageSnapshot().length;
242
245
  this.messageKindRegistry.set(nextIndex, kind);
246
+ if (opts?.isUserReceipt) this.userReceiptIndices.add(nextIndex); else this.userReceiptIndices.delete(nextIndex);
243
247
  super.addSystemMessage(content);
244
248
  this.markDirty();
245
249
  }
@@ -256,6 +260,7 @@ export class ConversationManager extends SdkConversationManager {
256
260
  for (const key of this.messageKindRegistry.keys()) {
257
261
  if (key >= postUndoCount) this.messageKindRegistry.delete(key);
258
262
  }
263
+ this.userReceiptIndices.purgeFrom(postUndoCount);
259
264
  this.markDirty();
260
265
  }
261
266
  return result;
@@ -357,6 +362,7 @@ export class ConversationManager extends SdkConversationManager {
357
362
  this.messageLineRegistry = [];
358
363
  this.errorLineRegistry = [];
359
364
  this.messageKindRegistry = new Map();
365
+ this.userReceiptIndices.clear();
360
366
  this.streamingStartLine = -1;
361
367
  this._displayFromMessageIndex = 0; // full reset — show everything on next render
362
368
  }
@@ -478,17 +484,21 @@ export class ConversationManager extends SdkConversationManager {
478
484
  const displayStart = this._displayFromMessageIndex;
479
485
  const visibleSnapshot = displayStart > 0 ? renderSnapshot.slice(displayStart) : renderSnapshot;
480
486
 
481
- // Tool messages ARE rendered (as collapsed blocks); this filter is only
482
- // for determining whether to show the splash screen (tool-only messages
483
- // don't count as visible conversation content for splash purposes).
484
- const displayMessages = visibleSnapshot.filter(
485
- (m) => m.role !== 'tool' && m.role !== 'system',
486
- );
487
+ // Tool/system messages aren't visible splash-purposes content, except a
488
+ // system message in userReceiptIndices. visibleSnapshot mirrors the
489
+ // snapshot 1:1 (only front-sliced), so index i is absolute displayStart+i.
490
+ const displayMessages = visibleSnapshot.filter((m, i) => {
491
+ if (m.role === 'tool') return false;
492
+ if (m.role === 'system') return this.userReceiptIndices.has(displayStart + i);
493
+ return true;
494
+ });
487
495
 
488
- if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash) {
496
+ if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash && !this.splashGate.dismissed) {
489
497
  this.addSplashScreen(width);
498
+ this.splashGate.enter();
490
499
  return;
491
500
  }
501
+ this.splashGate.leave(); // latches the splash→transcript edge for consumeSplashTransition()
492
502
 
493
503
  // The in-progress streaming placeholder (rendered EMPTY above via
494
504
  // renderSnapshot) is left uncached: its content mutates in place per delta and
@@ -640,6 +650,7 @@ export class ConversationManager extends SdkConversationManager {
640
650
  if (!nearest) return -1;
641
651
  const current = this.collapseState.get(nearest.collapseKey) ?? false;
642
652
  this.collapseState.set(nearest.collapseKey, !current);
653
+ this.searchExpansion.noteUserTouch(nearest.collapseKey);
643
654
  this.markDirty();
644
655
  return nearest.blockIndex;
645
656
  }
@@ -701,30 +712,12 @@ export class ConversationManager extends SdkConversationManager {
701
712
 
702
713
  public nextTranscriptEventLine(currentLine: number, kind: TranscriptEventKind | 'all' = 'all'): number {
703
714
  this.flushHistory();
704
- const index = this.getTranscriptEventIndex();
705
- const events = kind === 'all' ? index.events : index.events.filter((event) => event.kind === kind);
706
- if (events.length === 0) return -1;
707
- const lines = events
708
- .map((event) => this.messageLineRegistry[event.messageIndex] ?? -1)
709
- .filter((line) => line >= 0)
710
- .sort((a, b) => a - b);
711
- if (lines.length === 0) return -1;
712
- const after = lines.find((line) => line > currentLine);
713
- return after ?? lines[0]!;
715
+ return resolveTranscriptEventLine(this.getTranscriptEventIndex().events, kind, this.messageLineRegistry, currentLine, 'next');
714
716
  }
715
717
 
716
718
  public prevTranscriptEventLine(currentLine: number, kind: TranscriptEventKind | 'all' = 'all'): number {
717
719
  this.flushHistory();
718
- const index = this.getTranscriptEventIndex();
719
- const events = kind === 'all' ? index.events : index.events.filter((event) => event.kind === kind);
720
- if (events.length === 0) return -1;
721
- const lines = events
722
- .map((event) => this.messageLineRegistry[event.messageIndex] ?? -1)
723
- .filter((line) => line >= 0)
724
- .sort((a, b) => a - b);
725
- if (lines.length === 0) return -1;
726
- const before = [...lines].reverse().find((line) => line < currentLine);
727
- return before ?? lines[lines.length - 1]!;
720
+ return resolveTranscriptEventLine(this.getTranscriptEventIndex().events, kind, this.messageLineRegistry, currentLine, 'prev');
728
721
  }
729
722
 
730
723
  public setSplashSuppressed(suppressed: boolean): void {
@@ -733,6 +726,16 @@ export class ConversationManager extends SdkConversationManager {
733
726
  this.markDirty();
734
727
  }
735
728
 
729
+ /** Retire the splash for the run — any submission does it (SplashGateState). */
730
+ public dismissSplash(): void {
731
+ if (this.splashGate.dismiss()) this.markDirty();
732
+ }
733
+
734
+ /** True once, on the frame the splash gives way to transcript content. */
735
+ public consumeSplashTransition(): boolean {
736
+ return this.splashGate.consumeTransition();
737
+ }
738
+
736
739
  private addSplashScreen(width: number): void {
737
740
  addConversationSplashScreen(this.renderingContext(), width);
738
741
  }
@@ -9,18 +9,22 @@
9
9
  * narration of their own.
10
10
  *
11
11
  * Division of labour with the failover path: the optimizer-gated failover in
12
- * stream-event-wiring.ts already renders a richer `[Failover] from → to
13
- * (reason)(+cost)` line AND records the transition into the optimizer's
14
- * fallback log. So when a MODEL_CHANGED correlates to a just-recorded fallback
15
- * transition, this chip stays silent (buildRoutingChip returns null) — the
16
- * [Failover] line already narrated that exact change with its real reason.
17
- * Every other change is surfaced here.
12
+ * stream-event-wiring.ts renders a richer `[Failover] from → to
13
+ * (reason)(+cost)` line for the switches it makes itself, and tells this
14
+ * listener so directly (see the self-narrated guard there) rather than relying
15
+ * on this module to infer it. Those changes never reach buildRoutingChip.
18
16
  *
19
- * Reason honesty: for the paths this chip surfaces the routing layer exposes no
20
- * machine-readable reason (a deliberate user switch has none; an SDK-internal
21
- * downgrade does not thread one to the TUI), so the notice states "reason
22
- * unknown" rather than omitting the change — per the honesty rule that a change
23
- * with no explanation is still reported, just marked unknown.
17
+ * Reason honesty, in three tiers:
18
+ * 1. a change the failover path already narrated → not offered here at all;
19
+ * 2. a change that correlates to a transition in the optimizer's fallback
20
+ * log → the log's own reason is quoted, because the reason IS known. The
21
+ * log is shared: the TUI's failover records provider ids ("abacusai"),
22
+ * the SDK's agent-orchestrator fallback records registry keys
23
+ * ("abacusai:route-llm"), and a match on either form counts. Reading only
24
+ * one form is what made a real, explained failover print
25
+ * "(reason unknown)";
26
+ * 3. no correlation → "(reason unknown)", because a change with no
27
+ * explanation is still reported, just honestly marked as unexplained.
24
28
  */
25
29
 
26
30
  /** The subset of a FallbackTransition this module reads (structural, SDK-agnostic). */
@@ -44,9 +48,22 @@ export const FALLBACK_CORRELATION_WINDOW_MS = 4_000;
44
48
  /** The forced conversation prefix so the notice is never gated as noise or routed away. */
45
49
  export const ROUTING_CHIP_PREFIX = '[Routing]';
46
50
 
51
+ /**
52
+ * The provider id of a fallback-log endpoint, which may be written either as a
53
+ * bare provider id or as a `provider:model` registry key depending on which
54
+ * writer recorded it. Everything before the first ':' is the provider in both
55
+ * forms, so one rule reads both.
56
+ */
57
+ function providerOf(endpoint: string): string {
58
+ const separator = endpoint.indexOf(':');
59
+ return separator === -1 ? endpoint : endpoint.slice(0, separator);
60
+ }
61
+
47
62
  /**
48
63
  * Most recent fallback-log reason for a from→to provider transition within the
49
64
  * correlation window, or null. Scans newest-first so the freshest transition wins.
65
+ * Endpoints are compared by provider id, so entries recorded as registry keys
66
+ * correlate exactly like entries recorded as bare provider ids.
50
67
  */
51
68
  export function findRecentFallbackReason(
52
69
  fallbackLog: readonly FallbackTransitionLike[],
@@ -57,17 +74,20 @@ export function findRecentFallbackReason(
57
74
  ): string | null {
58
75
  for (let i = fallbackLog.length - 1; i >= 0; i--) {
59
76
  const t = fallbackLog[i]!;
60
- if (t.from === fromProvider && t.to === toProvider && now - t.ts <= windowMs) return t.reason;
77
+ if (providerOf(t.from) === providerOf(fromProvider)
78
+ && providerOf(t.to) === providerOf(toProvider)
79
+ && now - t.ts <= windowMs) return t.reason;
61
80
  }
62
81
  return null;
63
82
  }
64
83
 
65
84
  /**
66
85
  * The routing-chip line to emit for a MODEL_CHANGED event, or null to stay
67
- * silent. Null only when there is no real change (no/same previous model) or
68
- * when the change was a failover already narrated by its own [Failover] line
69
- * (correlated via the fallback log). Otherwise returns the honest
70
- * `[Routing] model changed: old → new (reason unknown)` notice.
86
+ * silent. Null only when there is no real change (no/same previous model) —
87
+ * changes the failover path narrates itself never reach this function.
88
+ *
89
+ * A change that correlates to a logged fallback transition is reported WITH
90
+ * that transition's reason; anything else is reported as "reason unknown".
71
91
  */
72
92
  export function buildRoutingChip(
73
93
  change: ModelChangedLike,
@@ -77,6 +97,6 @@ export function buildRoutingChip(
77
97
  const prev = change.previous;
78
98
  if (!prev || prev.registryKey === change.registryKey) return null;
79
99
  const failoverReason = findRecentFallbackReason(fallbackLog, prev.provider, change.provider, now);
80
- if (failoverReason !== null) return null; // [Failover] already narrated this transition
81
- return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (reason unknown)`;
100
+ const reason = failoverReason === null ? 'reason unknown' : `failover: ${failoverReason}`;
101
+ return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (${reason})`;
82
102
  }
@@ -21,7 +21,7 @@
21
21
  * `messageCount` boundaries stay valid because a resume rehydrates the same
22
22
  * message history the anchors were recorded against.
23
23
  */
24
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import type { SessionSurface } from '@/runtime/index.ts';
27
27
 
@@ -92,12 +92,14 @@ export function clearTurnAnchors(sessionId: string): void {
92
92
  const ANCHOR_SIDECAR_VERSION = 1;
93
93
 
94
94
  /** Absolute path to a session's anchor sidecar, or null when inputs are unusable. */
95
+ const ANCHOR_SIDECAR_SUFFIX = '.anchors.json';
96
+
95
97
  function anchorSidecarPath(sessionId: string, surface: SessionSurface): string | null {
96
98
  if (!sessionId || !surface.sessionsDir) return null;
97
99
  // A sessionId is a filename component; refuse anything with path separators so
98
100
  // a malformed id can never escape the sessions directory.
99
101
  if (/[\\/]/.test(sessionId)) return null;
100
- return join(surface.sessionsDir, `${sessionId}.anchors.json`);
102
+ return join(surface.sessionsDir, `${sessionId}${ANCHOR_SIDECAR_SUFFIX}`);
101
103
  }
102
104
 
103
105
  function isTurnAnchor(value: unknown): value is TurnAnchor {
@@ -125,7 +127,7 @@ export function persistTurnAnchors(sessionId: string, surface: SessionSurface):
125
127
  try {
126
128
  mkdirSync(surface.sessionsDir, { recursive: true });
127
129
  const payload = JSON.stringify({ version: ANCHOR_SIDECAR_VERSION, sessionId, anchors: list });
128
- const tmp = `${path}.tmp-${process.pid}`;
130
+ const tmp = `${path}${ANCHOR_SIDECAR_TMP_MARKER}${process.pid}`;
129
131
  writeFileSync(tmp, payload);
130
132
  renameSync(tmp, path);
131
133
  } catch {
@@ -157,3 +159,153 @@ export function restoreTurnAnchors(sessionId: string, surface: SessionSurface):
157
159
  return 0;
158
160
  }
159
161
  }
162
+
163
+ // ─── Reaping orphaned sidecars ────────────────────────────────────────────────
164
+ //
165
+ // A sidecar's OWNER is the session JSONL it sits beside. Deleting a session
166
+ // removes the JSONL but nothing ever removed the sidecar, so anchors for
167
+ // sessions that no longer exist accumulate in the sessions directory forever.
168
+ // The sweep below reclaims them, plus the `.tmp-<pid>` staging files a crash
169
+ // between write and rename leaves behind.
170
+
171
+ /** Infix for the atomic-write staging file, before the writing process's pid. */
172
+ const ANCHOR_SIDECAR_TMP_MARKER = '.tmp-';
173
+
174
+ /**
175
+ * How long a staging file is tolerated before it is treated as crash residue:
176
+ * 1 hour. `persistTurnAnchors` renames within microseconds of writing, so
177
+ * anything this old was interrupted, and the completed sidecar (if the write
178
+ * ever finished) is a separate file.
179
+ */
180
+ export const ANCHOR_TMP_MAX_AGE_MS = 60 * 60 * 1000;
181
+
182
+ /**
183
+ * How settled a sidecar must be before this sweep will delete it: 1 hour.
184
+ *
185
+ * Another instance can be persisting anchors for a session this process knows
186
+ * nothing about, and the window between reading a sidecar's content and
187
+ * unlinking it is not atomic. Requiring the file to have been untouched for an
188
+ * hour means a sidecar that some other instance is actively rewriting is never
189
+ * a candidate, while genuine residue — whose writer is long gone — always is.
190
+ * The sweep repeats, so the delay costs nothing.
191
+ */
192
+ export const ANCHOR_SIDECAR_SETTLE_MS = 60 * 60 * 1000;
193
+
194
+ export interface AnchorSidecarReapResult {
195
+ /** Sidecar and staging files examined this sweep. */
196
+ readonly scanned: number;
197
+ /** Files deleted this sweep. */
198
+ readonly reaped: number;
199
+ }
200
+
201
+ export interface AnchorSidecarReapOptions {
202
+ /** The session this process is using right now; its sidecar is never reaped. */
203
+ readonly currentSessionId?: string | null;
204
+ readonly now?: () => number;
205
+ /** Override the staging-file age window (tests). */
206
+ readonly tmpMaxAgeMs?: number;
207
+ /** Override how long a sidecar must be untouched before it can be reaped (tests). */
208
+ readonly settleMs?: number;
209
+ }
210
+
211
+ /**
212
+ * Delete anchor sidecars whose owning session file is gone, sidecars that hold
213
+ * nothing readable, and abandoned staging files.
214
+ *
215
+ * A sidecar survives when `<sessionsDir>/<sessionId>.jsonl` still exists AND
216
+ * the sidecar itself parses into at least one usable anchor — content, not
217
+ * mere existence, because a sidecar truncated by a crash restores nothing and
218
+ * would otherwise sit there indefinitely looking like valid state. It also
219
+ * survives while it is still fresh (see `ANCHOR_SIDECAR_SETTLE_MS`), which
220
+ * keeps a sidecar another instance is mid-rewrite out of reach.
221
+ *
222
+ * The current session's sidecar is never touched, and an unreadable or absent
223
+ * sessions directory simply reclaims nothing. Idempotent and concurrency-safe:
224
+ * a file another sweeper unlinked first (ENOENT) counts as reaped.
225
+ */
226
+ export function reapOrphanedAnchorSidecars(
227
+ surface: SessionSurface,
228
+ options: AnchorSidecarReapOptions = {},
229
+ ): AnchorSidecarReapResult {
230
+ const dir = surface.sessionsDir;
231
+ if (!dir) return { scanned: 0, reaped: 0 };
232
+
233
+ let names: string[];
234
+ try {
235
+ names = readdirSync(dir).filter(
236
+ (n) => n.endsWith(ANCHOR_SIDECAR_SUFFIX) || n.includes(`${ANCHOR_SIDECAR_SUFFIX}${ANCHOR_SIDECAR_TMP_MARKER}`),
237
+ );
238
+ } catch {
239
+ return { scanned: 0, reaped: 0 };
240
+ }
241
+
242
+ const now = options.now?.() ?? Date.now();
243
+ const tmpMaxAgeMs = options.tmpMaxAgeMs ?? ANCHOR_TMP_MAX_AGE_MS;
244
+ const settleMs = options.settleMs ?? ANCHOR_SIDECAR_SETTLE_MS;
245
+ let reaped = 0;
246
+
247
+ for (const name of names) {
248
+ const path = join(dir, name);
249
+
250
+ if (!name.endsWith(ANCHOR_SIDECAR_SUFFIX)) {
251
+ // A `<sessionId>.anchors.json.tmp-<pid>` staging file. Age alone decides:
252
+ // a young one may belong to another instance's in-flight write.
253
+ let mtimeMs: number;
254
+ try {
255
+ mtimeMs = statSync(path).mtimeMs;
256
+ } catch {
257
+ continue;
258
+ }
259
+ if (now - mtimeMs > tmpMaxAgeMs && unlinkAnchorFile(path)) reaped++;
260
+ continue;
261
+ }
262
+
263
+ const sessionId = name.slice(0, name.length - ANCHOR_SIDECAR_SUFFIX.length);
264
+ if (sessionId.length === 0) continue;
265
+ if (options.currentSessionId && sessionId === options.currentSessionId) continue;
266
+
267
+ // A sidecar written moments ago belongs to a writer that is still around —
268
+ // possibly another instance whose session this process cannot see.
269
+ let sidecarMtimeMs: number;
270
+ try {
271
+ sidecarMtimeMs = statSync(path).mtimeMs;
272
+ } catch {
273
+ continue;
274
+ }
275
+ if (now - sidecarMtimeMs <= settleMs) continue;
276
+
277
+ if (!existsSync(join(dir, `${sessionId}.jsonl`))) {
278
+ // The owning session is gone — this sidecar can never be used again.
279
+ if (unlinkAnchorFile(path)) reaped++;
280
+ continue;
281
+ }
282
+ if (!sidecarHoldsAnchors(path)) {
283
+ // The session survives but the sidecar is empty or torn: it restores
284
+ // nothing, so keeping it only hides the loss.
285
+ if (unlinkAnchorFile(path)) reaped++;
286
+ }
287
+ }
288
+
289
+ return { scanned: names.length, reaped };
290
+ }
291
+
292
+ /** Content check: does this sidecar parse into at least one usable anchor? */
293
+ function sidecarHoldsAnchors(path: string): boolean {
294
+ try {
295
+ const text = readFileSync(path, 'utf8');
296
+ if (text.trim().length === 0) return false;
297
+ const parsed = JSON.parse(text) as { anchors?: unknown };
298
+ return Array.isArray(parsed?.anchors) && parsed.anchors.some(isTurnAnchor);
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ function unlinkAnchorFile(path: string): boolean {
305
+ try {
306
+ unlinkSync(path);
307
+ return true;
308
+ } catch (err) {
309
+ return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
310
+ }
311
+ }