@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/index.js CHANGED
@@ -702,6 +702,162 @@ function getLogger(component) {
702
702
  }
703
703
  var logger = build(baseLogger);
704
704
 
705
+ // src/services/questions/detectPermissionGate.ts
706
+ var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
707
+ function hasPermissionOsc(rawData) {
708
+ return OSC_777_RE.test(rawData);
709
+ }
710
+ var OPTION_RE = /^\s*(❯)?\s*(\d+)\.\s+(.+?)\s*$/;
711
+ var FOOTER_RE = /Enter to select|Esc to cancel|↑|↓|to navigate|to cancel/i;
712
+ var BOX_ONLY_RE = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
713
+ var PROMPT_ARROW_RE = /^[\s]*[❯›>]\s*$/;
714
+ function stripGutter(line) {
715
+ return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
716
+ }
717
+ function scrapePermissionGate(lines) {
718
+ const options = [];
719
+ let cursor;
720
+ let firstOptionLine = -1;
721
+ for (let i = 0; i < lines.length; i++) {
722
+ const m = OPTION_RE.exec(stripGutter(lines[i]));
723
+ if (!m) continue;
724
+ const index = Number.parseInt(m[2], 10);
725
+ if (!Number.isFinite(index)) continue;
726
+ if (firstOptionLine === -1) firstOptionLine = i;
727
+ if (m[1]) cursor = index;
728
+ options.push({ index, label: m[3] });
729
+ }
730
+ if (options.length === 0) return null;
731
+ let prompt;
732
+ for (let i = firstOptionLine - 1; i >= 0; i--) {
733
+ const t = stripGutter(lines[i]).trim();
734
+ if (t.length === 0) continue;
735
+ if (BOX_ONLY_RE.test(lines[i].trim()) || FOOTER_RE.test(t) || PROMPT_ARROW_RE.test(t)) continue;
736
+ prompt = t || void 0;
737
+ break;
738
+ }
739
+ return { ...prompt ? { prompt } : {}, options, ...cursor !== void 0 ? { cursor } : {} };
740
+ }
741
+
742
+ // src/services/questions/detectQuestionFromScreen.ts
743
+ var ASK_FOOTER_RE = /Enter to select/i;
744
+ var ESC_FOOTER_RE = /Esc to cancel|to navigate/i;
745
+ var OPTION_RE2 = /^\s*(?:❯)?\s*(\d+)\.\s+(.+?)\s*$/;
746
+ var QUESTION_RE = /\?\s*$/;
747
+ var BOX_ONLY_RE2 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
748
+ var PERMISSION_LABEL_RE = /^(Yes|No)\b/i;
749
+ function stripBoxGutter(line) {
750
+ return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
751
+ }
752
+ function detectQuestionFromScreen(lines) {
753
+ let footerIdx = -1;
754
+ for (let i = lines.length - 1; i >= 0; i--) {
755
+ if (ASK_FOOTER_RE.test(lines[i])) {
756
+ footerIdx = i;
757
+ break;
758
+ }
759
+ }
760
+ if (footerIdx === -1) return null;
761
+ const options = [];
762
+ let firstOptionIdx = -1;
763
+ for (let i = footerIdx - 1; i >= 0; i--) {
764
+ const line = lines[i];
765
+ const inner = stripBoxGutter(line);
766
+ const trimmed = inner.trim();
767
+ if (trimmed.length === 0) {
768
+ if (options.length === 0) continue;
769
+ break;
770
+ }
771
+ if (BOX_ONLY_RE2.test(line.trim())) continue;
772
+ if (ESC_FOOTER_RE.test(line) && options.length === 0) continue;
773
+ if (options.length > 0 && QUESTION_RE.test(trimmed) && !OPTION_RE2.test(inner)) break;
774
+ const m = OPTION_RE2.exec(inner);
775
+ if (m) {
776
+ const label = m[2].trim();
777
+ if (PERMISSION_LABEL_RE.test(label)) return null;
778
+ options.unshift({ label, description: "" });
779
+ firstOptionIdx = i;
780
+ }
781
+ }
782
+ if (options.length < 2 || firstOptionIdx === -1) return null;
783
+ let question;
784
+ for (let i = firstOptionIdx - 1; i >= 0; i--) {
785
+ const raw = stripBoxGutter(lines[i]);
786
+ const trimmed = raw.trim();
787
+ if (trimmed.length === 0) continue;
788
+ if (BOX_ONLY_RE2.test(lines[i].trim())) continue;
789
+ if (QUESTION_RE.test(trimmed)) {
790
+ question = trimmed;
791
+ }
792
+ break;
793
+ }
794
+ if (!question) return null;
795
+ return {
796
+ questions: [{ question, header: "", multiSelect: false, options }]
797
+ };
798
+ }
799
+ function questionContentKey(questions) {
800
+ return questions.map((q) => `${q.question} ${q.options.map((o) => o.label).join(",")}`).join("::");
801
+ }
802
+
803
+ // src/services/questions/detectShellPrompt.ts
804
+ var ENTER = "\r";
805
+ var YN_RE = /[[(]\s*y\s*\/\s*n\s*[\])]/i;
806
+ var PRESS_ENTER_RE = /press\s+(enter|return|any key)/i;
807
+ var CONTINUE_RE = /\bcontinue\b\s*\??\s*$/i;
808
+ var NUMBERED_RE = /^\s*(?:❯|>)?\s*(\d+)[.)]\s+(.+?)\s*$/;
809
+ var CLAUDE_CHROME_RE = /Enter to select|Esc to cancel|╭|╰|│.*│/;
810
+ var BOX_ONLY_RE3 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
811
+ function lastNonBlank(lines) {
812
+ for (let i = lines.length - 1; i >= 0; i--) {
813
+ const t = lines[i].trim();
814
+ if (t.length > 0) return { text: t, idx: i };
815
+ }
816
+ return null;
817
+ }
818
+ function detectShellPrompt(lines) {
819
+ if (lines.some((l) => CLAUDE_CHROME_RE.test(l))) return null;
820
+ const last = lastNonBlank(lines);
821
+ if (!last) return null;
822
+ if (YN_RE.test(last.text)) {
823
+ return {
824
+ prompt: last.text,
825
+ options: [
826
+ { index: 1, label: "Yes", answerKeys: `y${ENTER}` },
827
+ { index: 2, label: "No", answerKeys: `n${ENTER}` }
828
+ ]
829
+ };
830
+ }
831
+ if (NUMBERED_RE.test(last.text)) {
832
+ const options = [];
833
+ for (let i = 0; i <= last.idx; i++) {
834
+ const m = NUMBERED_RE.exec(lines[i]);
835
+ if (!m) continue;
836
+ const num = Number.parseInt(m[1], 10);
837
+ if (!Number.isFinite(num)) continue;
838
+ options.push({ index: num, label: m[2].trim(), answerKeys: `${num}${ENTER}` });
839
+ }
840
+ if (options.length >= 2) {
841
+ const firstRow = lines.findIndex((l) => NUMBERED_RE.test(l));
842
+ let prompt = "";
843
+ for (let i = firstRow - 1; i >= 0; i--) {
844
+ const t = lines[i].trim();
845
+ if (t.length === 0 || BOX_ONLY_RE3.test(t)) continue;
846
+ prompt = t;
847
+ break;
848
+ }
849
+ return { prompt: prompt || "Select an option", options };
850
+ }
851
+ }
852
+ if (PRESS_ENTER_RE.test(last.text) || CONTINUE_RE.test(last.text)) {
853
+ return {
854
+ prompt: last.text,
855
+ options: [{ index: 1, label: "Continue", answerKeys: ENTER }]
856
+ };
857
+ }
858
+ return null;
859
+ }
860
+
705
861
  // src/pty-manager.ts
