@threadbase-sh/streamer 1.44.4 → 1.46.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
@@ -824,6 +824,22 @@ declare class ConversationCache {
824
824
  name: string;
825
825
  sessionCount: number;
826
826
  }>;
827
+ /** Every project with at least one cached conversation, most recently active
828
+ * first. Paths are the raw `project_path` values, which is what
829
+ * /api/conversations?project= matches on exactly — so a summary row is
830
+ * always joinable against the page it describes. */
831
+ listProjectSummaries(opts: {
832
+ limit: number;
833
+ offset: number;
834
+ }): {
835
+ projects: Array<{
836
+ path: string;
837
+ name: string;
838
+ conversationCount: number;
839
+ lastActivity: string;
840
+ }>;
841
+ total: number;
842
+ };
827
843
  private ensureFileIndex;
828
844
  updateFromLine(filePath: string, rawLine: string): void;
829
845
  /**
@@ -1689,6 +1705,7 @@ type ApiDeps = {
1689
1705
  handleSearchTarget: (id: string, req: IncomingMessage, res: ServerResponse) => Promise<void>;
1690
1706
  handleListProjects: (url: URL, res: ServerResponse) => void;
1691
1707
  handleGetPopularProjects: (url: URL, res: ServerResponse) => void;
1708
+ handleGetProjectSummaries: (url: URL, res: ServerResponse) => void;
1692
1709
  handlePairStart: (res: ServerResponse) => void;
1693
1710
  handlePairExchange: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
1694
1711
  handleBrowse: (url: URL, res: ServerResponse) => Promise<void>;
@@ -1775,8 +1792,11 @@ declare class PTYManager implements SessionRunner {
1775
1792
  private onLiveQuestionGone;
1776
1793
  private onUserMessage;
1777
1794
  private permissionOpen;
1795
+ private oscTail;
1796
+ private lastDetectAt;
1778
1797
  private lastScreenQuestionKey;
1779
1798
  private shellPromptOpen;
1799
+ private closedGateKey;
1780
1800
  private pendingReady;
1781
1801
  private queuedInputs;
1782
1802
  private log;
@@ -2116,6 +2136,7 @@ declare class StreamerServer {
2116
2136
  private handleSessionsCount;
2117
2137
  private handleGetRecentSessions;
2118
2138
  private handleGetPopularProjects;
2139
+ private handleGetProjectSummaries;
2119
2140
  private buildStatCache;
2120
2141
  private codexScanOpts;
2121
2142
  private newScanner;
package/dist/index.d.ts CHANGED
@@ -824,6 +824,22 @@ declare class ConversationCache {
824
824
  name: string;
825
825
  sessionCount: number;
826
826
  }>;
827
+ /** Every project with at least one cached conversation, most recently active
828
+ * first. Paths are the raw `project_path` values, which is what
829
+ * /api/conversations?project= matches on exactly — so a summary row is
830
+ * always joinable against the page it describes. */
831
+ listProjectSummaries(opts: {
832
+ limit: number;
833
+ offset: number;
834
+ }): {
835
+ projects: Array<{
836
+ path: string;
837
+ name: string;
838
+ conversationCount: number;
839
+ lastActivity: string;
840
+ }>;
841
+ total: number;
842
+ };
827
843
  private ensureFileIndex;
828
844
  updateFromLine(filePath: string, rawLine: string): void;
