@threadbase-sh/streamer 1.16.0 → 1.17.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/cli.cjs CHANGED
@@ -133470,6 +133470,10 @@ var createSessionRoutes = (deps) => {
133470
133470
  await deps.handleSendInput(c.req.param("id"), c.env.incoming, c.env.outgoing);
133471
133471
  return alreadyHandled6();
133472
133472
  });
133473
+ app.post("/:id/answer", async (c) => {
133474
+ await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
133475
+ return alreadyHandled6();
133476
+ });
133473
133477
  app.post("/:id/files", async (c) => {
133474
133478
  await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
133475
133479
  return alreadyHandled6();
@@ -135452,6 +135456,164 @@ var import_crypto5 = require("crypto");
135452
135456
  var import_fs11 = require("fs");
135453
135457
  var import_path12 = require("path");
135454
135458
  init_logger();
135459
+
135460
+ // src/services/questions/detectPermissionGate.ts
135461
+ var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
135462
+ function hasPermissionOsc(rawData) {
135463
+ return OSC_777_RE.test(rawData);
135464
+ }
135465
+ var OPTION_RE = /^\s*(❯)?\s*(\d+)\.\s+(.+?)\s*$/;
135466
+ var FOOTER_RE = /Enter to select|Esc to cancel|↑|↓|to navigate|to cancel/i;
135467
+ var BOX_ONLY_RE = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
135468
+ var PROMPT_ARROW_RE = /^[\s]*[❯›>]\s*$/;
135469
+ function stripGutter(line) {
135470
+ return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
135471
+ }
135472
+ function scrapePermissionGate(lines) {
135473
+ const options = [];
135474
+ let cursor;
135475
+ let firstOptionLine = -1;
135476
+ for (let i = 0; i < lines.length; i++) {
135477
+ const m2 = OPTION_RE.exec(stripGutter(lines[i]));
135478
+ if (!m2) continue;
135479
+ const index = Number.parseInt(m2[2], 10);
135480
+ if (!Number.isFinite(index)) continue;
135481
+ if (firstOptionLine === -1) firstOptionLine = i;
135482
+ if (m2[1]) cursor = index;
135483
+ options.push({ index, label: m2[3] });
135484
+ }
135485
+ if (options.length === 0) return null;
135486
+ let prompt;
135487
+ for (let i = firstOptionLine - 1; i >= 0; i--) {
135488
+ const t2 = stripGutter(lines[i]).trim();
135489
+ if (t2.length === 0) continue;
135490
+ if (BOX_ONLY_RE.test(lines[i].trim()) || FOOTER_RE.test(t2) || PROMPT_ARROW_RE.test(t2)) continue;
135491
+ prompt = t2 || void 0;
135492
+ break;
135493
+ }
135494
+ return { ...prompt ? { prompt } : {}, options, ...cursor !== void 0 ? { cursor } : {} };
135495
+ }
135496
+
135497
+ // src/services/questions/detectQuestionFromScreen.ts
135498
+ var ASK_FOOTER_RE = /Enter to select/i;
135499
+ var ESC_FOOTER_RE = /Esc to cancel|to navigate/i;
135500
+ var OPTION_RE2 = /^\s*(?:❯)?\s*(\d+)\.\s+(.+?)\s*$/;
135501
+ var QUESTION_RE = /\?\s*$/;
135502
+ var BOX_ONLY_RE2 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
135503
+ var PERMISSION_LABEL_RE = /^(Yes|No)\b/i;
135504
+ function stripBoxGutter(line) {
135505
+ return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
135506
+ }
135507
+ function detectQuestionFromScreen(lines) {
135508
+ let footerIdx = -1;
135509
+ for (let i = lines.length - 1; i >= 0; i--) {
135510
+ if (ASK_FOOTER_RE.test(lines[i])) {
135511
+ footerIdx = i;
135512
+ break;
135513
+ }
135514
+ }
135515
+ if (footerIdx === -1) return null;
135516
+ const options = [];
135517
+ let firstOptionIdx = -1;
135518
+ for (let i = footerIdx - 1; i >= 0; i--) {
135519
+ const line = lines[i];
135520
+ const inner = stripBoxGutter(line);
135521
+ const trimmed = inner.trim();
135522
+ if (trimmed.length === 0) {
135523
+ if (options.length === 0) continue;
135524
+ break;
135525
+ }
135526
+ if (BOX_ONLY_RE2.test(line.trim())) continue;
135527
+ if (ESC_FOOTER_RE.test(line) && options.length === 0) continue;
135528
+ if (options.length > 0 && QUESTION_RE.test(trimmed) && !OPTION_RE2.test(inner)) break;
135529
+ const m2 = OPTION_RE2.exec(inner);
135530
+ if (m2) {
135531
+ const label = m2[2].trim();
135532
+ if (PERMISSION_LABEL_RE.test(label)) return null;
135533
+ options.unshift({ label, description: "" });
135534
+ firstOptionIdx = i;
135535
+ }
135536
+ }
135537
+ if (options.length < 2 || firstOptionIdx === -1) return null;
135538
+ let question;
135539
+ for (let i = firstOptionIdx - 1; i >= 0; i--) {
135540
+ const raw2 = stripBoxGutter(lines[i]);
135541
+ const trimmed = raw2.trim();
135542
+ if (trimmed.length === 0) continue;
135543
+ if (BOX_ONLY_RE2.test(lines[i].trim())) continue;
135544
+ if (QUESTION_RE.test(trimmed)) {
135545
+ question = trimmed;
135546
+ }
135547
+ break;
135548
+ }
135549
+ if (!question) return null;
135550
+ return {
135551
+ questions: [{ question, header: "", multiSelect: false, options }]
135552
+ };
135553
+ }
135554
+ function questionContentKey(questions) {
135555
+ return questions.map((q2) => `${q2.question} ${q2.options.map((o) => o.label).join(",")}`).join("::");
135556
+ }
135557
+
135558
+ // src/services/questions/detectShellPrompt.ts
135559
+ var ENTER = "\r";
135560
+ var YN_RE = /[[(]\s*y\s*\/\s*n\s*[\])]/i;
135561
+ var PRESS_ENTER_RE = /press\s+(enter|return|any key)/i;
135562
+ var CONTINUE_RE = /\bcontinue\b\s*\??\s*$/i;
135563
+ var NUMBERED_RE = /^\s*(?:❯|>)?\s*(\d+)[.)]\s+(.+?)\s*$/;
135564
+ var CLAUDE_CHROME_RE = /Enter to select|Esc to cancel|╭|╰|│.*│/;
135565
+ var BOX_ONLY_RE3 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
135566
+ function lastNonBlank(lines) {
135567
+ for (let i = lines.length - 1; i >= 0; i--) {
135568
+ const t2 = lines[i].trim();
135569
+ if (t2.length > 0) return { text: t2, idx: i };
135570
+ }
135571
+ return null;
135572
+ }
135573
+ function detectShellPrompt(lines) {
135574
+ if (lines.some((l) => CLAUDE_CHROME_RE.test(l))) return null;
135575
+ const last = lastNonBlank(lines);
135576
+ if (!last) return null;
135577
+ if (YN_RE.test(last.text)) {
135578
+ return {
135579
+ prompt: last.text,
135580
+ options: [
135581
+ { index: 1, label: "Yes", answerKeys: `y${ENTER}` },
135582
+ { index: 2, label: "No", answerKeys: `n${ENTER}` }
135583
+ ]
135584
+ };
135585
+ }
135586
+ if (NUMBERED_RE.test(last.text)) {
135587
+ const options = [];
135588
+ for (let i = 0; i <= last.idx; i++) {
135589
+ const m2 = NUMBERED_RE.exec(lines[i]);
135590
+ if (!m2) continue;
135591
+ const num = Number.parseInt(m2[1], 10);
135592
+ if (!Number.isFinite(num)) continue;
135593
+ options.push({ index: num, label: m2[2].trim(), answerKeys: `${num}${ENTER}` });
135594
+ }
135595
+ if (options.length >= 2) {
135596
+ const firstRow = lines.findIndex((l) => NUMBERED_RE.test(l));
135597
+ let prompt = "";
135598
+ for (let i = firstRow - 1; i >= 0; i--) {
135599
+ const t2 = lines[i].trim();
135600
+ if (t2.length === 0 || BOX_ONLY_RE3.test(t2)) continue;
135601
+ prompt = t2;
135602
+ break;
135603
+ }
135604
+ return { prompt: prompt || "Select an option", options };
135605
+ }
135606
+ }
135607
+ if (PRESS_ENTER_RE.test(last.text) || CONTINUE_RE.test(last.text)) {
135608
+ return {
135609
+ prompt: last.text,
135610
+ options: [{ index: 1, label: "Continue", answerKeys: ENTER }]
135611
+ };
135612
+ }
135613
+ return null;
135614
+ }
135615
+
135616
+ // src/pty-manager.ts
135455
135617
  var OUTPUT_BUFFER_MAX = 65536;
135456
135618
  var PTY_COLS = 120;
135457
135619
  var PTY_ROWS = 40;
@@ -135501,6 +135663,21 @@ var PTYManager = class {
135501
135663
  onOutput;
135502
135664
  onStatusChange;
135503
135665
  onReady;
135666
+ onPermissionChange;
135667
+ onLiveQuestion;
135668
+ onLiveQuestionGone;
135669
+ // Per-session permission-gate state. True between an OSC 777 (gate open) and
135670
+ // the next prompt-ready without a fresh 777 (gate closed). Prevents
135671
+ // re-broadcasting open/close on every chunk.
135672
+ permissionOpen = /* @__PURE__ */ new Set();
135673
+ // Content key of the last AskUserQuestion broadcast from the rendered screen,
135674
+ // per session — de-dupes the same menu firing on consecutive repaints.
135675
+ lastScreenQuestionKey = /* @__PURE__ */ new Map();
135676
+ // Content key of the last unstructured shell prompt (detectShellPrompt)
135677
+ // broadcast per session — present between open and resolve so we can clear it
135678
+ // on a prompt-ready/marker return and de-dupe consecutive repaints. Modelled
135679
+ // on permissionOpen but keyed by content (a shell prompt has no OSC trigger).
135680
+ shellPromptOpen = /* @__PURE__ */ new Map();
135504
135681
  // Tracks sessions (both fresh and resume) whose PTY has spawned but Claude
135505
135682
  // hasn't yet reached an interactive prompt — i.e. onReady hasn't fired.
135506
135683
  pendingReady = /* @__PURE__ */ new Set();
@@ -135523,6 +135700,9 @@ var PTYManager = class {
135523
135700
  this.onOutput = options.onOutput;
135524
135701
  this.onStatusChange = options.onStatusChange;
135525
135702
  this.onReady = options.onReady;
135703
+ this.onPermissionChange = options.onPermissionChange;
135704
+ this.onLiveQuestion = options.onLiveQuestion;
135705
+ this.onLiveQuestionGone = options.onLiveQuestionGone;
135526
135706
  this.log = options.logger ?? getLogger("pty");
135527
135707
  }
135528
135708
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
@@ -135765,6 +135945,9 @@ var PTYManager = class {
135765
135945
  this.pendingReady.delete(sessionId);
135766
135946
  this.queuedInputs.delete(sessionId);
135767
135947
  this.firstChunkAt.delete(sessionId);
135948
+ this.permissionOpen.delete(sessionId);
135949
+ this.lastScreenQuestionKey.delete(sessionId);
135950
+ this.shellPromptOpen.delete(sessionId);
135768
135951
  try {
135769
135952
  session.process.kill("SIGINT");
135770
135953
  } catch {
@@ -135824,6 +136007,9 @@ var PTYManager = class {
135824
136007
  this.firstChunkAt.clear();
135825
136008
  this.chunkIndex.clear();
135826
136009
  this.lastChunkAt.clear();
136010
+ this.permissionOpen.clear();
136011
+ this.lastScreenQuestionKey.clear();
136012
+ this.shellPromptOpen.clear();
135827
136013
  }
135828
136014
  handleOutput(sessionId, data) {
135829
136015
  const session = this.sessions.get(sessionId);
@@ -135867,6 +136053,87 @@ var PTYManager = class {
135867
136053
  this.markReady(sessionId, session, "fallback:timeout");
135868
136054
  }
135869
136055
  this.onOutput?.(sessionId, data);
136056
+ this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
136057
+ this.log.warn("[pty.prompt_detect] failed", {
136058
+ event: "pty.prompt_detect_failed",
136059
+ sessionId,
136060
+ err
136061
+ });
136062
+ });
136063
+ }
136064
+ // Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
136065
+ // menus from the rendered screen, firing the additive callbacks. Async because
136066
+ // reading the rendered buffer needs the xterm write queue flushed. Pure
136067
+ // detection lives in services/questions/*; this only orchestrates triggers,
136068
+ // per-session debounce, and the callbacks.
136069
+ async detectLivePrompts(sessionId, rawData, stripped) {
136070
+ const session = this.sessions.get(sessionId);
136071
+ if (!session) return;
136072
+ const oscPermission = hasPermissionOsc(rawData);
136073
+ const hasAskFooter = /Enter to select/i.test(stripped);
136074
+ const hasPromptMarker = CLAUDE_PROMPT_MARKERS.some((m2) => stripped.includes(m2));
136075
+ const hasShellPromptHint = /[[(]\s*y\s*\/\s*n\s*[\])]|press\s+(enter|return|any key)|\bcontinue\b\s*\?|^\s*(?:❯|>)?\s*\d+[.)]\s+\S/im.test(
136076
+ stripped
136077
+ );
136078
+ if (!oscPermission && !hasAskFooter && !hasShellPromptHint && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
136079
+ return;
136080
+ }
136081
+ const lines = await this.getOutputLines(sessionId, 60);
136082
+ const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
136083
+ if (oscPermission || hasAskFooter || askFooterOnScreen) {
136084
+ this.log.debug?.(`[pty.prompt_detect] ${sessionId.slice(0, 8)} trigger`, {
136085
+ event: "pty.prompt_detect",
136086
+ sessionId,
136087
+ oscPermission,
136088
+ hasAskFooter,
136089
+ askFooterOnScreen,
136090
+ permGate: oscPermission && !askFooterOnScreen ? scrapePermissionGate(lines) : void 0,
136091
+ askQuestion: askFooterOnScreen ? detectQuestionFromScreen(lines) : void 0,
136092
+ renderedTail: lines.slice(-25)
136093
+ });
136094
+ }
136095
+ if (oscPermission && !askFooterOnScreen) {
136096
+ const gate = scrapePermissionGate(lines);
136097
+ this.permissionOpen.add(sessionId);
136098
+ this.onPermissionChange?.(sessionId, gate ?? { options: [] });
136099
+ } else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
136100
+ const gate = scrapePermissionGate(lines);
136101
+ if (gate) {
136102
+ this.onPermissionChange?.(sessionId, gate);
136103
+ } else if (hasPromptMarker) {
136104
+ this.permissionOpen.delete(sessionId);
136105
+ this.onPermissionChange?.(sessionId, null);
136106
+ }
136107
+ }
136108
+ if (askFooterOnScreen) {
136109
+ const detected = detectQuestionFromScreen(lines);
136110
+ if (detected) {
136111
+ const key = questionContentKey(detected.questions);
136112
+ if (this.lastScreenQuestionKey.get(sessionId) !== key) {
136113
+ this.lastScreenQuestionKey.set(sessionId, key);
136114
+ this.onLiveQuestion?.(sessionId, detected.questions);
136115
+ }
136116
+ }
136117
+ } else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
136118
+ this.lastScreenQuestionKey.delete(sessionId);
136119
+ this.onLiveQuestionGone?.(sessionId);
136120
+ }
136121
+ if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
136122
+ const shell = detectShellPrompt(lines);
136123
+ if (shell) {
136124
+ const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
136125
+ if (this.shellPromptOpen.get(sessionId) !== key) {
136126
+ this.shellPromptOpen.set(sessionId, key);
136127
+ this.onPermissionChange?.(sessionId, {
136128
+ prompt: shell.prompt,
136129
+ options: shell.options
136130
+ });
136131
+ }
136132
+ } else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
136133
+ this.shellPromptOpen.delete(sessionId);
136134
+ this.onPermissionChange?.(sessionId, null);
136135
+ }
136136
+ }
135870
136137
  }