706
862
  var OUTPUT_BUFFER_MAX = 65536;
707
863
  var PTY_COLS = 120;
@@ -752,6 +908,21 @@ var PTYManager = class {
752
908
  onOutput;
753
909
  onStatusChange;
754
910
  onReady;
911
+ onPermissionChange;
912
+ onLiveQuestion;
913
+ onLiveQuestionGone;
914
+ // Per-session permission-gate state. True between an OSC 777 (gate open) and
915
+ // the next prompt-ready without a fresh 777 (gate closed). Prevents
916
+ // re-broadcasting open/close on every chunk.
917
+ permissionOpen = /* @__PURE__ */ new Set();
918
+ // Content key of the last AskUserQuestion broadcast from the rendered screen,
919
+ // per session — de-dupes the same menu firing on consecutive repaints.
920
+ lastScreenQuestionKey = /* @__PURE__ */ new Map();
921
+ // Content key of the last unstructured shell prompt (detectShellPrompt)
922
+ // broadcast per session — present between open and resolve so we can clear it
923
+ // on a prompt-ready/marker return and de-dupe consecutive repaints. Modelled
924
+ // on permissionOpen but keyed by content (a shell prompt has no OSC trigger).
925
+ shellPromptOpen = /* @__PURE__ */ new Map();
755
926
  // Tracks sessions (both fresh and resume) whose PTY has spawned but Claude
756
927
  // hasn't yet reached an interactive prompt — i.e. onReady hasn't fired.
757
928
  pendingReady = /* @__PURE__ */ new Set();
@@ -774,6 +945,9 @@ var PTYManager = class {
774
945
  this.onOutput = options.onOutput;
775
946
  this.onStatusChange = options.onStatusChange;
776
947
  this.onReady = options.onReady;
948
+ this.onPermissionChange = options.onPermissionChange;
949
+ this.onLiveQuestion = options.onLiveQuestion;
950
+ this.onLiveQuestionGone = options.onLiveQuestionGone;
777
951
  this.log = options.logger ?? getLogger("pty");
778
952
  }
779
953
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
@@ -1016,6 +1190,9 @@ var PTYManager = class {
1016
1190
  this.pendingReady.delete(sessionId);
1017
1191
  this.queuedInputs.delete(sessionId);
1018
1192
  this.firstChunkAt.delete(sessionId);
1193
+ this.permissionOpen.delete(sessionId);
1194
+ this.lastScreenQuestionKey.delete(sessionId);
1195
+ this.shellPromptOpen.delete(sessionId);
1019
1196
  try {
1020
1197
  session.process.kill("SIGINT");
1021
1198
  } catch {
@@ -1075,6 +1252,9 @@ var PTYManager = class {
1075
1252
  this.firstChunkAt.clear();
1076
1253
  this.chunkIndex.clear();
1077
1254
  this.lastChunkAt.clear();
1255
+ this.permissionOpen.clear();
1256
+ this.lastScreenQuestionKey.clear();
1257
+ this.shellPromptOpen.clear();
1078
1258
  }
1079
1259
  handleOutput(sessionId, data) {
1080
1260
  const session = this.sessions.get(sessionId);
@@ -1118,6 +1298,87 @@ var PTYManager = class {
1118
1298
  this.markReady(sessionId, session, "fallback:timeout");
1119
1299
  }
1120
1300
  this.onOutput?.(sessionId, data);
1301
+ this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
1302
+ this.log.warn("[pty.prompt_detect] failed", {
1303
+ event: "pty.prompt_detect_failed",
1304
+ sessionId,
1305
+ err
1306
+ });
1307
+ });
1308
+ }
1309
+ // Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
1310
+ // menus from the rendered screen, firing the additive callbacks. Async because
1311
+ // reading the rendered buffer needs the xterm write queue flushed. Pure
1312
+ // detection lives in services/questions/*; this only orchestrates triggers,
1313
+ // per-session debounce, and the callbacks.
1314
+ async detectLivePrompts(sessionId, rawData, stripped) {
1315
+ const session = this.sessions.get(sessionId);
1316
+ if (!session) return;
1317
+ const oscPermission = hasPermissionOsc(rawData);
1318
+ const hasAskFooter = /Enter to select/i.test(stripped);
1319
+ const hasPromptMarker = CLAUDE_PROMPT_MARKERS.some((m) => stripped.includes(m));
1320
+ const hasShellPromptHint = /[[(]\s*y\s*\/\s*n\s*[\])]|press\s+(enter|return|any key)|\bcontinue\b\s*\?|^\s*(?:❯|>)?\s*\d+[.)]\s+\S/im.test(
1321
+ stripped
1322
+ );
1323
+ if (!oscPermission && !hasAskFooter && !hasShellPromptHint && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
1324
+ return;
1325
+ }
1326
+ const lines = await this.getOutputLines(sessionId, 60);
1327
+ const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
1328
+ if (oscPermission || hasAskFooter || askFooterOnScreen) {
1329
+ this.log.debug?.(`[pty.prompt_detect] ${sessionId.slice(0, 8)} trigger`, {
1330
+ event: "pty.prompt_detect",
1331
+ sessionId,
1332
+ oscPermission,
1333
+ hasAskFooter,
1334
+ askFooterOnScreen,
1335
+ permGate: oscPermission && !askFooterOnScreen ? scrapePermissionGate(lines) : void 0,
1336
+ askQuestion: askFooterOnScreen ? detectQuestionFromScreen(lines) : void 0,
1337
+ renderedTail: lines.slice(-25)
1338
+ });
1339
+ }
1340
+ if (oscPermission && !askFooterOnScreen) {
1341
+ const gate = scrapePermissionGate(lines);
1342
+ this.permissionOpen.add(sessionId);
1343
+ this.onPermissionChange?.(sessionId, gate ?? { options: [] });
1344
+ } else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
1345
+ const gate = scrapePermissionGate(lines);
1346
+ if (gate) {
1347
+ this.onPermissionChange?.(sessionId, gate);
1348
+ } else if (hasPromptMarker) {
1349
+ this.permissionOpen.delete(sessionId);
1350
+ this.onPermissionChange?.(sessionId, null);
1351
+ }
1352
+ }
1353
+ if (askFooterOnScreen) {
1354
+ const detected = detectQuestionFromScreen(lines);
1355
+ if (detected) {
1356
+ const key = questionContentKey(detected.questions);
1357
+ if (this.lastScreenQuestionKey.get(sessionId) !== key) {
1358
+ this.lastScreenQuestionKey.set(sessionId, key);
1359
+ this.onLiveQuestion?.(sessionId, detected.questions);
1360
+ }
1361
+ }
1362
+ } else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
1363
+ this.lastScreenQuestionKey.delete(sessionId);
1364
+ this.onLiveQuestionGone?.(sessionId);
1365
+ }
1366
+ if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
1367
+ const shell = detectShellPrompt(lines);
1368
+ if (shell) {
1369
+ const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
1370
+ if (this.shellPromptOpen.get(sessionId) !== key) {
1371
+ this.shellPromptOpen.set(sessionId, key);
1372
+ this.onPermissionChange?.(sessionId, {
1373
+ prompt: shell.prompt,
1374
+ options: shell.options
1375
+ });
1376
+ }
1377
+ } else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
1378
+ this.shellPromptOpen.delete(sessionId);
1379
+ this.onPermissionChange?.(sessionId, null);
1380
+ }
1381
+ }
1121
1382
  }
