@threadbase-sh/streamer 1.33.0 → 1.35.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.
package/dist/index.d.ts CHANGED
@@ -196,6 +196,12 @@ type WSMessage = {
196
196
  sessionId: string;
197
197
  lines: string[];
198
198
  seqs?: (number | null)[];
199
+ } | {
200
+ type: "conversation_updated";
201
+ conversationId: string;
202
+ messageCount: number;
203
+ lastActivity: string;
204
+ ownership: "external" | "managed";
199
205
  } | {
200
206
  type: "question";
201
207
  sessionId: string;
@@ -252,7 +258,30 @@ type WSMessage = {
252
258
  type: "scan_progress";
253
259
  scanned: number;
254
260
  total: number;
261
+ } | {
262
+ type: "cache_alert";
263
+ fingerprint: string;
264
+ severity: "high" | "low";
265
+ missingCount: number;
266
+ totalRows: number;
267
+ detectedAt: string;
268
+ sample: {
269
+ id: string;
270
+ title?: string;
271
+ }[];
272
+ } | {
273
+ type: "cache_alert_resolved";
274
+ fingerprint: string;
275
+ action: CacheAlertResolveAction;
255
276
  };
277
+ /** The four cache-integrity resolution actions (POST /api/cache/alert/resolve). */
278
+ type CacheAlertResolveAction = "prune_all" | "prune_selected" | "ignore" | "reset_rescan";
279
+ type ServerWarmupState = "startup" | "cache_reset" | "conversation_refresh";
280
+ interface ServerWarmingUpResponse {
281
+ error: "Server is warming up";
282
+ code: "SERVER_WARMING_UP";
283
+ warmupState: ServerWarmupState;
284
+ }
256
285
  interface SessionResponse {
257
286
  id: string;
258
287
  conversationId: string;
@@ -284,6 +313,36 @@ interface SessionResponse {
284
313
  resumedFromConversationId?: string;
285
314
  /** See `ManagedSession.boundConversationId` — never repurposes `conversationId`. */
286
315
  boundConversationId?: string;
316
+ /**
317
+ * Who owns the underlying process. Additive — older clients ignore it, and it
318
+ * deliberately does NOT introduce a new `status` value: `VALID_STATUSES`
319
+ * rejects unknown values in `?status=` and the store drops sessions outside
320
+ * the requested set, so a new status string would make these sessions vanish
321
+ * from already-shipped apps.
322
+ * managed — this streamer spawned and holds the PTY
323
+ * external — a process we discovered but do not own (read-only)
324
+ * historical — a cached conversation, no process known
325
+ */
326
+ ownership?: SessionOwnership;
327
+ /**
328
+ * Whether the process is believed to be running. Only ever "alive" when
329
+ * discovery actually saw it; never guessed from file activity.
330
+ */
331
+ processLiveness?: ProcessLiveness;
332
+ /**
333
+ * INFERRED from JSONL writes, never authoritative: "active_writing" means the
334
+ * transcript grew recently, which cannot distinguish a generating agent from
335
+ * one blocked on a permission gate (gates are screen-only). Absent for
336
+ * sessions we own — their `status` is the authoritative signal.
337
+ */
338
+ activity?: SessionActivity;
339
+ }
340
+ type SessionOwnership = "managed" | "external" | "historical";
341
+ type ProcessLiveness = "alive" | "gone" | "unknown";
342
+ interface SessionActivity {
343
+ state: "active_writing" | "quiet";
344
+ lastEventAt: string;
345
+ source: "jsonl";
287
346
  }
288
347
  interface ConversationListResponse {
289
348
  conversations: unknown[];
@@ -456,6 +515,7 @@ interface ScannerMeta {
456
515
  projectPath?: string;
457
516
  projectName?: string;
458
517
  title?: string;
518
+ sessionName?: string;
459
519
  model?: string;
460
520
  account?: string;
461
521
  gitBranch?: string;
@@ -579,7 +639,8 @@ declare class ConversationCache {
579
639
  * updateFromLine in order: the agent filter short-circuits the whole batch,
580
640
  * project context is backfilled last-wins, message_count increases by the
581
641
  * number of surviving message lines, and last_activity/last_message reflect
582
- * the final message line.
642
+ * the newest message line by timestamp (a monotonic guard keeps them from
643
+ * moving backward when an interleaved writer appends an older line — P0.3).
583
644
  */
584
645
  updateFromLines(filePath: string, rawLines: string[]): void;
585
646
  upsertFromScannerMeta(metas: ScannerMeta[]): string[];
@@ -605,6 +666,12 @@ declare class ConversationCache {
605
666
  stat: FileStatEntry;
606
667
  meta: ConversationMeta;
607
668
  }>;
669
+ /**
670
+ * Conversation id for a JSONL path, or null when no row exists yet. Resolves
671
+ * by file_path (NOT conversationIdForFile) so codex rollout files — named
672
+ * rollout-<ts>-<uuid>.jsonl, whose stem is not the row id — resolve correctly.
673
+ */
674
+ getIdByFilePath(filePath: string): string | null;
608
675
  getMetaById(id: string): ConversationListItem | null;
609
676
  setConversationProjectId(conversationId: string, projectId: string): void;
610
677
  markAsStreamer(id: string): void;
@@ -628,15 +695,15 @@ declare class ConversationCache {
628
695
  /**
629
696
  * Drop the cached row for a file. Two callers with opposite intent:
630
697
  * - a directory-watch "change" event (the file was appended to) — pass
631
- * `skipIfTailed: true`. A cached tail means the row is being actively
632
- * maintained from the file's real content by the live-tail
633
- * (updateFromLines) or warm-up path, fresher than any scanner-derived
634
- * view. Both watchers fire on the same append with no ordering guarantee;
635
- * without this guard the invalidate can land after the tail write and wipe
636
- * the just-cached row, flickering the conversation out of
637
- * /api/conversations on nearly every message (CRITICAL #2). The debounced
638
- * rescan still re-derives metadata, so skipping the eager drop loses
639
- * nothing.
698
+ * `skipIfTailed: true`, which NEVER deletes (upsert-or-leave). A change
699
+ * event fires on every external append; deleting here flickers the
700
+ * conversation out of /api/conversations whether it's a live-tailed row
701
+ * the updateFromLines/warm-up path just wrote (CRITICAL #2; both watchers
702
+ * fire on the same append with no ordering guarantee) OR a refresh-created
703
+ * untailed row (a ?refresh=1 upsert never populates a tail, so the old
704
+ * "delete when untailed" behavior made it vanish on its next append with no
705
+ * client action). The live-tail path owns the row's content and the
706
+ * debounced rescan re-derives metadata, so leaving the row loses nothing.
640
707
  * - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
641
708
  * row is always removed, otherwise a deleted session ghosts in the cache.
642
709
  */
@@ -678,6 +745,30 @@ declare class ConversationCache {
678
745
  reconcileDeletions(livePaths: Set<string>, opts?: {
679
746
  exists?: (filePath: string) => boolean;
680
747
  }): string[];
748
+ /**
749
+ * Read-only: list cached rows whose `file_path` no longer exists on disk.
750
+ * Unlike pruneGhostFiles/reconcileDeletions this mutates nothing — it just
751
+ * reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
752
+ * rows that still have cached history (which pruneGhostFiles would keep).
753
+ */
754
+ listMissingFiles(exists?: (filePath: string) => boolean): {
755
+ id: string;
756
+ filePath: string;
757
+ title: string | null;
758
+ tailed: boolean;
759
+ }[];
760
+ /**
761
+ * Drop the given conversation ids outright — main row, tail, and message
762
+ * index — regardless of whether they have a tail. Used by the cache-integrity
763
+ * resolution actions (prune_all / prune_selected). Returns the count dropped.
764
+ */
765
+ dropRowsById(ids: string[]): number;
766
+ /**
767
+ * Wipe all cached conversation state — meta, tails, and message index — and
768
+ * reset the in-memory file index. Only called by the `reset_rescan`
769
+ * resolution action, which repopulates from a fresh disk scan afterward.
770
+ */
771
+ clearAll(): void;
681
772
  }
682
773
 
683
774
  type CacheMetadataKey = "last_conversation_id" | "last_conversation_created_at" | "projects_last_indexed_at" | "conversations_last_indexed_at" | "conversations_dirty";
@@ -832,6 +923,90 @@ declare class WSHub {
832
923
  private startPing;
833
924
  }
834
925
 
926
+ /** One missing conversation the alert covers. */
927
+ interface MissingEntry {
928
+ id: string;
929
+ filePath: string;
930
+ title: string | null;
931
+ tailed: boolean;
932
+ }
933
+ interface PendingAlert {
934
+ fingerprint: string;
935
+ severity: "high" | "low";
936
+ detectedAt: string;
937
+ missingCount: number;
938
+ totalRows: number;
939
+ backupPath?: string;
940
+ /** Capped at 1000 entries in the persisted file. */
941
+ missing: MissingEntry[];
942
+ }
943
+
944
+ type ResolveAction = CacheAlertResolveAction;
945
+ /** The `cache_alert` WS variant, narrowed from the WSMessage union. */
946
+ type CacheAlertWsMessage = Extract<WSMessage, {
947
+ type: "cache_alert";
948
+ }>;
949
+ type ResolveResult = {
950
+ ok: true;
951
+ action: ResolveAction;
952
+ pruned?: number;
953
+ backupPath?: string;
954
+ } | {
955
+ alreadyResolved: true;
956
+ } | {
957
+ conflict: true;
958
+ currentFingerprint: string;
959
+ };
960
+ declare class CacheIntegrityMonitor {
961
+ private readonly cache;
962
+ private readonly wsHub;
963
+ private readonly log;
964
+ private readonly cacheDir;
965
+ private readonly rescan?;
966
+ private readonly runDuringReset?;
967
+ private _pending;
968
+ private ignoredIds;
969
+ private deferredUnlinks;
970
+ private unlinkTimes;
971
+ constructor(cache: ConversationCache, wsHub: WSHub, log: Logger, cacheDir: string, rescan?: (() => Promise<ScannerMeta[]>) | undefined, runDuringReset?: (<T>(operation: () => Promise<T>) => Promise<T>) | undefined);
972
+ get pending(): PendingAlert | null;
973
+ private persist;
974
+ private classifySeverity;
975
+ private sampleOf;
976
+ private buildWsMessage;
977
+ wsMessage(): CacheAlertWsMessage | null;
978
+ healthzField(): {
979
+ severity: "high" | "low";
980
+ missingCount: number;
981
+ fingerprint: string;
982
+ detectedAt: string;
983
+ } | undefined;
984
+ /**
985
+ * Scan the cache for rows whose file is gone, excluding ids the user chose to
986
+ * ignore. If none remain, clear any stale pending alert and return (the caller
987
+ * decides whether to run pruneGhostFiles). Otherwise classify severity, persist
988
+ * the pending record, back up on high severity, and broadcast the alert.
989
+ */
990
+ runDetection(detectedAt?: string): Promise<void>;
991
+ /** Queue an unlink while an alert is pending — the row is not invalidated. */
992
+ deferUnlink(filePath: string): void;
993
+ /**
994
+ * Record a live unlink while NO alert is pending. Crossing the storm threshold
995
+ * (>= 10 unlinks within 30s) re-triggers detection.
996
+ */
997
+ recordUnlink(filePath: string): void;
998
+ private ensureBackup;
999
+ private clearPending;
1000
+ private applyDeferredUnlinks;
1001
+ private broadcastResolved;
1002
+ /**
1003
+ * Apply the human's chosen resolution. Idempotent per fingerprint: no pending
1004
+ * alert → alreadyResolved; a different fingerprint → conflict. See the spec's
1005
+ * four-action semantics.
1006
+ */
1007
+ resolve(fingerprint: string, action: ResolveAction, ids?: string[]): Promise<ResolveResult>;
1008
+ }
1009
+
835
1010
  type ApiDeps = {
836
1011
  apiKey: string;
837
1012
  localNoAuth: boolean;
@@ -847,6 +1022,7 @@ type ApiDeps = {
847
1022
  sessionStore: SessionStore;
848
1023
  wsHub: WSHub;
849
1024
  cache: () => ConversationCache | null;
1025
+ cacheMonitor: () => CacheIntegrityMonitor | null;
850
1026
  projectsRepo: () => ProjectsRepository | null;
851
1027
  conversationsRepo: () => ConversationsRepository | null;
852
1028
  sessionsRepo: () => SessionsRepository | null;
@@ -990,6 +1166,7 @@ declare class PTYManager implements SessionRunner {
990
1166
  private handleOutput;
991
1167
  private detectLivePrompts;
992
1168
  private handleQuiet;
1169
+ private recheckReadyFromScreen;
993
1170
  private markReady;
994
1171
  private handleExit;
995
1172
  }
@@ -1001,8 +1178,11 @@ declare class StreamerServer {
1001
1178
  private wsHub;
1002
1179
  private fileWatcher;
1003
1180
  private sessionFileMap;
1181
+ private externalTails;
1004
1182
  private pendingLineSeqs;
1005
1183
  private pendingQuestions;
1184
+ private contendedSessions;
1185
+ private selfPtyEndedAt;
1006
1186
  private pendingQuestionKey;
1007
1187
  private pendingPermission;
1008
1188
  private scanner;
@@ -1012,7 +1192,8 @@ declare class StreamerServer {
1012
1192
  private scannerStale;
1013
1193
  private refreshInFlight;
1014
1194
  private binding;
1015
- private cacheReady;
1195
+ private activeWarmups;
1196
+ private nextWarmupId;
1016
1197
  private inFlightCacheWrites;
1017
1198
  private apiKey;
1018
1199
  private apiKeySource;
@@ -1036,10 +1217,12 @@ declare class StreamerServer {
1036
1217
  private defaultModel;
1037
1218
  private defaultEffort;
1038
1219
  private ptyGraceTimers;
1220
+ private ptyGraceDeferCounts;
1039
1221
  private sessionSubscribers;
1040
1222
  private clientIdToWs;
1041
1223
  private wsToClientId;
1042
1224
  private cache;
1225
+ private cacheMonitor;
1043
1226
  private projectsRepo;
1044
1227
  private conversationsRepo;
1045
1228
  private sessionsRepo;
@@ -1061,6 +1244,7 @@ declare class StreamerServer {
1061
1244
  apiKey: string;
1062
1245
  });
1063
1246
  private ptyAttachedIds;
1247
+ private rememberSelfPtyEnded;
1064
1248
  /**
1065
1249
  * Send a session_list to only the client that triggered this HTTP request
1066
1250
  * (identified by X-Client-Id header → registered WS socket). Falls back to
@@ -1070,6 +1254,11 @@ declare class StreamerServer {
1070
1254
  private addSessionSubscriber;
1071
1255
  private startGraceTimer;
1072
1256
  get port(): number;
1257
+ private currentWarmupState;
1258
+ private beginWarmup;
1259
+ private finishWarmup;
1260
+ private withWarmup;
1261
+ private rejectIfWarmingUp;
1073
1262
  listen(port: number, opts?: {
1074
1263
  awaitReady?: boolean;
1075
1264
  }): Promise<void>;
@@ -1126,6 +1315,43 @@ declare class StreamerServer {
1126
1315
  * Claude lines pass through unchanged so seq alignment stays intact.
1127
1316
  */
1128
1317
  private broadcastConversationLines;
1318
+ /** True when a managed (PTY) session owns the tail for this canonical path. */
1319
+ private isManagedTailPath;
1320
+ /**
1321
+ * Attach a live tail to a JSONL nobody is tailing yet, when it was touched
1322
+ * recently enough to look actively written by an external agent. Capped at
1323
+ * EXTERNAL_TAIL_MAX with LRU eviction.
1324
+ */
1325
+ private maybeAttachExternalTail;
1326
+ /** Stop tailing an external file and drop its bookkeeping. */
1327
+ private detachExternalTail;
1328
+ /** Make room for one more tail by evicting the least recently active ones. */
1329
+ private evictExternalTailsIfNeeded;
1330
+ /**
1331
+ * INFERRED activity for an externally-owned conversation, derived purely from
1332
+ * how recently its JSONL grew (the external tail's bookkeeping). Returns
1333
+ * undefined when we hold no tail for it, so a session we know nothing about
1334
+ * reports no activity rather than a fabricated "quiet".
1335
+ *
1336
+ * This can never distinguish a generating agent from one blocked on a
1337
+ * permission gate — gates render on the PTY screen and never reach the JSONL —
1338
+ * which is why it is a separate field and not folded into `status`.
1339
+ */
1340
+ private externalActivityFor;
1341
+ /** Attach inferred `activity` to externally-owned sessions in a response set. */
1342
+ private withExternalActivity;
1343
+ /** Detach external tails idle past EXTERNAL_TAIL_IDLE_MS. */
1344
+ private sweepIdleExternalTails;
1345
+ /**
1346
+ * Push appended lines from an externally-owned conversation. Reuses the exact
1347
+ * conversation_events / conversation_event shapes mobile already consumes,
1348
+ * keyed by the conversation UUID — an external session has no PTY, so it must
1349
+ * never produce terminal_output / terminal_replay / session_ready, and never a
1350
+ * session_update whose session.id is a conversation UUID (that would mint a
1351
+ * phantom session row in the mobile cache). Question cards are likewise never
1352
+ * derived here: with no PTY there is nothing that could deliver an answer.
1353
+ */
1354
+ private broadcastExternalTailLines;
1129
1355
  private findConversationByUuid;
1130
1356
  private isConversationSnapshotStale;
1131
1357
  private handleGetConversation;
@@ -1136,6 +1362,7 @@ declare class StreamerServer {
1136
1362
  private handleResume;
1137
1363
  private enrichResumedSessionAsync;
1138
1364
  private handleSendInput;
1365
+ private processJsonlQuestions;
1139
1366
  private cancelPendingQuestion;
1140
1367
  private handleLiveQuestion;
1141
1368
  private handlePermissionChange;
@@ -1148,6 +1375,7 @@ declare class StreamerServer {
1148
1375
  private handleStartSession;
1149
1376
  private linkSessionToProject;
1150
1377
  private watchConversationFile;
1378
+ private readFirstLineSessionId;
1151
1379
  private watchForJsonl;
1152
1380
  private watchForCodexRollout;
1153
1381
  private handleBrowse;
@@ -1179,6 +1407,13 @@ interface ConversationWatcherEvents {
1179
1407
  onConversationChanged?: (filePath: string) => void | Promise<void>;
1180
1408
  /** Fires when a tailed file is deleted (per-file watcher unlink event). */
1181
1409
  onFileDeleted?: (filePath: string) => void;
1410
+ /**
1411
+ * Fires when a tailed file shrank below our read offset (in-place truncation
1412
+ * or replacement by a shorter file). The tail has already reset to byte 0;
1413
+ * the consumer must discard any byte-offset index built for the old content,
1414
+ * which no longer describes this file.
1415
+ */
1416
+ onTruncated?: (filePath: string) => void;
1182
1417
  /** Reported errors per file. */
1183
1418
  onError?: (filePath: string, error: Error) => void;
1184
1419
  }
@@ -1201,6 +1436,7 @@ declare class ConversationWatcher {
1201
1436
  private onNewLineSpans;
1202
1437
  private onConversationChanged;
1203
1438
  private onFileDeleted;
1439
+ private onTruncated;
1204
1440
  private onError;
1205
1441
  constructor(events?: ConversationWatcherEvents);
1206
1442
  watch(filePath: string): void;
@@ -1225,4 +1461,4 @@ declare class ConversationWatcher {
1225
1461
  private readNewLines;
1226
1462
  }
1227
1463
 
1228
- export { type AgentClient, type AgentClientOpts, type AgentConfig, type AppendArgs, type AskOption, type AskQuestion, CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER, type ConversationListResponse, ConversationWatcher, type ConversationWriter, type DbConfig, type DiscoveredProcess, LiveSessionManager, type ManagedSession, PTYManager, type PTYManagerOptions, type PermissionOption, type ProgressDedupeLRU, type ProviderName, type ServerConfig, type SessionCursor, type SessionListPage, type SessionListQuery, type SessionResponse, type SessionRunner, type SessionSortKey, type SessionStatus, SessionStore, type SortOrder, type StartFreshSessionOptions, type StartSessionOptions, StreamerServer, type UserMessage, WSHub, type WSMessage, createAgentClient, createConversationWriter, createPool, createProgressDedupeLRU, createProgressRoutes, discoverClaudeProcesses, generateApiKey, getDbConfig, isDbEnabled, isProviderName, isProviderResumable, loadOrCreateApiKey, maskConnectionString, readAgentConfig, validateApiKey };
1464
+ export { type AgentClient, type AgentClientOpts, type AgentConfig, type AppendArgs, type AskOption, type AskQuestion, CLAUDE_CODE_PROVIDER, CODEX_CLI_PROVIDER, type CacheAlertResolveAction, type ConversationListResponse, ConversationWatcher, type ConversationWriter, type DbConfig, type DiscoveredProcess, LiveSessionManager, type ManagedSession, PTYManager, type PTYManagerOptions, type PermissionOption, type ProcessLiveness, type ProgressDedupeLRU, type ProviderName, type ServerConfig, type ServerWarmingUpResponse, type ServerWarmupState, type SessionActivity, type SessionCursor, type SessionListPage, type SessionListQuery, type SessionOwnership, type SessionResponse, type SessionRunner, type SessionSortKey, type SessionStatus, SessionStore, type SortOrder, type StartFreshSessionOptions, type StartSessionOptions, StreamerServer, type UserMessage, WSHub, type WSMessage, createAgentClient, createConversationWriter, createPool, createProgressDedupeLRU, createProgressRoutes, discoverClaudeProcesses, generateApiKey, getDbConfig, isDbEnabled, isProviderName, isProviderResumable, loadOrCreateApiKey, maskConnectionString, readAgentConfig, validateApiKey };