135871
136138
  // Transition a session from "running" to "waiting_input", clear pendingReady,
135872
136139
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
@@ -135905,6 +136172,9 @@ var PTYManager = class {
135905
136172
  this.sessions.delete(sessionId);
135906
136173
  this.queuedInputs.delete(sessionId);
135907
136174
  this.firstChunkAt.delete(sessionId);
136175
+ this.permissionOpen.delete(sessionId);
136176
+ this.lastScreenQuestionKey.delete(sessionId);
136177
+ this.shellPromptOpen.delete(sessionId);
135908
136178
  }
135909
136179
  };
135910
136180
  function toPublicSession(s3) {
@@ -137830,6 +138100,129 @@ function pruneAgentConversations(cache) {
137830
138100
  return { scanned: rows.length, pruned, missing };
137831
138101
  }
137832
138102
 
138103
+ // src/services/questions/detectAskUserQuestion.ts
138104
+ function normalizeContent2(raw2) {
138105
+ if (Array.isArray(raw2)) return raw2;
138106
+ if (typeof raw2 === "string") return [{ type: "text", text: raw2 }];
138107
+ return [];
138108
+ }
138109
+ function coerceOptions(raw2) {
138110
+ if (!Array.isArray(raw2)) return null;
138111
+ const out = [];
138112
+ for (const o of raw2) {
138113
+ if (o && typeof o === "object" && typeof o.label === "string") {
138114
+ const opt = o;
138115
+ out.push({
138116
+ label: opt.label,
138117
+ description: typeof opt.description === "string" ? opt.description : "",
138118
+ ...typeof opt.preview === "string" ? { preview: opt.preview } : {}
138119
+ });
138120
+ }
138121
+ }
138122
+ return out.length > 0 ? out : null;
138123
+ }
138124
+ function coerceQuestions(raw2) {
138125
+ if (!Array.isArray(raw2)) return null;
138126
+ const out = [];
138127
+ for (const q2 of raw2) {
138128
+ if (!q2 || typeof q2 !== "object") continue;
138129
+ const qq = q2;
138130
+ const options = coerceOptions(qq.options);
138131
+ if (typeof qq.question !== "string" || !options) continue;
138132
+ out.push({
138133
+ question: qq.question,
138134
+ header: typeof qq.header === "string" ? qq.header : "",
138135
+ multiSelect: qq.multiSelect === true,
138136
+ options
138137
+ });
138138
+ }
138139
+ return out.length > 0 ? out : null;
138140
+ }
138141
+ function detectAskUserQuestion(rawLine) {
138142
+ let parsed;
138143
+ try {
138144
+ parsed = JSON.parse(rawLine);
138145
+ } catch {
138146
+ return null;
138147
+ }
138148
+ const blocks = normalizeContent2(parsed.message?.content ?? parsed.content);
138149
+ for (const b2 of blocks) {
138150
+ if (b2.type === "tool_use" && b2.name === "AskUserQuestion" && typeof b2.id === "string") {
138151
+ const input = b2.input;
138152
+ const questions = coerceQuestions(input?.questions);
138153
+ if (questions) return { toolUseId: b2.id, questions };
138154
+ }
138155
+ }
138156
+ return null;
138157
+ }
138158
+
138159
+ // src/services/questions/questionBroadcast.ts
138160
+ function questionsFromLines(sessionId, lines) {
138161
+ const messages = [];
138162
+ const pending = [];
138163
+ for (const line of lines) {
138164
+ const detected = detectAskUserQuestion(line);
138165
+ if (detected) {
138166
+ messages.push({
138167
+ type: "question",
138168
+ sessionId,
138169
+ toolUseId: detected.toolUseId,
138170
+ questions: detected.questions
138171
+ });
138172
+ pending.push(detected);
138173
+ }
138174
+ }
138175
+ return { messages, pending };
138176
+ }
138177
+ function shouldBroadcastQuestion(args) {
138178
+ const alreadyShown = args.lastContentKey === args.newContentKey;
138179
+ if (!alreadyShown) return true;
138180
+ return args.priorToolUseId !== args.newToolUseId;
138181
+ }
138182
+
138183
+ // src/services/questions/answersToKeystrokes.ts
138184
+ var DOWN = "\x1B[B";
138185
+ var ENTER2 = "\r";
138186
+ var UnknownOptionError = class extends Error {
138187
+ constructor(question, value) {
138188
+ super(`No option labelled "${value}" for question "${question}"`);
138189
+ this.question = question;
138190
+ this.value = value;
138191
+ this.name = "UnknownOptionError";
138192
+ }
138193
+ question;
138194
+ value;
138195
+ };
138196
+ function answersToKeystrokes(questions, answers) {
138197
+ let out = "";
138198
+ for (const q2 of questions) {
138199
+ const raw2 = answers[q2.question];
138200
+ if (raw2 === void 0) {
138201
+ throw new Error(`Missing answer for question "${q2.question}"`);
138202
+ }
138203
+ const label = Array.isArray(raw2) ? raw2[0] : raw2;
138204
+ const target = q2.options.findIndex((o) => o.label === label);
138205
+ if (target < 0) throw new UnknownOptionError(q2.question, label);
138206
+ out += DOWN.repeat(target) + ENTER2;
138207
+ }
138208
+ return out;
138209
+ }
138210
+
138211
+ // src/services/questions/resolveAnswer.ts
138212
+ function resolveAnswer(pending, body) {
138213
+ if (!pending) return { ok: false, reason: "no_pending_question" };
138214
+ if (typeof body.toolUseId !== "string" || body.toolUseId !== pending.toolUseId) {
138215
+ return { ok: false, reason: "tool_use_mismatch" };
138216
+ }
138217
+ const answers = body.answers ?? {};
138218
+ try {
138219
+ return { ok: true, keys: answersToKeystrokes(pending.questions, answers) };
138220
+ } catch (e) {
138221
+ if (e instanceof UnknownOptionError) return { ok: false, reason: "unknown_option" };
138222
+ throw e;
138223
+ }
138224
+ }
138225
+
137833
138226
  // src/agent/dedupe.ts
137834
138227
  function createProgressDedupeLRU(capacity) {
137835
138228
  if (!Number.isFinite(capacity) || capacity < 1) {
@@ -138286,6 +138679,16 @@ var StreamerServer = class {
138286
138679
  fileWatcher;
138287
138680
  sessionFileMap = /* @__PURE__ */ new Map();
138288
138681
  // sessionId → JSONL filePath
138682
+ pendingQuestions = /* @__PURE__ */ new Map();
138683
+ // Content key of the AskUserQuestion currently broadcast for a session (from
138684
+ // either the rendered screen or JSONL), used to de-dupe the two paths: when
138685
+ // the screen detection fires first, the later JSONL flush of the same question
138686
+ // is suppressed. Cleared alongside pendingQuestions.
138687
+ pendingQuestionKey = /* @__PURE__ */ new Map();
138688
+ // Per-session permission gate currently open (scraped via OSC 777). Parallel
138689
+ // to pendingQuestions; mobile answers it by sending the option index via
138690
+ // /input { keys }. Cleared when the gate closes.
138691
+ pendingPermission = /* @__PURE__ */ new Map();
138289
138692
  scanner = null;
138290
138693
  scannerReady = null;
138291
138694
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
@@ -138381,6 +138784,28 @@ var StreamerServer = class {
138381
138784
  this.cache?.updateFromLines(filePath, lines);
138382
138785
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
138383
138786
  if (watchedPath === filePath) {
138787
+ const priorToolUseId = this.pendingQuestions.get(sessionId)?.toolUseId;
138788
+ const { messages, pending } = questionsFromLines(sessionId, lines);
138789
+ for (const p2 of pending) {
138790
+ this.pendingQuestions.set(sessionId, p2);
138791
+ const t2 = setTimeout(() => {
138792
+ if (this.pendingQuestions.get(sessionId)?.toolUseId === p2.toolUseId) {
138793
+ this.cancelPendingQuestion(sessionId);
138794
+ }
138795
+ }, 6e4);
138796
+ t2.unref();
138797
+ }
138798
+ for (const m2 of messages) {
138799
+ const key = questionContentKey(m2.questions);
138800
+ const broadcast = shouldBroadcastQuestion({
138801
+ newContentKey: key,
138802
+ lastContentKey: this.pendingQuestionKey.get(sessionId),
138803
+ newToolUseId: m2.toolUseId,
138804
+ priorToolUseId
138805
+ });
138806
+ this.pendingQuestionKey.set(sessionId, key);
138807
+ if (broadcast) this.wsHub.broadcast(m2);
138808
+ }
138384
138809
  this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
138385
138810
  for (const line of lines) {
138386
138811
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
@@ -138412,6 +138837,19 @@ var StreamerServer = class {
138412
138837
  onOutput: (sessionId, data) => {
138413
138838
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
138414
138839
  },
138840
+ onPermissionChange: (sessionId, gate) => {
138841
+ this.handlePermissionChange(sessionId, gate);
138842
+ },
138843
+ onLiveQuestion: (sessionId, questions) => {
138844
+ this.handleLiveQuestion(sessionId, questions);
138845
+ },
138846
+ onLiveQuestionGone: (sessionId) => {
138847
+ this.pendingQuestionKey.delete(sessionId);
138848
+ const pq = this.pendingQuestions.get(sessionId);
138849
+ if (pq?.toolUseId.startsWith("screen:")) {
138850
+ this.cancelPendingQuestion(sessionId);
138851
+ }
138852
+ },
138415
138853
  onReady: (session) => {
138416
138854
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
138417
138855
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
@@ -138449,7 +138887,9 @@ var StreamerServer = class {
138449
138887
  if (filePath) {
138450
138888
  this.fileWatcher.unwatch(filePath);
138451
138889
  this.sessionFileMap.delete(session.id);
138890
+ this.cancelPendingQuestion(session.id);
138452
138891
  }
138892
+ this.pendingPermission.delete(session.id);
138453
138893
  }
138454
138894
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
138455
138895
  if (resp) {
@@ -138500,6 +138940,7 @@ var StreamerServer = class {
138500
138940
  handleGetSession: (id, res) => this.handleGetSession(id, res),
138501
138941
  handleGetOutput: (id, res) => this.handleGetOutput(id, res),
138502
138942
  handleSendInput: (id, req, res) => this.handleSendInput(id, req, res),
138943
+ handleSendAnswer: (id, req, res) => this.handleSendAnswer(id, req, res),
138503
138944
  handleCancel: (id, res) => this.handleCancel(id, res),
138504
138945
  handleStopSession: (id, res) => this.handleStopSession(id, res),
138505
138946
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
@@ -138635,16 +139076,28 @@ var StreamerServer = class {
138635
139076
  if (existing) clearTimeout(existing);
138636
139077
  const timer = setTimeout(() => {
138637
139078
  this.ptyGraceTimers.delete(sessionId);
138638
- this.sessionSubscribers.delete(sessionId);
138639
139079
  if (this.ptyManager.hasSession(sessionId)) {
139080
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
139081
+ if (resp?.status === "running") {
139082
+ this.log.info(
139083
+ `[grace] session ${sessionId} still running, deferring hold`,
139084
+ { sessionId, event: "pty.grace_defer" },
139085
+ "pino"
139086
+ );
139087
+ this.startGraceTimer(sessionId, delayMs);
139088
+ return;
139089
+ }
139090
+ this.sessionSubscribers.delete(sessionId);
138640
139091
  this.log.info(
138641
139092
  `[grace] killing idle PTY for ${sessionId}`,
138642
139093
  { sessionId, event: "pty.grace_kill" },
138643
139094
  "pino"
138644
139095
  );
138645
139096
  this.ptyManager.putOnHold(sessionId);
138646
- const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
138647
- if (resp) this.wsHub.broadcast({ type: "session_update", session: resp });
139097
+ const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
139098
+ if (held) this.wsHub.broadcast({ type: "session_update", session: held });
139099
+ } else {
139100
+ this.sessionSubscribers.delete(sessionId);
138648
139101
  }
138649
139102
  }, delayMs);
138650
139103
  this.ptyGraceTimers.set(sessionId, timer);
@@ -139010,22 +139463,36 @@ var StreamerServer = class {
139010
139463
  async handleConversationsCount(url2, res) {
139011
139464
  const project = url2.searchParams.get("project") ?? void 0;
139012
139465
  const bustCache = url2.searchParams.get("refresh") === "1";
139013
- if (bustCache) {
139014
- this.cache?.invalidate();
139015
- this.scanner = null;
139016
- this.scannerReady = null;
139017
- }
139018
- if (this.cache && !bustCache) {
139466
+ if (this.cache) {
139019
139467
  const { total } = this.cache.listConversations({ project, limit: 0, offset: 0 });
139020
139468
  json2(res, 200, { total });
139469
+ if (bustCache) this.refreshCountInBackground();
139021
139470
  return;
139022
139471
  }
139023
- const scanner = await this.getScanner();
139472
+ const scanner = await this.getScanner(true);
139024
139473
  let metas = [...scanner.getMetadataCache().values()];
139025
139474
  metas = applyIncludeFilter(metas, "conversations");
139026
139475
  if (project) metas = applyProjectFilter(metas, project);
139027
139476
  json2(res, 200, { total: metas.length });
139028
139477
  }
139478
+ // Fire-and-forget full rescan that reconciles the SQLite cache from disk so a
139479
+ // later count reflects new/removed conversations. Never awaited by the request
139480
+ // path — refresh=1 returns the cached total synchronously and this catches up.
139481
+ refreshCountInBackground() {
139482
+ void (async () => {
139483
+ try {
139484
+ const scanner = await this.getFreshScanner();
139485
+ if (this.cache) {
139486
+ this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
139487
+ }
139488
+ } catch (err) {
139489
+ this.log.warn(
139490
+ `Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
139491
+ { event: "count.refresh_failed" }
139492
+ );
139493
+ }
139494
+ })();
139495
+ }
139029
139496
  handleSessionsCount(res) {
139030
139497
  json2(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
139031
139498
  }
@@ -139609,6 +140076,65 @@ var StreamerServer = class {
139609
140076
  json2(res, 400, { error: message });
139610
140077
  }
139611
140078
  }
140079
+ cancelPendingQuestion(sessionId) {
140080
+ const pq = this.pendingQuestions.get(sessionId);
140081
+ if (!pq) return;
140082
+ this.pendingQuestions.delete(sessionId);
140083
+ this.pendingQuestionKey.delete(sessionId);
140084
+ this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId: pq.toolUseId });
140085
+ }
140086
+ // Live AskUserQuestion detected from the rendered screen (ahead of JSONL).
140087
+ // Broadcasts the `question` event immediately and records the content key so
140088
+ // the later JSONL flush of the same question is de-duped. We synthesize a
140089
+ // screen-scoped toolUseId; the JSONL path overwrites pendingQuestions with the
140090
+ // real toolUseId when it lands, so answering works once JSONL catches up.
140091
+ handleLiveQuestion(sessionId, questions) {
140092
+ const key = questionContentKey(questions);
140093
+ if (this.pendingQuestionKey.get(sessionId) === key) return;
140094
+ const toolUseId = `screen:${sessionId}:${key.length}`;
140095
+ this.pendingQuestions.set(sessionId, { toolUseId, questions });
140096
+ this.pendingQuestionKey.set(sessionId, key);
140097
+ this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
140098
+ }
140099
+ // Permission gate opened/closed (OSC 777 + scraped options). Broadcasts the
140100
+ // additive `permission` / `permission_cancelled` events. Mobile answers by
140101
+ // sending the chosen option index via /input { keys } (e.g. "2\r").
140102
+ handlePermissionChange(sessionId, gate) {
140103
+ if (gate === null) {
140104
+ if (!this.pendingPermission.has(sessionId)) return;
140105
+ this.pendingPermission.delete(sessionId);
140106
+ this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
140107
+ return;
140108
+ }
140109
+ this.pendingPermission.set(sessionId, gate);
140110
+ this.wsHub.broadcast({
140111
+ type: "permission",
140112
+ sessionId,
140113
+ ...gate.prompt ? { prompt: gate.prompt } : {},
140114
+ options: gate.options,
140115
+ ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
140116
+ });
140117
+ }
140118
+ async handleSendAnswer(sessionId, req, res) {
140119
+ const body = await readBody(req);
140120
+ const pending = this.pendingQuestions.get(sessionId);
140121
+ const resolution = resolveAnswer(pending, body);
140122
+ if (!resolution.ok) {
140123
+ json2(res, 400, { ok: false, reason: resolution.reason });
140124
+ return;
140125
+ }
140126
+ const toolUseId = pending?.toolUseId ?? "";
140127
+ try {
140128
+ this.ptyManager.sendKeys(sessionId, resolution.keys);
140129
+ } catch (err) {
140130
+ const message = err instanceof Error ? err.message : "Failed to send answer";
140131
+ json2(res, 400, { ok: false, reason: message });
140132
+ return;
140133
+ }
140134
+ this.pendingQuestions.delete(sessionId);
140135
+ this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId });
140136
+ json2(res, 200, { ok: true });
140137
+ }
139612
140138
  async handleUploadFile(sessionId, req, res) {
139613
140139
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
139614
140140
  if (!session) {