829
845
  /**
@@ -1689,6 +1705,7 @@ type ApiDeps = {
1689
1705
  handleSearchTarget: (id: string, req: IncomingMessage, res: ServerResponse) => Promise<void>;
1690
1706
  handleListProjects: (url: URL, res: ServerResponse) => void;
1691
1707
  handleGetPopularProjects: (url: URL, res: ServerResponse) => void;
1708
+ handleGetProjectSummaries: (url: URL, res: ServerResponse) => void;
1692
1709
  handlePairStart: (res: ServerResponse) => void;
1693
1710
  handlePairExchange: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
1694
1711
  handleBrowse: (url: URL, res: ServerResponse) => Promise<void>;
@@ -1775,8 +1792,11 @@ declare class PTYManager implements SessionRunner {
1775
1792
  private onLiveQuestionGone;
1776
1793
  private onUserMessage;
1777
1794
  private permissionOpen;
1795
+ private oscTail;
1796
+ private lastDetectAt;
1778
1797
  private lastScreenQuestionKey;
1779
1798
  private shellPromptOpen;
1799
+ private closedGateKey;
1780
1800
  private pendingReady;
1781
1801
  private queuedInputs;
1782
1802
  private log;
@@ -2116,6 +2136,7 @@ declare class StreamerServer {
2116
2136
  private handleSessionsCount;
2117
2137
  private handleGetRecentSessions;
2118
2138
  private handleGetPopularProjects;
2139
+ private handleGetProjectSummaries;
2119
2140
  private buildStatCache;
2120
2141
  private codexScanOpts;
2121
2142
  private newScanner;
package/dist/index.js CHANGED
@@ -2120,14 +2120,19 @@ function scrapePermissionGate(lines) {
2120
2120
  const options = [];
2121
2121
  let cursor;
2122
2122
  let firstOptionLine = -1;
2123
- for (let i = 0; i < lines.length; i++) {
2124
- const m = OPTION_RE.exec(stripGutter(lines[i]));
2125
- if (!m) continue;
2126
- const index = Number.parseInt(m[2], 10);
2127
- if (!Number.isFinite(index)) continue;
2128
- if (firstOptionLine === -1) firstOptionLine = i;
2129
- if (m[1]) cursor = index;
2130
- options.push({ index, label: m[3] });
2123
+ for (let i = lines.length - 1; i >= 0; i--) {
2124
+ const stripped = stripGutter(lines[i]);
2125
+ const m = OPTION_RE.exec(stripped);
2126
+ if (m) {
2127
+ const index = Number.parseInt(m[2], 10);
2128
+ if (!Number.isFinite(index)) continue;
2129
+ firstOptionLine = i;
2130
+ if (m[1]) cursor = index;
2131
+ options.unshift({ index, label: m[3] });
2132
+ continue;
2133
+ }
2134
+ if (options.length === 0) continue;
2135
+ if (stripped.trim().length === 0 || BOX_ONLY_RE.test(lines[i].trim())) break;
2131
2136
  }
2132
2137
  if (options.length === 0) return null;
2133
2138
  let prompt;
@@ -2167,6 +2172,17 @@ function scrapeDetail(lines, promptLine) {
2167
2172
  }
2168
2173
  return collected.length > 0 ? collected.reverse().join("\n") : void 0;
2169
2174
  }
2175
+ var GATE_FOOTER_RE = /esc to cancel/i;
2176
+ var ASK_MENU_FOOTER_RE = /Enter to select/i;
2177
+ var YES_NO_LABEL_RE = /^(yes|no)\b/i;
2178
+ function detectGateScreen(lines) {
2179
+ if (lines.some((l) => ASK_MENU_FOOTER_RE.test(l))) return null;
2180
+ if (!lines.some((l) => GATE_FOOTER_RE.test(l))) return null;
2181
+ const gate = scrapePermissionGate(lines);
2182
+ if (!gate || gate.options.length < 2) return null;
2183
+ if (!gate.options.some((o) => YES_NO_LABEL_RE.test(o.label))) return null;
2184
+ return gate;
2185
+ }
2170
2186
 
2171
2187
  // src/services/questions/detectQuestionFromScreen.ts
2172
2188
  var ASK_FOOTER_RE = /Enter to select/i;
@@ -2314,6 +2330,8 @@ var PTY_ROWS2 = 40;
2314
2330
  var SCREEN_SCROLLBACK2 = 1e3;
2315
2331
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
2316
2332
  var QUIET_DETECT_MS2 = 500;
2333
+ var OSC_TAIL_CHARS = 128;
2334
+ var SCRAPE_THROTTLE_MS = 300;
2317
2335
  var CLAUDE_READY_FALLBACK_MS = 8e3;
2318
2336
  function buildPasteBytes(input) {
2319
2337
  return `\x1B[200~${input}\x1B[201~`;
@@ -2372,6 +2390,12 @@ var PTYManager = class {
2372
2390
  // the next prompt-ready without a fresh 777 (gate closed). Prevents
2373
2391
  // re-broadcasting open/close on every chunk.
2374
2392
  permissionOpen = /* @__PURE__ */ new Set();
2393
+ // Last chunk's raw tail per session — prepended to the next chunk before
2394
+ // the OSC regex test so a split escape still matches. Consumed on match.
2395
+ oscTail = /* @__PURE__ */ new Map();
2396
+ // When the last full detection pass ran per session (any trigger) — the
2397
+ // clock the SCRAPE_THROTTLE_MS ceiling is measured against.
2398
+ lastDetectAt = /* @__PURE__ */ new Map();
2375
2399
  // Content key of the last AskUserQuestion broadcast from the rendered screen,
