@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.cjs CHANGED
@@ -2173,14 +2173,19 @@ function scrapePermissionGate(lines) {
2173
2173
  const options = [];
2174
2174
  let cursor;
2175
2175
  let firstOptionLine = -1;
2176
- for (let i = 0; i < lines.length; i++) {
2177
- const m = OPTION_RE.exec(stripGutter(lines[i]));
2178
- if (!m) continue;
2179
- const index = Number.parseInt(m[2], 10);
2180
- if (!Number.isFinite(index)) continue;
2181
- if (firstOptionLine === -1) firstOptionLine = i;
2182
- if (m[1]) cursor = index;
2183
- options.push({ index, label: m[3] });
2176
+ for (let i = lines.length - 1; i >= 0; i--) {
2177
+ const stripped = stripGutter(lines[i]);
2178
+ const m = OPTION_RE.exec(stripped);
2179
+ if (m) {
2180
+ const index = Number.parseInt(m[2], 10);
2181
+ if (!Number.isFinite(index)) continue;
2182
+ firstOptionLine = i;
2183
+ if (m[1]) cursor = index;
2184
+ options.unshift({ index, label: m[3] });
2185
+ continue;
2186
+ }
2187
+ if (options.length === 0) continue;
2188
+ if (stripped.trim().length === 0 || BOX_ONLY_RE.test(lines[i].trim())) break;
2184
2189
  }
2185
2190
  if (options.length === 0) return null;
2186
2191
  let prompt;
@@ -2220,6 +2225,17 @@ function scrapeDetail(lines, promptLine) {
2220
2225
  }
2221
2226
  return collected.length > 0 ? collected.reverse().join("\n") : void 0;
2222
2227
  }
2228
+ var GATE_FOOTER_RE = /esc to cancel/i;
2229
+ var ASK_MENU_FOOTER_RE = /Enter to select/i;
2230
+ var YES_NO_LABEL_RE = /^(yes|no)\b/i;
2231
+ function detectGateScreen(lines) {
2232
+ if (lines.some((l) => ASK_MENU_FOOTER_RE.test(l))) return null;
2233
+ if (!lines.some((l) => GATE_FOOTER_RE.test(l))) return null;
2234
+ const gate = scrapePermissionGate(lines);
2235
+ if (!gate || gate.options.length < 2) return null;
2236
+ if (!gate.options.some((o) => YES_NO_LABEL_RE.test(o.label))) return null;
2237
+ return gate;
2238
+ }
2223
2239
 
2224
2240
  // src/services/questions/detectQuestionFromScreen.ts
2225
2241
  var ASK_FOOTER_RE = /Enter to select/i;
@@ -2367,6 +2383,8 @@ var PTY_ROWS2 = 40;
2367
2383
  var SCREEN_SCROLLBACK2 = 1e3;
2368
2384
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
2369
2385
  var QUIET_DETECT_MS2 = 500;
2386
+ var OSC_TAIL_CHARS = 128;
2387
+ var SCRAPE_THROTTLE_MS = 300;
2370
2388
  var CLAUDE_READY_FALLBACK_MS = 8e3;
2371
2389
  function buildPasteBytes(input) {
2372
2390
  return `\x1B[200~${input}\x1B[201~`;
@@ -2425,6 +2443,12 @@ var PTYManager = class {
2425
2443
  // the next prompt-ready without a fresh 777 (gate closed). Prevents
2426
2444
  // re-broadcasting open/close on every chunk.
2427
2445
  permissionOpen = /* @__PURE__ */ new Set();
2446
+ // Last chunk's raw tail per session — prepended to the next chunk before
2447
+ // the OSC regex test so a split escape still matches. Consumed on match.
2448
+ oscTail = /* @__PURE__ */ new Map();
2449
+ // When the last full detection pass ran per session (any trigger) — the
2450
+ // clock the SCRAPE_THROTTLE_MS ceiling is measured against.
2451
+ lastDetectAt = /* @__PURE__ */ new Map();
2428
2452
  // Content key of the last AskUserQuestion broadcast from the rendered screen,
2429
2453
  // per session — de-dupes the same menu firing on consecutive repaints.
2430
2454
  lastScreenQuestionKey = /* @__PURE__ */ new Map();
@@ -2433,6 +2457,13 @@ var PTYManager = class {
2433
2457
  // on a prompt-ready/marker return and de-dupe consecutive repaints. Modelled
2434
2458
  // on permissionOpen but keyed by content (a shell prompt has no OSC trigger).
2435
2459
  shellPromptOpen = /* @__PURE__ */ new Map();
2460
+ // Content key (permissionContentKey, cursor excluded — see arm 3) of the
2461
+ // gate that was still painted on screen when arm 2 closed it. Suppresses
2462
+ // arm 3 re-claiming that same box from a later trigger-less/throttled pass
2463
+ // before Claude erases it — otherwise the paint-time claim reopens the
2464
+ // card it just closed. Cleared once detectGateScreen sees the box is gone,
2465
+ // so a genuinely new (even content-identical) gate is claimed normally.
2466
+ closedGateKey = /* @__PURE__ */ new Map();
2436
2467
  // Tracks sessions (both fresh and resume) whose PTY has spawned but Claude
2437
2468
  // hasn't yet reached an interactive prompt — i.e. onReady hasn't fired.
2438
2469
  pendingReady = /* @__PURE__ */ new Set();
@@ -2786,8 +2817,11 @@ var PTYManager = class {
2786
2817
  this.queuedInputs.delete(sessionId);
2787
2818
  this.firstChunkAt.delete(sessionId);
2788
2819
  this.permissionOpen.delete(sessionId);
2820
+ this.oscTail.delete(sessionId);
2821
+ this.lastDetectAt.delete(sessionId);
2789
2822
  this.lastScreenQuestionKey.delete(sessionId);
2790
2823
  this.shellPromptOpen.delete(sessionId);
2824
+ this.closedGateKey.delete(sessionId);
2791
2825
  this.quietCheckers.get(sessionId)?.cancel();
2792
2826
  this.quietCheckers.delete(sessionId);
2793
2827
  this.clearReadyFallback(sessionId);
@@ -2882,8 +2916,11 @@ var PTYManager = class {
2882
2916
  for (const timer of this.readyFallbackTimers.values()) clearTimeout(timer);
2883
2917
  this.readyFallbackTimers.clear();
2884
2918
  this.permissionOpen.clear();
2919
+ this.oscTail.clear();
2920
+ this.lastDetectAt.clear();
2885
2921
  this.lastScreenQuestionKey.clear();
2886
2922
  this.shellPromptOpen.clear();
2923
+ this.closedGateKey.clear();
2887
2924
  }
2888
2925
  handleOutput(sessionId, data) {
2889
2926
  const session = this.sessions.get(sessionId);
@@ -2947,16 +2984,24 @@ var PTYManager = class {
2947
2984
  async detectLivePrompts(sessionId, rawData, stripped) {
2948
2985
  const session = this.sessions.get(sessionId);
2949
2986
  if (!session) return;
2950
- const oscPermission = hasPermissionOsc(rawData);
2951
- const oscWaitingForInput = hasWaitingForInputOsc(rawData);
2987
+ const oscWindow = (this.oscTail.get(sessionId) ?? "") + rawData;
2988
+ const oscPermission = hasPermissionOsc(oscWindow);
2989
+ const oscWaitingForInput = hasWaitingForInputOsc(oscWindow);
2990
+ if (rawData !== "") {
2991
+ if (oscPermission || oscWaitingForInput) this.oscTail.delete(sessionId);
2992
+ else this.oscTail.set(sessionId, oscWindow.slice(-OSC_TAIL_CHARS));
2993
+ }
2952
2994
  const hasAskFooter = /Enter to select/i.test(stripped);
2953
2995
  const hasPromptMarker = CLAUDE_PROMPT_MARKERS.some((m) => stripped.includes(m));
2954
2996
  const hasShellPromptHint = /[[(]\s*y\s*\/\s*n\s*[\])]|press\s+(enter|return|any key)|\bcontinue\b\s*\?|^\s*(?:❯|>)?\s*\d+[.)]\s+\S/im.test(
2955
2997
  stripped
2956
2998
  );
2957
- if (!oscPermission && !oscWaitingForInput && !hasAskFooter && !hasShellPromptHint && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
2999
+ const nowMs = Date.now();
3000
+ const scrapeDue = nowMs - (this.lastDetectAt.get(sessionId) ?? 0) >= SCRAPE_THROTTLE_MS;
3001
+ if (!oscPermission && !oscWaitingForInput && !hasAskFooter && !hasShellPromptHint && !scrapeDue && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
2958
3002
  return;
2959
3003
  }
3004
+ this.lastDetectAt.set(sessionId, nowMs);
2960
3005
  const lines = await this.getOutputLines(sessionId, 60);
2961
3006
  const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
2962
3007
  if (oscPermission || hasAskFooter || askFooterOnScreen) {
@@ -2976,13 +3021,30 @@ var PTYManager = class {
2976
3021
  this.permissionOpen.add(sessionId);
2977
3022
  this.onPermissionChange?.(sessionId, gate ?? { options: [] });
2978
3023
  } else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
2979
- const gate = scrapePermissionGate(lines);
2980
- if (oscWaitingForInput || !gate && hasPromptMarker) {
3024
+ const gate = detectGateScreen(lines);
3025
+ const stillPainted = gate !== null || scrapePermissionGate(lines) !== null;
3026
+ if (oscWaitingForInput || !stillPainted && hasPromptMarker) {
2981
3027
  this.permissionOpen.delete(sessionId);
2982
3028
  this.onPermissionChange?.(sessionId, null);
3029
+ if (gate) {
3030
+ this.closedGateKey.set(sessionId, permissionContentKey({ ...gate, cursor: void 0 }));
3031
+ } else {
3032
+ this.closedGateKey.delete(sessionId);
3033
+ }
2983
3034
  } else if (gate) {
2984
3035
  this.onPermissionChange?.(sessionId, gate);
2985
3036
  }
3037
+ } else if (!askFooterOnScreen && !oscWaitingForInput) {
3038
+ const gate = detectGateScreen(lines);
3039
+ if (gate) {
3040
+ const key = permissionContentKey({ ...gate, cursor: void 0 });
3041
+ if (this.closedGateKey.get(sessionId) !== key) {
3042
+ this.permissionOpen.add(sessionId);
3043
+ this.onPermissionChange?.(sessionId, gate);
3044
+ }
3045
+ } else {
3046
+ this.closedGateKey.delete(sessionId);
3047
+ }
2986
3048
  }
2987
3049
  if (askFooterOnScreen) {
2988
3050
  const detected = detectQuestionFromScreen(lines);
@@ -3125,8 +3187,11 @@ var PTYManager = class {
3125
3187
  this.queuedInputs.delete(sessionId);
3126
3188
  this.firstChunkAt.delete(sessionId);
3127
3189
  this.permissionOpen.delete(sessionId);
3190
+ this.oscTail.delete(sessionId);
3191
+ this.lastDetectAt.delete(sessionId);
3128
3192
  this.lastScreenQuestionKey.delete(sessionId);
3129
3193
  this.shellPromptOpen.delete(sessionId);
3194
+ this.closedGateKey.delete(sessionId);
3130
3195
  this.quietCheckers.get(sessionId)?.cancel();
3131
3196
  this.quietCheckers.delete(sessionId);
3132
3197
  this.clearReadyFallback(sessionId);
@@ -5280,7 +5345,10 @@ var createMiscRoutes = (deps) => {
5280
5345
  // Same contract: this server serves GET /api/config/feature-flags. Lives
5281
5346
  // here rather than behind /api/config (admin-only) so a read-only client
5282
5347
  // still learns the server supports flags even if it can't read values.
5283
- featureFlags: true
5348
+ featureFlags: true,
5349
+ // Same contract: this server serves GET /api/projects/summary, which the
5350
+ // Hub's grouped views need before they can draw a tree.
5351
+ projectSummary: true
5284
5352
  });
5285
5353
  });
5286
5354
  app.get("/api/profiles", (c) => c.json([]));
@@ -5427,6 +5495,11 @@ var createProjectRoutes = (deps) => {
5427
5495
  deps.handleGetPopularProjects(url, c.env.outgoing);
5428
5496
  return alreadyHandled4();
5429
5497
  });
5498
+ app.get("/summary", (c) => {
5499
+ const url = new URL(c.req.url);
5500
+ deps.handleGetProjectSummaries(url, c.env.outgoing);
5501
+ return alreadyHandled4();
5502
+ });
5430
5503
  return app;
5431
5504
  };
5432
5505
 
@@ -6292,6 +6365,21 @@ var ConversationCache = class _ConversationCache {
6292
6365
  ORDER BY cnt DESC
6293
6366
  LIMIT ?`
6294
6367
  ),
6368
+ // Same table, same rows and same NULL filter /api/conversations lists
6369
+ // from, so a group's count/last-activity can never disagree with the
6370
+ // page it opens. Bare project_name is the one from the MAX(last_activity)
6371
+ // row (SQLite's documented min/max-aggregate bare-column rule).
6372
+ projectSummaries: db.prepare(
6373
+ `SELECT project_path, project_name, COUNT(*) as cnt, MAX(last_activity) as latest
6374
+ FROM conversation_meta
6375
+ WHERE project_path IS NOT NULL
6376
+ GROUP BY project_path
6377
+ ORDER BY latest DESC, project_path ASC
6378
+ LIMIT ? OFFSET ?`
6379
+ ),
6380
+ projectSummaryCount: db.prepare(
6381
+ "SELECT COUNT(DISTINCT project_path) as n FROM conversation_meta WHERE project_path IS NOT NULL"
6382
+ ),
6295
6383
  getFileState: db.prepare("SELECT * FROM conversation_file_state WHERE path = ?"),
6296
6384
  upsertFileState: db.prepare(
6297
6385
  `INSERT INTO conversation_file_state
@@ -6667,6 +6755,23 @@ var ConversationCache = class _ConversationCache {
6667
6755
  sessionCount: r.cnt
6668
6756
  }));
6669
6757
  }
6758
+ /** Every project with at least one cached conversation, most recently active
6759
+ * first. Paths are the raw `project_path` values, which is what
6760
+ * /api/conversations?project= matches on exactly — so a summary row is
6761
+ * always joinable against the page it describes. */
6762
+ listProjectSummaries(opts) {
6763
+ const total = this.stmts.projectSummaryCount.get().n;
6764
+ const rows = opts.limit === 0 ? [] : this.stmts.projectSummaries.all(opts.limit, opts.offset);
6765
+ return {
6766
+ total,
6767
+ projects: rows.map((r) => ({
6768
+ path: r.project_path,
6769
+ name: r.project_name ?? r.project_path.split(/[/\\]/).pop() ?? r.project_path,
6770
+ conversationCount: r.cnt,
6771
+ lastActivity: new Date(r.latest ?? 0).toISOString()
6772
+ }))
6773
+ };
6774
+ }
6670
6775
  ensureFileIndex() {
6671
6776
  if (this.fileIndexLoaded) return;
6672
6777
  const rows = this.stmts.allFilePaths.all();
@@ -10808,6 +10913,7 @@ var StreamerServer = class {
10808
10913
  handleSearchTarget: (id, req, res) => this.handleSearchTarget(id, req, res),
10809
10914
  handleListProjects: (url, res) => handleListProjects(url, res),
10810
10915
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
10916
+ handleGetProjectSummaries: (url, res) => this.handleGetProjectSummaries(url, res),
10811
10917
  handlePairStart: (res) => this.handlePairStart(res),
10812
10918
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
10813
10919
  handleBrowse: (url, res) => this.handleBrowse(url, res),
@@ -12373,6 +12479,20 @@ var StreamerServer = class {
12373
12479
  const projects = this.cache.getPopularProjects(limit);
12374
12480
  json(res, 200, { projects, total: projects.length });
12375
12481
  }
12482
+ handleGetProjectSummaries(url, res) {
12483
+ if (this.rejectIfWarmingUp(res)) return;
12484
+ const limit = intParam(url, "limit", 200);
12485
+ const offset = intParam(url, "offset", 0);
12486
+ if (!this.cache) {
12487
+ json(res, 503, {
12488
+ error: "Conversation cache unavailable",
12489
+ code: "CACHE_UNAVAILABLE"
12490
+ });
12491
+ return;
12492
+ }
12493
+ const { projects, total } = this.cache.listProjectSummaries({ limit, offset });
12494
+ json(res, 200, { projects, total, offset, hasMore: offset + projects.length < total });
12495
+ }
12376
12496
  buildStatCache(previousScanner) {
12377
12497
  if (!this.cache) return void 0;
12378
12498
  if (!previousScanner) {