1122
1383
  // Transition a session from "running" to "waiting_input", clear pendingReady,
1123
1384
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
@@ -1156,6 +1417,9 @@ var PTYManager = class {
1156
1417
  this.sessions.delete(sessionId);
1157
1418
  this.queuedInputs.delete(sessionId);
1158
1419
  this.firstChunkAt.delete(sessionId);
1420
+ this.permissionOpen.delete(sessionId);
1421
+ this.lastScreenQuestionKey.delete(sessionId);
1422
+ this.shellPromptOpen.delete(sessionId);
1159
1423
  }
1160
1424
  };
1161
1425
  function toPublicSession(s) {
@@ -1830,6 +2094,10 @@ var createSessionRoutes = (deps) => {
1830
2094
  await deps.handleSendInput(c.req.param("id"), c.env.incoming, c.env.outgoing);
1831
2095
  return alreadyHandled6();
1832
2096
  });
2097
+ app.post("/:id/answer", async (c) => {
2098
+ await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
2099
+ return alreadyHandled6();
2100
+ });
1833
2101
  app.post("/:id/files", async (c) => {
1834
2102
  await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
1835
2103
  return alreadyHandled6();
@@ -3452,6 +3720,129 @@ function pruneAgentConversations(cache) {
3452
3720
  return { scanned: rows.length, pruned, missing };
3453
3721
  }
3454
3722
 
3723
+ // src/services/questions/detectAskUserQuestion.ts
3724
+ function normalizeContent2(raw) {
3725
+ if (Array.isArray(raw)) return raw;
3726
+ if (typeof raw === "string") return [{ type: "text", text: raw }];
3727
+ return [];
3728
+ }
3729
+ function coerceOptions(raw) {
3730
+ if (!Array.isArray(raw)) return null;
3731
+ const out = [];
3732
+ for (const o of raw) {
3733
+ if (o && typeof o === "object" && typeof o.label === "string") {
3734
+ const opt = o;
3735
+ out.push({
3736
+ label: opt.label,
3737
+ description: typeof opt.description === "string" ? opt.description : "",
3738
+ ...typeof opt.preview === "string" ? { preview: opt.preview } : {}
3739
+ });
3740
+ }
3741
+ }
3742
+ return out.length > 0 ? out : null;
3743
+ }
3744
+ function coerceQuestions(raw) {
3745
+ if (!Array.isArray(raw)) return null;
3746
+ const out = [];
3747
+ for (const q of raw) {
3748
+ if (!q || typeof q !== "object") continue;
3749
+ const qq = q;
3750
+ const options = coerceOptions(qq.options);
3751
+ if (typeof qq.question !== "string" || !options) continue;
3752
+ out.push({
3753
+ question: qq.question,
3754
+ header: typeof qq.header === "string" ? qq.header : "",
3755
+ multiSelect: qq.multiSelect === true,
3756
+ options
3757
+ });
3758
+ }
3759
+ return out.length > 0 ? out : null;
3760
+ }
3761
+ function detectAskUserQuestion(rawLine) {
3762
+ let parsed;
3763
+ try {
3764
+ parsed = JSON.parse(rawLine);
3765
+ } catch {
3766
+ return null;
3767
+ }
3768
+ const blocks = normalizeContent2(parsed.message?.content ?? parsed.content);
3769
+ for (const b of blocks) {
3770
+ if (b.type === "tool_use" && b.name === "AskUserQuestion" && typeof b.id === "string") {
3771
+ const input = b.input;
3772
+ const questions = coerceQuestions(input?.questions);
3773
+ if (questions) return { toolUseId: b.id, questions };
3774
+ }
3775
+ }
3776
+ return null;
3777
+ }
3778
+
3779
+ // src/services/questions/questionBroadcast.ts
3780
+ function questionsFromLines(sessionId, lines) {
3781
+ const messages = [];
3782
+ const pending = [];
3783
+ for (const line of lines) {
3784
+ const detected = detectAskUserQuestion(line);
3785
+ if (detected) {
3786
+ messages.push({
3787
+ type: "question",
3788
+ sessionId,
3789
+ toolUseId: detected.toolUseId,
3790
+ questions: detected.questions
3791
+ });
3792
+ pending.push(detected);
3793
+ }
3794
+ }
3795
+ return { messages, pending };
3796
+ }
3797
+ function shouldBroadcastQuestion(args) {
3798
+ const alreadyShown = args.lastContentKey === args.newContentKey;
3799
+ if (!alreadyShown) return true;
3800
+ return args.priorToolUseId !== args.newToolUseId;
3801
+ }
3802
+
3803
+ // src/services/questions/answersToKeystrokes.ts
3804
+ var DOWN = "\x1B[B";
3805
+ var ENTER2 = "\r";
3806
+ var UnknownOptionError = class extends Error {
3807
+ constructor(question, value) {
3808
+ super(`No option labelled "${value}" for question "${question}"`);
3809
+ this.question = question;
3810
+ this.value = value;
3811
+ this.name = "UnknownOptionError";
3812
+ }
3813
+ question;
3814
+ value;
3815
+ };
3816
+ function answersToKeystrokes(questions, answers) {
3817
+ let out = "";
3818
+ for (const q of questions) {
3819
+ const raw = answers[q.question];
3820
+ if (raw === void 0) {
3821
+ throw new Error(`Missing answer for question "${q.question}"`);
3822
+ }
3823
+ const label = Array.isArray(raw) ? raw[0] : raw;
3824
+ const target = q.options.findIndex((o) => o.label === label);
3825
+ if (target < 0) throw new UnknownOptionError(q.question, label);
3826
+ out += DOWN.repeat(target) + ENTER2;
3827
+ }
3828
+ return out;
3829
+ }
3830
+
3831
+ // src/services/questions/resolveAnswer.ts
3832
+ function resolveAnswer(pending, body) {
3833
+ if (!pending) return { ok: false, reason: "no_pending_question" };
3834
+ if (typeof body.toolUseId !== "string" || body.toolUseId !== pending.toolUseId) {
3835
+ return { ok: false, reason: "tool_use_mismatch" };
3836
+ }
3837
+ const answers = body.answers ?? {};
3838
+ try {
3839
+ return { ok: true, keys: answersToKeystrokes(pending.questions, answers) };
3840
+ } catch (e) {
3841
+ if (e instanceof UnknownOptionError) return { ok: false, reason: "unknown_option" };
3842
+ throw e;
3843
+ }
3844
+ }
3845
+
3455
3846
  // src/session-store.ts
3456
3847
  var SessionStore = class {
3457
3848
  managed = /* @__PURE__ */ new Map();
@@ -3882,6 +4273,16 @@ var StreamerServer = class {
3882
4273
  fileWatcher;
3883
4274
  sessionFileMap = /* @__PURE__ */ new Map();
3884
4275
  // sessionId → JSONL filePath
4276
+ pendingQuestions = /* @__PURE__ */ new Map();
4277
+ // Content key of the AskUserQuestion currently broadcast for a session (from
4278
+ // either the rendered screen or JSONL), used to de-dupe the two paths: when
4279
+ // the screen detection fires first, the later JSONL flush of the same question
4280
+ // is suppressed. Cleared alongside pendingQuestions.
4281
+ pendingQuestionKey = /* @__PURE__ */ new Map();
4282
+ // Per-session permission gate currently open (scraped via OSC 777). Parallel
4283
+ // to pendingQuestions; mobile answers it by sending the option index via
4284
+ // /input { keys }. Cleared when the gate closes.
4285
+ pendingPermission = /* @__PURE__ */ new Map();
3885
4286
  scanner = null;
3886
4287
  scannerReady = null;
3887
4288
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
@@ -3977,6 +4378,28 @@ var StreamerServer = class {
3977
4378
  this.cache?.updateFromLines(filePath, lines);
3978
4379
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
3979
4380
  if (watchedPath === filePath) {
4381
+ const priorToolUseId = this.pendingQuestions.get(sessionId)?.toolUseId;
4382
+ const { messages, pending } = questionsFromLines(sessionId, lines);
4383
+ for (const p of pending) {
4384
+ this.pendingQuestions.set(sessionId, p);
4385
+ const t = setTimeout(() => {
4386
+ if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
4387
+ this.cancelPendingQuestion(sessionId);
4388
+ }
4389
+ }, 6e4);
4390
+ t.unref();
4391
+ }
4392
+ for (const m of messages) {
4393
+ const key = questionContentKey(m.questions);
4394
+ const broadcast = shouldBroadcastQuestion({
4395
+ newContentKey: key,
4396
+ lastContentKey: this.pendingQuestionKey.get(sessionId),
4397
+ newToolUseId: m.toolUseId,
4398
+ priorToolUseId
4399
+ });
4400
+ this.pendingQuestionKey.set(sessionId, key);
4401
+ if (broadcast) this.wsHub.broadcast(m);
4402
+ }
3980
4403
  this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
3981
4404
  for (const line of lines) {
3982
4405
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
@@ -4008,6 +4431,19 @@ var StreamerServer = class {
4008
4431
  onOutput: (sessionId, data) => {
4009
4432
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
4010
4433
  },
4434
+ onPermissionChange: (sessionId, gate) => {
4435
+ this.handlePermissionChange(sessionId, gate);
4436
+ },
4437
+ onLiveQuestion: (sessionId, questions) => {
4438
+ this.handleLiveQuestion(sessionId, questions);
4439
+ },
4440
+ onLiveQuestionGone: (sessionId) => {
4441
+ this.pendingQuestionKey.delete(sessionId);
4442
+ const pq = this.pendingQuestions.get(sessionId);
4443
+ if (pq?.toolUseId.startsWith("screen:")) {
4444
+ this.cancelPendingQuestion(sessionId);
4445
+ }
4446
+ },
4011
4447
  onReady: (session) => {
4012
4448
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
4013
4449
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
@@ -4045,7 +4481,9 @@ var StreamerServer = class {
4045
4481
  if (filePath) {
4046
4482
  this.fileWatcher.unwatch(filePath);
4047
4483
  this.sessionFileMap.delete(session.id);
4484
+ this.cancelPendingQuestion(session.id);
4048
4485
  }
4486
+ this.pendingPermission.delete(session.id);
4049
4487
  }
4050
4488
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
4051
4489
  if (resp) {
@@ -4096,6 +4534,7 @@ var StreamerServer = class {
4096
4534
  handleGetSession: (id, res) => this.handleGetSession(id, res),
4097
4535
  handleGetOutput: (id, res) => this.handleGetOutput(id, res),
4098
4536
  handleSendInput: (id, req, res) => this.handleSendInput(id, req, res),
4537
+ handleSendAnswer: (id, req, res) => this.handleSendAnswer(id, req, res),
4099
4538
  handleCancel: (id, res) => this.handleCancel(id, res),
4100
4539
  handleStopSession: (id, res) => this.handleStopSession(id, res),
4101
4540
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
@@ -4231,16 +4670,28 @@ var StreamerServer = class {
4231
4670
  if (existing) clearTimeout(existing);
4232
4671
  const timer = setTimeout(() => {
4233
4672
  this.ptyGraceTimers.delete(sessionId);
4234
- this.sessionSubscribers.delete(sessionId);
4235
4673
  if (this.ptyManager.hasSession(sessionId)) {
4674
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
4675
+ if (resp?.status === "running") {
4676
+ this.log.info(
4677
+ `[grace] session ${sessionId} still running, deferring hold`,
4678
+ { sessionId, event: "pty.grace_defer" },
4679
+ "pino"
4680
+ );
4681
+ this.startGraceTimer(sessionId, delayMs);
4682
+ return;
4683
+ }
4684
+ this.sessionSubscribers.delete(sessionId);
4236
4685
  this.log.info(
4237
4686
  `[grace] killing idle PTY for ${sessionId}`,
4238
4687
  { sessionId, event: "pty.grace_kill" },
4239
4688
  "pino"
4240
4689
  );
4241
4690
  this.ptyManager.putOnHold(sessionId);
4242
- const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
4243
- if (resp) this.wsHub.broadcast({ type: "session_update", session: resp });
4691
+ const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
4692
+ if (held) this.wsHub.broadcast({ type: "session_update", session: held });
4693
+ } else {
4694
+ this.sessionSubscribers.delete(sessionId);
4244
4695
  }
4245
4696
  }, delayMs);
4246
4697
  this.ptyGraceTimers.set(sessionId, timer);
@@ -4606,22 +5057,36 @@ var StreamerServer = class {
4606
5057
  async handleConversationsCount(url, res) {
4607
5058
  const project = url.searchParams.get("project") ?? void 0;
4608
5059
  const bustCache = url.searchParams.get("refresh") === "1";
4609
- if (bustCache) {
4610
- this.cache?.invalidate();
4611
- this.scanner = null;
4612
- this.scannerReady = null;
4613
- }
4614
- if (this.cache && !bustCache) {
5060
+ if (this.cache) {
4615
5061
  const { total } = this.cache.listConversations({ project, limit: 0, offset: 0 });
4616
5062
  json(res, 200, { total });
5063
+ if (bustCache) this.refreshCountInBackground();
4617
5064
  return;
4618
5065
  }
4619
- const scanner = await this.getScanner();
5066
+ const scanner = await this.getScanner(true);
4620
5067
  let metas = [...scanner.getMetadataCache().values()];
4621
5068
  metas = applyIncludeFilter(metas, "conversations");
4622
5069
  if (project) metas = applyProjectFilter(metas, project);
4623
5070
  json(res, 200, { total: metas.length });
4624
5071
  }
5072
+ // Fire-and-forget full rescan that reconciles the SQLite cache from disk so a
5073
+ // later count reflects new/removed conversations. Never awaited by the request
5074
+ // path — refresh=1 returns the cached total synchronously and this catches up.
5075
+ refreshCountInBackground() {
5076
+ void (async () => {
5077
+ try {
5078
+ const scanner = await this.getFreshScanner();
5079
+ if (this.cache) {
5080
+ this.cache.upsertFromScannerMeta([...scanner.getMetadataCache().values()]);
5081
+ }
5082
+ } catch (err) {
5083
+ this.log.warn(
5084
+ `Background count refresh failed: ${err instanceof Error ? err.message : String(err)}`,
5085
+ { event: "count.refresh_failed" }
5086
+ );
5087
+ }
5088
+ })();
5089
+ }
4625
5090
  handleSessionsCount(res) {
4626
5091
  json(res, 200, { total: this.sessionStore.list(this.ptyAttachedIds()).length });
4627
5092
  }
@@ -5205,6 +5670,65 @@ var StreamerServer = class {
5205
5670
  json(res, 400, { error: message });
5206
5671
  }
5207
5672
  }
5673
+ cancelPendingQuestion(sessionId) {
5674
+ const pq = this.pendingQuestions.get(sessionId);
5675
+ if (!pq) return;
5676
+ this.pendingQuestions.delete(sessionId);
5677
+ this.pendingQuestionKey.delete(sessionId);
5678
+ this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId: pq.toolUseId });
5679
+ }
5680
+ // Live AskUserQuestion detected from the rendered screen (ahead of JSONL).
5681
+ // Broadcasts the `question` event immediately and records the content key so
5682
+ // the later JSONL flush of the same question is de-duped. We synthesize a
5683
+ // screen-scoped toolUseId; the JSONL path overwrites pendingQuestions with the
5684
+ // real toolUseId when it lands, so answering works once JSONL catches up.
5685
+ handleLiveQuestion(sessionId, questions) {
5686
+ const key = questionContentKey(questions);
5687
+ if (this.pendingQuestionKey.get(sessionId) === key) return;
5688
+ const toolUseId = `screen:${sessionId}:${key.length}`;
5689
+ this.pendingQuestions.set(sessionId, { toolUseId, questions });
5690
+ this.pendingQuestionKey.set(sessionId, key);
5691
+ this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
5692
+ }
5693
+ // Permission gate opened/closed (OSC 777 + scraped options). Broadcasts the
5694
+ // additive `permission` / `permission_cancelled` events. Mobile answers by
5695
+ // sending the chosen option index via /input { keys } (e.g. "2\r").
5696
+ handlePermissionChange(sessionId, gate) {
5697
+ if (gate === null) {
5698
+ if (!this.pendingPermission.has(sessionId)) return;
5699
+ this.pendingPermission.delete(sessionId);
5700
+ this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
5701
+ return;
5702
+ }
5703
+ this.pendingPermission.set(sessionId, gate);
5704
+ this.wsHub.broadcast({
5705
+ type: "permission",
5706
+ sessionId,
5707
+ ...gate.prompt ? { prompt: gate.prompt } : {},
5708
+ options: gate.options,
5709
+ ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
5710
+ });
5711
+ }
5712
+ async handleSendAnswer(sessionId, req, res) {
5713
+ const body = await readBody(req);
5714
+ const pending = this.pendingQuestions.get(sessionId);
5715
+ const resolution = resolveAnswer(pending, body);
5716
+ if (!resolution.ok) {
5717
+ json(res, 400, { ok: false, reason: resolution.reason });
5718
+ return;
5719
+ }
5720
+ const toolUseId = pending?.toolUseId ?? "";
5721
+ try {
5722
+ this.ptyManager.sendKeys(sessionId, resolution.keys);
5723
+ } catch (err) {
5724
+ const message = err instanceof Error ? err.message : "Failed to send answer";
5725
+ json(res, 400, { ok: false, reason: message });
5726
+ return;
5727
+ }
5728
+ this.pendingQuestions.delete(sessionId);
5729
+ this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId });
5730
+ json(res, 200, { ok: true });
5731
+ }
5208
5732
  async handleUploadFile(sessionId, req, res) {
5209
5733
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
5210
5734
  if (!session) {