2376
2400
  // per session — de-dupes the same menu firing on consecutive repaints.
2377
2401
  lastScreenQuestionKey = /* @__PURE__ */ new Map();
@@ -2380,6 +2404,13 @@ var PTYManager = class {
2380
2404
  // on a prompt-ready/marker return and de-dupe consecutive repaints. Modelled
2381
2405
  // on permissionOpen but keyed by content (a shell prompt has no OSC trigger).
2382
2406
  shellPromptOpen = /* @__PURE__ */ new Map();
2407
+ // Content key (permissionContentKey, cursor excluded — see arm 3) of the
2408
+ // gate that was still painted on screen when arm 2 closed it. Suppresses
2409
+ // arm 3 re-claiming that same box from a later trigger-less/throttled pass
2410
+ // before Claude erases it — otherwise the paint-time claim reopens the
2411
+ // card it just closed. Cleared once detectGateScreen sees the box is gone,
2412
+ // so a genuinely new (even content-identical) gate is claimed normally.
2413
+ closedGateKey = /* @__PURE__ */ new Map();
2383
2414
  // Tracks sessions (both fresh and resume) whose PTY has spawned but Claude
2384
2415
  // hasn't yet reached an interactive prompt — i.e. onReady hasn't fired.
2385
2416
  pendingReady = /* @__PURE__ */ new Set();
@@ -2733,8 +2764,11 @@ var PTYManager = class {
2733
2764
  this.queuedInputs.delete(sessionId);
2734
2765
  this.firstChunkAt.delete(sessionId);
2735
2766
  this.permissionOpen.delete(sessionId);
2767
+ this.oscTail.delete(sessionId);
2768
+ this.lastDetectAt.delete(sessionId);
2736
2769
  this.lastScreenQuestionKey.delete(sessionId);
2737
2770
  this.shellPromptOpen.delete(sessionId);
2771
+ this.closedGateKey.delete(sessionId);
2738
2772
  this.quietCheckers.get(sessionId)?.cancel();
2739
2773
  this.quietCheckers.delete(sessionId);
2740
2774
  this.clearReadyFallback(sessionId);
@@ -2829,8 +2863,11 @@ var PTYManager = class {
2829
2863
  for (const timer of this.readyFallbackTimers.values()) clearTimeout(timer);
2830
2864
  this.readyFallbackTimers.clear();
2831
2865
  this.permissionOpen.clear();
2866
+ this.oscTail.clear();
2867
+ this.lastDetectAt.clear();
2832
2868
  this.lastScreenQuestionKey.clear();
2833
2869
  this.shellPromptOpen.clear();
2870
+ this.closedGateKey.clear();
2834
2871
  }
2835
2872
  handleOutput(sessionId, data) {
2836
2873
  const session = this.sessions.get(sessionId);
@@ -2894,16 +2931,24 @@ var PTYManager = class {
2894
2931
  async detectLivePrompts(sessionId, rawData, stripped) {
2895
2932
  const session = this.sessions.get(sessionId);
2896
2933
  if (!session) return;
2897
- const oscPermission = hasPermissionOsc(rawData);
2898
- const oscWaitingForInput = hasWaitingForInputOsc(rawData);
2934
+ const oscWindow = (this.oscTail.get(sessionId) ?? "") + rawData;
2935
+ const oscPermission = hasPermissionOsc(oscWindow);
2936
+ const oscWaitingForInput = hasWaitingForInputOsc(oscWindow);
2937
+ if (rawData !== "") {
2938
+ if (oscPermission || oscWaitingForInput) this.oscTail.delete(sessionId);
2939
+ else this.oscTail.set(sessionId, oscWindow.slice(-OSC_TAIL_CHARS));
2940
+ }
2899
2941
  const hasAskFooter = /Enter to select/i.test(stripped);
2900
2942
  const hasPromptMarker = CLAUDE_PROMPT_MARKERS.some((m) => stripped.includes(m));
2901
2943
  const hasShellPromptHint = /[[(]\s*y\s*\/\s*n\s*[\])]|press\s+(enter|return|any key)|\bcontinue\b\s*\?|^\s*(?:❯|>)?\s*\d+[.)]\s+\S/im.test(
2902
2944
  stripped
2903
2945
  );
2904
- if (!oscPermission && !oscWaitingForInput && !hasAskFooter && !hasShellPromptHint && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
2946
+ const nowMs = Date.now();
2947
+ const scrapeDue = nowMs - (this.lastDetectAt.get(sessionId) ?? 0) >= SCRAPE_THROTTLE_MS;
2948
+ if (!oscPermission && !oscWaitingForInput && !hasAskFooter && !hasShellPromptHint && !scrapeDue && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
2905
2949
  return;
2906
2950
  }
2951
+ this.lastDetectAt.set(sessionId, nowMs);
2907
2952
  const lines = await this.getOutputLines(sessionId, 60);
2908
2953
  const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
2909
2954
  if (oscPermission || hasAskFooter || askFooterOnScreen) {
@@ -2923,13 +2968,30 @@ var PTYManager = class {
2923
2968
  this.permissionOpen.add(sessionId);
2924
2969
  this.onPermissionChange?.(sessionId, gate ?? { options: [] });
2925
2970
  } else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
2926
- const gate = scrapePermissionGate(lines);
2927
- if (oscWaitingForInput || !gate && hasPromptMarker) {
2971
+ const gate = detectGateScreen(lines);
2972
+ const stillPainted = gate !== null || scrapePermissionGate(lines) !== null;
2973
+ if (oscWaitingForInput || !stillPainted && hasPromptMarker) {
2928
2974
  this.permissionOpen.delete(sessionId);
2929
2975
  this.onPermissionChange?.(sessionId, null);
2976
+ if (gate) {
2977
+ this.closedGateKey.set(sessionId, permissionContentKey({ ...gate, cursor: void 0 }));
2978
+ } else {
2979
+ this.closedGateKey.delete(sessionId);
2980
+ }
2930
2981
  } else if (gate) {
2931
2982
  this.onPermissionChange?.(sessionId, gate);
2932
2983
  }
2984
+ } else if (!askFooterOnScreen && !oscWaitingForInput) {
2985
+ const gate = detectGateScreen(lines);
2986
+ if (gate) {
2987
+ const key = permissionContentKey({ ...gate, cursor: void 0 });
2988
+ if (this.closedGateKey.get(sessionId) !== key) {
2989
+ this.permissionOpen.add(sessionId);
2990
+ this.onPermissionChange?.(sessionId, gate);
2991
+ }
2992
+ } else {
2993
+ this.closedGateKey.delete(sessionId);
2994
+ }
2933
2995
  }
2934
2996
  if (askFooterOnScreen) {
2935
2997
  const detected = detectQuestionFromScreen(lines);
@@ -3072,8 +3134,11 @@ var PTYManager = class {
3072
3134
  this.queuedInputs.delete(sessionId);
3073
3135
  this.firstChunkAt.delete(sessionId);
3074
3136
  this.permissionOpen.delete(sessionId);
3137
+ this.oscTail.delete(sessionId);
3138
+ this.lastDetectAt.delete(sessionId);
3075
3139
  this.lastScreenQuestionKey.delete(sessionId);
3076
3140
  this.shellPromptOpen.delete(sessionId);
3141
+ this.closedGateKey.delete(sessionId);
3077
3142
  this.quietCheckers.get(sessionId)?.cancel();
3078
3143
  this.quietCheckers.delete(sessionId);
3079
3144
  this.clearReadyFallback(sessionId);
@@ -5241,7 +5306,10 @@ var createMiscRoutes = (deps) => {
5241
5306
  // Same contract: this server serves GET /api/config/feature-flags. Lives
5242
5307
  // here rather than behind /api/config (admin-only) so a read-only client
5243
5308
  // still learns the server supports flags even if it can't read values.
5244
- featureFlags: true
5309
+ featureFlags: true,
5310
+ // Same contract: this server serves GET /api/projects/summary, which the
5311
+ // Hub's grouped views need before they can draw a tree.
5312
+ projectSummary: true
5245
5313
  });
5246
5314
  });
5247
5315
  app.get("/api/profiles", (c) => c.json([]));
@@ -5388,6 +5456,11 @@ var createProjectRoutes = (deps) => {
5388
5456
  deps.handleGetPopularProjects(url, c.env.outgoing);
5389
5457
  return alreadyHandled4();
5390
5458
  });
5459
+ app.get("/summary", (c) => {
5460
+ const url = new URL(c.req.url);
5461
+ deps.handleGetProjectSummaries(url, c.env.outgoing);
5462
+ return alreadyHandled4();
5463
+ });
5391
5464
  return app;
5392
5465
  };
5393
5466
 
@@ -6255,6 +6328,21 @@ var ConversationCache = class _ConversationCache {
6255
6328
  ORDER BY cnt DESC
6256
6329
  LIMIT ?`
6257
6330
  ),
6331
+ // Same table, same rows and same NULL filter /api/conversations lists
6332
+ // from, so a group's count/last-activity can never disagree with the
6333
+ // page it opens. Bare project_name is the one from the MAX(last_activity)
6334
+ // row (SQLite's documented min/max-aggregate bare-column rule).
6335
+ projectSummaries: db.prepare(
6336
+ `SELECT project_path, project_name, COUNT(*) as cnt, MAX(last_activity) as latest
6337
+ FROM conversation_meta
6338
+ WHERE project_path IS NOT NULL
6339
+ GROUP BY project_path
6340
+ ORDER BY latest DESC, project_path ASC
6341
+ LIMIT ? OFFSET ?`
6342
+ ),
6343
+ projectSummaryCount: db.prepare(
6344
+ "SELECT COUNT(DISTINCT project_path) as n FROM conversation_meta WHERE project_path IS NOT NULL"
6345
+ ),
6258
6346
  getFileState: db.prepare("SELECT * FROM conversation_file_state WHERE path = ?"),
6259
6347
  upsertFileState: db.prepare(
6260
6348
  `INSERT INTO conversation_file_state
@@ -6630,6 +6718,23 @@ var ConversationCache = class _ConversationCache {
6630
6718
  sessionCount: r.cnt
6631
6719
  }));
6632
6720
  }
6721
+ /** Every project with at least one cached conversation, most recently active
6722
+ * first. Paths are the raw `project_path` values, which is what
6723
+ * /api/conversations?project= matches on exactly — so a summary row is
6724
+ * always joinable against the page it describes. */
6725
+ listProjectSummaries(opts) {
6726
+ const total = this.stmts.projectSummaryCount.get().n;
6727
+ const rows = opts.limit === 0 ? [] : this.stmts.projectSummaries.all(opts.limit, opts.offset);
6728
+ return {
6729
+ total,
6730
+ projects: rows.map((r) => ({
6731
+ path: r.project_path,
6732
+ name: r.project_name ?? r.project_path.split(/[/\\]/).pop() ?? r.project_path,
6733
+ conversationCount: r.cnt,
6734
+ lastActivity: new Date(r.latest ?? 0).toISOString()
6735
+ }))
6736
+ };
6737
+ }
6633
6738
  ensureFileIndex() {
6634
6739
  if (this.fileIndexLoaded) return;
6635
6740
  const rows = this.stmts.allFilePaths.all();
@@ -10771,6 +10876,7 @@ var StreamerServer = class {
10771
10876
  handleSearchTarget: (id, req, res) => this.handleSearchTarget(id, req, res),
10772
10877
  handleListProjects: (url, res) => handleListProjects(url, res),
10773
10878
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
10879
+ handleGetProjectSummaries: (url, res) => this.handleGetProjectSummaries(url, res),
10774
10880
  handlePairStart: (res) => this.handlePairStart(res),
10775
10881
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
10776
10882
  handleBrowse: (url, res) => this.handleBrowse(url, res),
@@ -12336,6 +12442,20 @@ var StreamerServer = class {
12336
12442
  const projects = this.cache.getPopularProjects(limit);
12337
12443
  json(res, 200, { projects, total: projects.length });
12338
12444
  }
12445
+ handleGetProjectSummaries(url, res) {
12446
+ if (this.rejectIfWarmingUp(res)) return;
12447
+ const limit = intParam(url, "limit", 200);
12448
+ const offset = intParam(url, "offset", 0);
12449
+ if (!this.cache) {
12450
+ json(res, 503, {
12451
+ error: "Conversation cache unavailable",
12452
+ code: "CACHE_UNAVAILABLE"
12453
+ });
12454
+ return;
12455
+ }
12456
+ const { projects, total } = this.cache.listProjectSummaries({ limit, offset });
12457
+ json(res, 200, { projects, total, offset, hasMore: offset + projects.length < total });
12458
+ }
12339
12459
  buildStatCache(previousScanner) {
12340
12460
  if (!this.cache) return void 0;
12341
12461
  if (!previousScanner) {