@threadbase-sh/streamer 1.33.0 → 1.34.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.cts 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,24 @@ 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";
256
279
  interface SessionResponse {
257
280
  id: string;
258
281
  conversationId: string;
@@ -284,6 +307,36 @@ interface SessionResponse {
284
307
  resumedFromConversationId?: string;
285
308
  /** See `ManagedSession.boundConversationId` — never repurposes `conversationId`. */
286
309
  boundConversationId?: string;
310
+ /**
311
+ * Who owns the underlying process. Additive — older clients ignore it, and it
312
+ * deliberately does NOT introduce a new `status` value: `VALID_STATUSES`
313
+ * rejects unknown values in `?status=` and the store drops sessions outside
314
+ * the requested set, so a new status string would make these sessions vanish
315
+ * from already-shipped apps.
316
+ * managed — this streamer spawned and holds the PTY
317
+ * external — a process we discovered but do not own (read-only)
318
+ * historical — a cached conversation, no process known
319
+ */
320
+ ownership?: SessionOwnership;
321
+ /**
322
+ * Whether the process is believed to be running. Only ever "alive" when
323
+ * discovery actually saw it; never guessed from file activity.
324
+ */
325
+ processLiveness?: ProcessLiveness;
326
+ /**
327
+ * INFERRED from JSONL writes, never authoritative: "active_writing" means the
328
+ * transcript grew recently, which cannot distinguish a generating agent from
329
+ * one blocked on a permission gate (gates are screen-only). Absent for
330
+ * sessions we own — their `status` is the authoritative signal.
331
+ */
332
+ activity?: SessionActivity;
333
+ }
334
+ type SessionOwnership = "managed" | "external" | "historical";
335
+ type ProcessLiveness = "alive" | "gone" | "unknown";
336
+ interface SessionActivity {
337
+ state: "active_writing" | "quiet";
338
+ lastEventAt: string;
339
+ source: "jsonl";
287
340
  }
288
341
  interface ConversationListResponse {
289
342
  conversations: unknown[];
@@ -456,6 +509,7 @@ interface ScannerMeta {
456
509
  projectPath?: string;
457
510
  projectName?: string;
458
511
  title?: string;
512
+ sessionName?: string;
459
513
  model?: string;
460
514
  account?: string;
461
515
  gitBranch?: string;
@@ -579,7 +633,8 @@ declare class ConversationCache {
579
633
  * updateFromLine in order: the agent filter short-circuits the whole batch,
580
634
  * project context is backfilled last-wins, message_count increases by the
581
635
  * number of surviving message lines, and last_activity/last_message reflect
582
- * the final message line.
636
+ * the newest message line by timestamp (a monotonic guard keeps them from
637
+ * moving backward when an interleaved writer appends an older line — P0.3).
583
638
  */
584
639
  updateFromLines(filePath: string, rawLines: string[]): void;
585
640
  upsertFromScannerMeta(metas: ScannerMeta[]): string[];
@@ -605,6 +660,12 @@ declare class ConversationCache {
605
660
  stat: FileStatEntry;
606
661
  meta: ConversationMeta;
607
662
  }>;
663
+ /**
664
+ * Conversation id for a JSONL path, or null when no row exists yet. Resolves
665
+ * by file_path (NOT conversationIdForFile) so codex rollout files — named
666
+ * rollout-<ts>-<uuid>.jsonl, whose stem is not the row id — resolve correctly.
667
+ */
668
+ getIdByFilePath(filePath: string): string | null;
608
669
  getMetaById(id: string): ConversationListItem | null;
609
670
  setConversationProjectId(conversationId: string, projectId: string): void;
610
671
  markAsStreamer(id: string): void;
@@ -628,15 +689,15 @@ declare class ConversationCache {
628
689
  /**
629
690
  * Drop the cached row for a file. Two callers with opposite intent:
630
691
  * - 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.
692
+ * `skipIfTailed: true`, which NEVER deletes (upsert-or-leave). A change
693
+ * event fires on every external append; deleting here flickers the
694
+ * conversation out of /api/conversations whether it's a live-tailed row
695
+ * the updateFromLines/warm-up path just wrote (CRITICAL #2; both watchers
696
+ * fire on the same append with no ordering guarantee) OR a refresh-created
697
+ * untailed row (a ?refresh=1 upsert never populates a tail, so the old
698
+ * "delete when untailed" behavior made it vanish on its next append with no
699
+ * client action). The live-tail path owns the row's content and the
700
+ * debounced rescan re-derives metadata, so leaving the row loses nothing.
640
701
  * - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
641
702
  * row is always removed, otherwise a deleted session ghosts in the cache.
642
703
  */
@@ -678,6 +739,30 @@ declare class ConversationCache {
678
739
  reconcileDeletions(livePaths: Set<string>, opts?: {
679
740
  exists?: (filePath: string) => boolean;
680
741
  }): string[];
742
+ /**
743
+ * Read-only: list cached rows whose `file_path` no longer exists on disk.
744
+ * Unlike pruneGhostFiles/reconcileDeletions this mutates nothing — it just
745
+ * reports drift for the CacheIntegrityMonitor to classify. `tailed` flags
746
+ * rows that still have cached history (which pruneGhostFiles would keep).
747
+ */
748
+ listMissingFiles(exists?: (filePath: string) => boolean): {
749
+ id: string;
750
+ filePath: string;
751
+ title: string | null;
752
+ tailed: boolean;
753
+ }[];
754
+ /**
755
+ * Drop the given conversation ids outright — main row, tail, and message
756
+ * index — regardless of whether they have a tail. Used by the cache-integrity
757
+ * resolution actions (prune_all / prune_selected). Returns the count dropped.
758
+ */
759
+ dropRowsById(ids: string[]): number;
760
+ /**
761
+ * Wipe all cached conversation state — meta, tails, and message index — and
762
+ * reset the in-memory file index. Only called by the `reset_rescan`
763
+ * resolution action, which repopulates from a fresh disk scan afterward.
764
+ */
765
+ clearAll(): void;
681
766
  }
682
767
 
683
768
  type CacheMetadataKey = "last_conversation_id" | "last_conversation_created_at" | "projects_last_indexed_at" | "conversations_last_indexed_at" | "conversations_dirty";
@@ -832,6 +917,89 @@ declare class WSHub {
832
917
  private startPing;
833
918
  }
834
919
 
920
+ /** One missing conversation the alert covers. */
921
+ interface MissingEntry {
922
+ id: string;
923
+ filePath: string;
924
+ title: string | null;
925
+ tailed: boolean;
926
+ }
927
+ interface PendingAlert {
928
+ fingerprint: string;
929
+ severity: "high" | "low";
930
+ detectedAt: string;
931
+ missingCount: number;
932
+ totalRows: number;
933
+ backupPath?: string;
934
+ /** Capped at 1000 entries in the persisted file. */
935
+ missing: MissingEntry[];
936
+ }
937
+
938
+ type ResolveAction = CacheAlertResolveAction;
939
+ /** The `cache_alert` WS variant, narrowed from the WSMessage union. */
940
+ type CacheAlertWsMessage = Extract<WSMessage, {
941
+ type: "cache_alert";
942
+ }>;
943
+ type ResolveResult = {
944
+ ok: true;
945
+ action: ResolveAction;
946
+ pruned?: number;
947
+ backupPath?: string;
948
+ } | {
949
+ alreadyResolved: true;
950
+ } | {
951
+ conflict: true;
952
+ currentFingerprint: string;
953
+ };
954
+ declare class CacheIntegrityMonitor {
955
+ private readonly cache;
956
+ private readonly wsHub;
957
+ private readonly log;
958
+ private readonly cacheDir;
959
+ private readonly rescan?;
960
+ private _pending;
961
+ private ignoredIds;
962
+ private deferredUnlinks;
963
+ private unlinkTimes;
964
+ constructor(cache: ConversationCache, wsHub: WSHub, log: Logger, cacheDir: string, rescan?: (() => Promise<ScannerMeta[]>) | undefined);
965
+ get pending(): PendingAlert | null;
966
+ private persist;
967
+ private classifySeverity;
968
+ private sampleOf;
969
+ private buildWsMessage;
970
+ wsMessage(): CacheAlertWsMessage | null;
971
+ healthzField(): {
972
+ severity: "high" | "low";
973
+ missingCount: number;
974
+ fingerprint: string;
975
+ detectedAt: string;
976
+ } | undefined;
977
+ /**
978
+ * Scan the cache for rows whose file is gone, excluding ids the user chose to
979
+ * ignore. If none remain, clear any stale pending alert and return (the caller
980
+ * decides whether to run pruneGhostFiles). Otherwise classify severity, persist
981
+ * the pending record, back up on high severity, and broadcast the alert.
982
+ */
983
+ runDetection(detectedAt?: string): Promise<void>;
984
+ /** Queue an unlink while an alert is pending — the row is not invalidated. */
985
+ deferUnlink(filePath: string): void;
986
+ /**
987
+ * Record a live unlink while NO alert is pending. Crossing the storm threshold
988
+ * (>= 10 unlinks within 30s) re-triggers detection.
989
+ */
990
+ recordUnlink(filePath: string): void;
991
+ private ensureBackup;
992
+ private clearPending;
993
+ private applyDeferredUnlinks;
994
+ private broadcastResolved;
995
+ /**
996
+ * Apply the human's chosen resolution. Idempotent per fingerprint: no pending
997
+ * alert → alreadyResolved; a different fingerprint → conflict. See the spec's
998
+ * four-action semantics.
999
+ */
1000
+ resolve(fingerprint: string, action: ResolveAction, ids?: string[]): Promise<ResolveResult>;
1001
+ }
1002
+
835
1003
  type ApiDeps = {
836
1004
  apiKey: string;
837
1005
  localNoAuth: boolean;
@@ -847,6 +1015,7 @@ type ApiDeps = {
847
1015
  sessionStore: SessionStore;
848
1016
  wsHub: WSHub;
849
1017
  cache: () => ConversationCache | null;
1018
+ cacheMonitor: () => CacheIntegrityMonitor | null;
850
1019
  projectsRepo: () => ProjectsRepository | null;
851
1020
  conversationsRepo: () => ConversationsRepository | null;
852
1021
  sessionsRepo: () => SessionsRepository | null;
@@ -990,6 +1159,7 @@ declare class PTYManager implements SessionRunner {
990
1159
  private handleOutput;
991
1160
  private detectLivePrompts;
992
1161
  private handleQuiet;
1162
+ private recheckReadyFromScreen;
993
1163
  private markReady;
994
1164
  private handleExit;
995
1165
  }
@@ -1001,8 +1171,11 @@ declare class StreamerServer {
1001
1171
  private wsHub;
1002
1172
  private fileWatcher;
1003
1173
  private sessionFileMap;
1174
+ private externalTails;
1004
1175
  private pendingLineSeqs;
1005
1176
  private pendingQuestions;
1177
+ private contendedSessions;
1178
+ private selfPtyEndedAt;
1006
1179
  private pendingQuestionKey;
1007
1180
  private pendingPermission;
1008
1181
  private scanner;
@@ -1036,10 +1209,12 @@ declare class StreamerServer {
1036
1209
  private defaultModel;
1037
1210
  private defaultEffort;
1038
1211
  private ptyGraceTimers;
1212
+ private ptyGraceDeferCounts;
1039
1213
  private sessionSubscribers;
1040
1214
  private clientIdToWs;
1041
1215
  private wsToClientId;
1042
1216
  private cache;
1217
+ private cacheMonitor;
1043
1218
  private projectsRepo;
1044
1219
  private conversationsRepo;
1045
1220
  private sessionsRepo;
@@ -1061,6 +1236,7 @@ declare class StreamerServer {
1061
1236
  apiKey: string;
1062
1237
  });
1063
1238
  private ptyAttachedIds;
1239
+ private rememberSelfPtyEnded;
1064
1240
  /**
1065
1241
  * Send a session_list to only the client that triggered this HTTP request
1066
1242
  * (identified by X-Client-Id header → registered WS socket). Falls back to
@@ -1126,6 +1302,43 @@ declare class StreamerServer {
1126
1302
  * Claude lines pass through unchanged so seq alignment stays intact.
1127
1303
  */
1128
1304
  private broadcastConversationLines;
1305
+ /** True when a managed (PTY) session owns the tail for this canonical path. */
1306
+ private isManagedTailPath;
1307
+ /**
1308
+ * Attach a live tail to a JSONL nobody is tailing yet, when it was touched
1309
+ * recently enough to look actively written by an external agent. Capped at
1310
+ * EXTERNAL_TAIL_MAX with LRU eviction.
1311
+ */
1312
+ private maybeAttachExternalTail;
1313
+ /** Stop tailing an external file and drop its bookkeeping. */
1314
+ private detachExternalTail;
1315
+ /** Make room for one more tail by evicting the least recently active ones. */
1316
+ private evictExternalTailsIfNeeded;
1317
+ /**
1318
+ * INFERRED activity for an externally-owned conversation, derived purely from
1319
+ * how recently its JSONL grew (the external tail's bookkeeping). Returns
1320
+ * undefined when we hold no tail for it, so a session we know nothing about
1321
+ * reports no activity rather than a fabricated "quiet".
1322
+ *
1323
+ * This can never distinguish a generating agent from one blocked on a
1324
+ * permission gate — gates render on the PTY screen and never reach the JSONL —
1325
+ * which is why it is a separate field and not folded into `status`.
1326
+ */
1327
+ private externalActivityFor;
1328
+ /** Attach inferred `activity` to externally-owned sessions in a response set. */
1329
+ private withExternalActivity;
1330
+ /** Detach external tails idle past EXTERNAL_TAIL_IDLE_MS. */
1331
+ private sweepIdleExternalTails;
1332
+ /**
1333
+ * Push appended lines from an externally-owned conversation. Reuses the exact
1334
+ * conversation_events / conversation_event shapes mobile already consumes,
1335
+ * keyed by the conversation UUID — an external session has no PTY, so it must
1336
+ * never produce terminal_output / terminal_replay / session_ready, and never a
1337
+ * session_update whose session.id is a conversation UUID (that would mint a
1338
+ * phantom session row in the mobile cache). Question cards are likewise never
1339
+ * derived here: with no PTY there is nothing that could deliver an answer.
1340
+ */
1341
+ private broadcastExternalTailLines;
1129
1342
  private findConversationByUuid;
1130
1343
  private isConversationSnapshotStale;
1131
1344
  private handleGetConversation;
@@ -1136,6 +1349,7 @@ declare class StreamerServer {
1136
1349
  private handleResume;
1137
1350
  private enrichResumedSessionAsync;
1138
1351
  private handleSendInput;
1352
+ private processJsonlQuestions;
1139
1353
  private cancelPendingQuestion;
1140
1354
  private handleLiveQuestion;
1141
1355
  private handlePermissionChange;
@@ -1148,6 +1362,7 @@ declare class StreamerServer {
1148
1362
  private handleStartSession;
1149
1363
  private linkSessionToProject;
1150
1364
  private watchConversationFile;
1365
+ private readFirstLineSessionId;
1151
1366
  private watchForJsonl;
1152
1367
  private watchForCodexRollout;
1153
1368
  private handleBrowse;
@@ -1179,6 +1394,13 @@ interface ConversationWatcherEvents {
1179
1394
  onConversationChanged?: (filePath: string) => void | Promise<void>;
1180
1395
  /** Fires when a tailed file is deleted (per-file watcher unlink event). */
1181
1396
  onFileDeleted?: (filePath: string) => void;
1397
+ /**
1398
+ * Fires when a tailed file shrank below our read offset (in-place truncation
1399
+ * or replacement by a shorter file). The tail has already reset to byte 0;
1400
+ * the consumer must discard any byte-offset index built for the old content,
1401
+ * which no longer describes this file.
1402
+ */
1403
+ onTruncated?: (filePath: string) => void;
1182
1404
  /** Reported errors per file. */
1183
1405
  onError?: (filePath: string, error: Error) => void;
1184
1406
  }
@@ -1201,6 +1423,7 @@ declare class ConversationWatcher {
1201
1423
  private onNewLineSpans;
1202
1424
  private onConversationChanged;
1203
1425
  private onFileDeleted;
1426
+ private onTruncated;
1204
1427
  private onError;
1205
1428
  constructor(events?: ConversationWatcherEvents);
1206
1429
  watch(filePath: string): void;
@@ -1225,4 +1448,4 @@ declare class ConversationWatcher {
1225
1448
  private readNewLines;
1226
1449
  }
1227
1450
 
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 };
1451
+ 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 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 };