@threadbase-sh/streamer 1.16.1 → 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.cjs CHANGED
@@ -749,6 +749,162 @@ function getLogger(component) {
749
749
  }
750
750
  var logger = build(baseLogger);
751
751
 
752
+ // src/services/questions/detectPermissionGate.ts
753
+ var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
754
+ function hasPermissionOsc(rawData) {
755
+ return OSC_777_RE.test(rawData);
756
+ }
757
+ var OPTION_RE = /^\s*(❯)?\s*(\d+)\.\s+(.+?)\s*$/;
758
+ var FOOTER_RE = /Enter to select|Esc to cancel|↑|↓|to navigate|to cancel/i;
759
+ var BOX_ONLY_RE = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
760
+ var PROMPT_ARROW_RE = /^[\s]*[❯›>]\s*$/;
761
+ function stripGutter(line) {
762
+ return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
763
+ }
764
+ function scrapePermissionGate(lines) {
765
+ const options = [];
766
+ let cursor;
767
+ let firstOptionLine = -1;
768
+ for (let i = 0; i < lines.length; i++) {
769
+ const m = OPTION_RE.exec(stripGutter(lines[i]));
770
+ if (!m) continue;
771
+ const index = Number.parseInt(m[2], 10);
772
+ if (!Number.isFinite(index)) continue;
773
+ if (firstOptionLine === -1) firstOptionLine = i;
774
+ if (m[1]) cursor = index;
775
+ options.push({ index, label: m[3] });
776
+ }
777
+ if (options.length === 0) return null;
778
+ let prompt;
779
+ for (let i = firstOptionLine - 1; i >= 0; i--) {
780
+ const t = stripGutter(lines[i]).trim();
781
+ if (t.length === 0) continue;
782
+ if (BOX_ONLY_RE.test(lines[i].trim()) || FOOTER_RE.test(t) || PROMPT_ARROW_RE.test(t)) continue;
783
+ prompt = t || void 0;
784
+ break;
785
+ }
786
+ return { ...prompt ? { prompt } : {}, options, ...cursor !== void 0 ? { cursor } : {} };
787
+ }
788
+
789
+ // src/services/questions/detectQuestionFromScreen.ts
790
+ var ASK_FOOTER_RE = /Enter to select/i;
791
+ var ESC_FOOTER_RE = /Esc to cancel|to navigate/i;
792
+ var OPTION_RE2 = /^\s*(?:❯)?\s*(\d+)\.\s+(.+?)\s*$/;
793
+ var QUESTION_RE = /\?\s*$/;
794
+ var BOX_ONLY_RE2 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
795
+ var PERMISSION_LABEL_RE = /^(Yes|No)\b/i;
796
+ function stripBoxGutter(line) {
797
+ return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
798
+ }
799
+ function detectQuestionFromScreen(lines) {
800
+ let footerIdx = -1;
801
+ for (let i = lines.length - 1; i >= 0; i--) {
802
+ if (ASK_FOOTER_RE.test(lines[i])) {
803
+ footerIdx = i;
804
+ break;
805
+ }
806
+ }
807
+ if (footerIdx === -1) return null;
808
+ const options = [];
809
+ let firstOptionIdx = -1;
810
+ for (let i = footerIdx - 1; i >= 0; i--) {
811
+ const line = lines[i];
812
+ const inner = stripBoxGutter(line);
813
+ const trimmed = inner.trim();
814
+ if (trimmed.length === 0) {
815
+ if (options.length === 0) continue;
816
+ break;
817
+ }
818
+ if (BOX_ONLY_RE2.test(line.trim())) continue;
819
+ if (ESC_FOOTER_RE.test(line) && options.length === 0) continue;
820
+ if (options.length > 0 && QUESTION_RE.test(trimmed) && !OPTION_RE2.test(inner)) break;
821
+ const m = OPTION_RE2.exec(inner);
822
+ if (m) {
823
+ const label = m[2].trim();
824
+ if (PERMISSION_LABEL_RE.test(label)) return null;
825
+ options.unshift({ label, description: "" });
826
+ firstOptionIdx = i;
827
+ }
828
+ }
829
+ if (options.length < 2 || firstOptionIdx === -1) return null;
830
+ let question;
831
+ for (let i = firstOptionIdx - 1; i >= 0; i--) {
832
+ const raw = stripBoxGutter(lines[i]);
833
+ const trimmed = raw.trim();
834
+ if (trimmed.length === 0) continue;
835
+ if (BOX_ONLY_RE2.test(lines[i].trim())) continue;
836
+ if (QUESTION_RE.test(trimmed)) {
837
+ question = trimmed;
838
+ }
839
+ break;
840
+ }
841
+ if (!question) return null;
842
+ return {
843
+ questions: [{ question, header: "", multiSelect: false, options }]
844
+ };
845
+ }
846
+ function questionContentKey(questions) {
847
+ return questions.map((q) => `${q.question} ${q.options.map((o) => o.label).join(",")}`).join("::");
848
+ }
849
+
850
+ // src/services/questions/detectShellPrompt.ts
851
+ var ENTER = "\r";
852
+ var YN_RE = /[[(]\s*y\s*\/\s*n\s*[\])]/i;
853
+ var PRESS_ENTER_RE = /press\s+(enter|return|any key)/i;
854
+ var CONTINUE_RE = /\bcontinue\b\s*\??\s*$/i;
855
+ var NUMBERED_RE = /^\s*(?:❯|>)?\s*(\d+)[.)]\s+(.+?)\s*$/;
856
+ var CLAUDE_CHROME_RE = /Enter to select|Esc to cancel|╭|╰|│.*│/;
857
+ var BOX_ONLY_RE3 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
858
+ function lastNonBlank(lines) {
859
+ for (let i = lines.length - 1; i >= 0; i--) {
860
+ const t = lines[i].trim();
861
+ if (t.length > 0) return { text: t, idx: i };
862
+ }
863
+ return null;
864
+ }
865
+ function detectShellPrompt(lines) {
866
+ if (lines.some((l) => CLAUDE_CHROME_RE.test(l))) return null;
867
+ const last = lastNonBlank(lines);
868
+ if (!last) return null;
869
+ if (YN_RE.test(last.text)) {
870
+ return {
871
+ prompt: last.text,
872
+ options: [
873
+ { index: 1, label: "Yes", answerKeys: `y${ENTER}` },
874
+ { index: 2, label: "No", answerKeys: `n${ENTER}` }
875
+ ]
876
+ };
877
+ }
878
+ if (NUMBERED_RE.test(last.text)) {
879
+ const options = [];
880
+ for (let i = 0; i <= last.idx; i++) {
881
+ const m = NUMBERED_RE.exec(lines[i]);
882
+ if (!m) continue;
883
+ const num = Number.parseInt(m[1], 10);
884
+ if (!Number.isFinite(num)) continue;
885
+ options.push({ index: num, label: m[2].trim(), answerKeys: `${num}${ENTER}` });
886
+ }
887
+ if (options.length >= 2) {
888
+ const firstRow = lines.findIndex((l) => NUMBERED_RE.test(l));
889
+ let prompt = "";
890
+ for (let i = firstRow - 1; i >= 0; i--) {
891
+ const t = lines[i].trim();
892
+ if (t.length === 0 || BOX_ONLY_RE3.test(t)) continue;
893
+ prompt = t;
894
+ break;
895
+ }
896
+ return { prompt: prompt || "Select an option", options };
897
+ }
898
+ }
899
+ if (PRESS_ENTER_RE.test(last.text) || CONTINUE_RE.test(last.text)) {
900
+ return {
901
+ prompt: last.text,
902
+ options: [{ index: 1, label: "Continue", answerKeys: ENTER }]
903
+ };
904
+ }
905
+ return null;
906
+ }
907
+
752
908
  // src/pty-manager.ts
753
909
  var OUTPUT_BUFFER_MAX = 65536;
754
910
  var PTY_COLS = 120;
@@ -799,6 +955,21 @@ var PTYManager = class {
799
955
  onOutput;
800
956
  onStatusChange;
801
957
  onReady;
958
+ onPermissionChange;
959
+ onLiveQuestion;
960
+ onLiveQuestionGone;
961
+ // Per-session permission-gate state. True between an OSC 777 (gate open) and
962
+ // the next prompt-ready without a fresh 777 (gate closed). Prevents
963
+ // re-broadcasting open/close on every chunk.
964
+ permissionOpen = /* @__PURE__ */ new Set();
965
+ // Content key of the last AskUserQuestion broadcast from the rendered screen,
966
+ // per session — de-dupes the same menu firing on consecutive repaints.
967
+ lastScreenQuestionKey = /* @__PURE__ */ new Map();
968
+ // Content key of the last unstructured shell prompt (detectShellPrompt)
969
+ // broadcast per session — present between open and resolve so we can clear it
970
+ // on a prompt-ready/marker return and de-dupe consecutive repaints. Modelled
971
+ // on permissionOpen but keyed by content (a shell prompt has no OSC trigger).
972
+ shellPromptOpen = /* @__PURE__ */ new Map();
802
973
  // Tracks sessions (both fresh and resume) whose PTY has spawned but Claude
803
974
  // hasn't yet reached an interactive prompt — i.e. onReady hasn't fired.
804
975
  pendingReady = /* @__PURE__ */ new Set();
@@ -821,6 +992,9 @@ var PTYManager = class {
821
992
  this.onOutput = options.onOutput;
822
993
  this.onStatusChange = options.onStatusChange;
823
994
  this.onReady = options.onReady;
995
+ this.onPermissionChange = options.onPermissionChange;
996
+ this.onLiveQuestion = options.onLiveQuestion;
997
+ this.onLiveQuestionGone = options.onLiveQuestionGone;
824
998
  this.log = options.logger ?? getLogger("pty");
825
999
  }
826
1000
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
@@ -1063,6 +1237,9 @@ var PTYManager = class {
1063
1237
  this.pendingReady.delete(sessionId);
1064
1238
  this.queuedInputs.delete(sessionId);
1065
1239
  this.firstChunkAt.delete(sessionId);
1240
+ this.permissionOpen.delete(sessionId);
1241
+ this.lastScreenQuestionKey.delete(sessionId);
1242
+ this.shellPromptOpen.delete(sessionId);
1066
1243
  try {
1067
1244
  session.process.kill("SIGINT");
1068
1245
  } catch {
@@ -1122,6 +1299,9 @@ var PTYManager = class {
1122
1299
  this.firstChunkAt.clear();
1123
1300
  this.chunkIndex.clear();
1124
1301
  this.lastChunkAt.clear();
1302
+ this.permissionOpen.clear();
1303
+ this.lastScreenQuestionKey.clear();
1304
+ this.shellPromptOpen.clear();
1125
1305
  }
1126
1306
  handleOutput(sessionId, data) {
1127
1307
  const session = this.sessions.get(sessionId);
@@ -1165,6 +1345,87 @@ var PTYManager = class {
1165
1345
  this.markReady(sessionId, session, "fallback:timeout");
1166
1346
  }
1167
1347
  this.onOutput?.(sessionId, data);
1348
+ this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
1349
+ this.log.warn("[pty.prompt_detect] failed", {
1350
+ event: "pty.prompt_detect_failed",
1351
+ sessionId,
1352
+ err
1353
+ });
1354
+ });
1355
+ }
1356
+ // Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
1357
+ // menus from the rendered screen, firing the additive callbacks. Async because
1358
+ // reading the rendered buffer needs the xterm write queue flushed. Pure
1359
+ // detection lives in services/questions/*; this only orchestrates triggers,
1360
+ // per-session debounce, and the callbacks.
1361
+ async detectLivePrompts(sessionId, rawData, stripped) {
1362
+ const session = this.sessions.get(sessionId);
1363
+ if (!session) return;
1364
+ const oscPermission = hasPermissionOsc(rawData);
1365
+ const hasAskFooter = /Enter to select/i.test(stripped);
1366
+ const hasPromptMarker = CLAUDE_PROMPT_MARKERS.some((m) => stripped.includes(m));
1367
+ const hasShellPromptHint = /[[(]\s*y\s*\/\s*n\s*[\])]|press\s+(enter|return|any key)|\bcontinue\b\s*\?|^\s*(?:❯|>)?\s*\d+[.)]\s+\S/im.test(
1368
+ stripped
1369
+ );
1370
+ if (!oscPermission && !hasAskFooter && !hasShellPromptHint && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
1371
+ return;
1372
+ }
1373
+ const lines = await this.getOutputLines(sessionId, 60);
1374
+ const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
1375
+ if (oscPermission || hasAskFooter || askFooterOnScreen) {
1376
+ this.log.debug?.(`[pty.prompt_detect] ${sessionId.slice(0, 8)} trigger`, {
1377
+ event: "pty.prompt_detect",
1378
+ sessionId,
1379
+ oscPermission,
1380
+ hasAskFooter,
1381
+ askFooterOnScreen,
1382
+ permGate: oscPermission && !askFooterOnScreen ? scrapePermissionGate(lines) : void 0,
1383
+ askQuestion: askFooterOnScreen ? detectQuestionFromScreen(lines) : void 0,
1384
+ renderedTail: lines.slice(-25)
1385
+ });
1386
+ }
1387
+ if (oscPermission && !askFooterOnScreen) {
1388
+ const gate = scrapePermissionGate(lines);
1389
+ this.permissionOpen.add(sessionId);
1390
+ this.onPermissionChange?.(sessionId, gate ?? { options: [] });
1391
+ } else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
1392
+ const gate = scrapePermissionGate(lines);
1393
+ if (gate) {
1394
+ this.onPermissionChange?.(sessionId, gate);
1395
+ } else if (hasPromptMarker) {
1396
+ this.permissionOpen.delete(sessionId);
1397
+ this.onPermissionChange?.(sessionId, null);
1398
+ }
1399
+ }
1400
+ if (askFooterOnScreen) {
1401
+ const detected = detectQuestionFromScreen(lines);
1402
+ if (detected) {
1403
+ const key = questionContentKey(detected.questions);
1404
+ if (this.lastScreenQuestionKey.get(sessionId) !== key) {
1405
+ this.lastScreenQuestionKey.set(sessionId, key);
1406
+ this.onLiveQuestion?.(sessionId, detected.questions);
1407
+ }
1408
+ }
1409
+ } else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
1410
+ this.lastScreenQuestionKey.delete(sessionId);
1411
+ this.onLiveQuestionGone?.(sessionId);
1412
+ }
1413
+ if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
1414
+ const shell = detectShellPrompt(lines);
1415
+ if (shell) {
1416
+ const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
1417
+ if (this.shellPromptOpen.get(sessionId) !== key) {
1418
+ this.shellPromptOpen.set(sessionId, key);
1419
+ this.onPermissionChange?.(sessionId, {
1420
+ prompt: shell.prompt,
1421
+ options: shell.options
1422
+ });
1423
+ }
1424
+ } else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
1425
+ this.shellPromptOpen.delete(sessionId);
1426
+ this.onPermissionChange?.(sessionId, null);
1427
+ }
1428
+ }
1168
1429
  }
1169
1430
  // Transition a session from "running" to "waiting_input", clear pendingReady,
1170
1431
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
@@ -1203,6 +1464,9 @@ var PTYManager = class {
1203
1464
  this.sessions.delete(sessionId);
1204
1465
  this.queuedInputs.delete(sessionId);
1205
1466
  this.firstChunkAt.delete(sessionId);
1467
+ this.permissionOpen.delete(sessionId);
1468
+ this.lastScreenQuestionKey.delete(sessionId);
1469
+ this.shellPromptOpen.delete(sessionId);
1206
1470
  }
1207
1471
  };
1208
1472
  function toPublicSession(s) {
@@ -1863,6 +2127,10 @@ var createSessionRoutes = (deps) => {
1863
2127
  await deps.handleSendInput(c.req.param("id"), c.env.incoming, c.env.outgoing);
1864
2128
  return alreadyHandled6();
1865
2129
  });
2130
+ app.post("/:id/answer", async (c) => {
2131
+ await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
2132
+ return alreadyHandled6();
2133
+ });
1866
2134
  app.post("/:id/files", async (c) => {
1867
2135
  await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
1868
2136
  return alreadyHandled6();
@@ -3486,6 +3754,129 @@ function pruneAgentConversations(cache) {
3486
3754
  return { scanned: rows.length, pruned, missing };
3487
3755
  }
3488
3756
 
3757
+ // src/services/questions/detectAskUserQuestion.ts
3758
+ function normalizeContent2(raw) {
3759
+ if (Array.isArray(raw)) return raw;
3760
+ if (typeof raw === "string") return [{ type: "text", text: raw }];
3761
+ return [];
3762
+ }
3763
+ function coerceOptions(raw) {
3764
+ if (!Array.isArray(raw)) return null;
3765
+ const out = [];
3766
+ for (const o of raw) {
3767
+ if (o && typeof o === "object" && typeof o.label === "string") {
3768
+ const opt = o;
3769
+ out.push({
3770
+ label: opt.label,
3771
+ description: typeof opt.description === "string" ? opt.description : "",
3772
+ ...typeof opt.preview === "string" ? { preview: opt.preview } : {}
3773
+ });
3774
+ }
3775
+ }
3776
+ return out.length > 0 ? out : null;
3777
+ }
3778
+ function coerceQuestions(raw) {
3779
+ if (!Array.isArray(raw)) return null;
3780
+ const out = [];
3781
+ for (const q of raw) {
3782
+ if (!q || typeof q !== "object") continue;
3783
+ const qq = q;
3784
+ const options = coerceOptions(qq.options);
3785
+ if (typeof qq.question !== "string" || !options) continue;
3786
+ out.push({
3787
+ question: qq.question,
3788
+ header: typeof qq.header === "string" ? qq.header : "",
3789
+ multiSelect: qq.multiSelect === true,
3790
+ options
3791
+ });
3792
+ }
3793
+ return out.length > 0 ? out : null;
3794
+ }
3795
+ function detectAskUserQuestion(rawLine) {
3796
+ let parsed;
3797
+ try {
3798
+ parsed = JSON.parse(rawLine);
3799
+ } catch {
3800
+ return null;
3801
+ }
3802
+ const blocks = normalizeContent2(parsed.message?.content ?? parsed.content);
3803
+ for (const b of blocks) {
3804
+ if (b.type === "tool_use" && b.name === "AskUserQuestion" && typeof b.id === "string") {
3805
+ const input = b.input;
3806
+ const questions = coerceQuestions(input?.questions);
3807
+ if (questions) return { toolUseId: b.id, questions };
3808
+ }
3809
+ }
3810
+ return null;
3811
+ }
3812
+
3813
+ // src/services/questions/questionBroadcast.ts
3814
+ function questionsFromLines(sessionId, lines) {
3815
+ const messages = [];
3816
+ const pending = [];
3817
+ for (const line of lines) {
3818
+ const detected = detectAskUserQuestion(line);
3819
+ if (detected) {
3820
+ messages.push({
3821
+ type: "question",
3822
+ sessionId,
3823
+ toolUseId: detected.toolUseId,
3824
+ questions: detected.questions
3825
+ });
3826
+ pending.push(detected);
3827
+ }
3828
+ }
3829
+ return { messages, pending };
3830
+ }
3831
+ function shouldBroadcastQuestion(args) {
3832
+ const alreadyShown = args.lastContentKey === args.newContentKey;
3833
+ if (!alreadyShown) return true;
3834
+ return args.priorToolUseId !== args.newToolUseId;
3835
+ }
3836
+
3837
+ // src/services/questions/answersToKeystrokes.ts
3838
+ var DOWN = "\x1B[B";
3839
+ var ENTER2 = "\r";
3840
+ var UnknownOptionError = class extends Error {
3841
+ constructor(question, value) {
3842
+ super(`No option labelled "${value}" for question "${question}"`);
3843
+ this.question = question;
3844
+ this.value = value;
3845
+ this.name = "UnknownOptionError";
3846
+ }
3847
+ question;
3848
+ value;
3849
+ };
3850
+ function answersToKeystrokes(questions, answers) {
3851
+ let out = "";
3852
+ for (const q of questions) {
3853
+ const raw = answers[q.question];
3854
+ if (raw === void 0) {
3855
+ throw new Error(`Missing answer for question "${q.question}"`);
3856
+ }
3857
+ const label = Array.isArray(raw) ? raw[0] : raw;
3858
+ const target = q.options.findIndex((o) => o.label === label);
3859
+ if (target < 0) throw new UnknownOptionError(q.question, label);
3860
+ out += DOWN.repeat(target) + ENTER2;
3861
+ }
3862
+ return out;
3863
+ }
3864
+
3865
+ // src/services/questions/resolveAnswer.ts
3866
+ function resolveAnswer(pending, body) {
3867
+ if (!pending) return { ok: false, reason: "no_pending_question" };
3868
+ if (typeof body.toolUseId !== "string" || body.toolUseId !== pending.toolUseId) {
3869
+ return { ok: false, reason: "tool_use_mismatch" };
3870
+ }
3871
+ const answers = body.answers ?? {};
3872
+ try {
3873
+ return { ok: true, keys: answersToKeystrokes(pending.questions, answers) };
3874
+ } catch (e) {
3875
+ if (e instanceof UnknownOptionError) return { ok: false, reason: "unknown_option" };
3876
+ throw e;
3877
+ }
3878
+ }
3879
+
3489
3880
  // src/session-store.ts
3490
3881
  var SessionStore = class {
3491
3882
  managed = /* @__PURE__ */ new Map();
@@ -3916,6 +4307,16 @@ var StreamerServer = class {
3916
4307
  fileWatcher;
3917
4308
  sessionFileMap = /* @__PURE__ */ new Map();
3918
4309
  // sessionId → JSONL filePath
4310
+ pendingQuestions = /* @__PURE__ */ new Map();
4311
+ // Content key of the AskUserQuestion currently broadcast for a session (from
4312
+ // either the rendered screen or JSONL), used to de-dupe the two paths: when
4313
+ // the screen detection fires first, the later JSONL flush of the same question
4314
+ // is suppressed. Cleared alongside pendingQuestions.
4315
+ pendingQuestionKey = /* @__PURE__ */ new Map();
4316
+ // Per-session permission gate currently open (scraped via OSC 777). Parallel
4317
+ // to pendingQuestions; mobile answers it by sending the option index via
4318
+ // /input { keys }. Cleared when the gate closes.
4319
+ pendingPermission = /* @__PURE__ */ new Map();
3919
4320
  scanner = null;
3920
4321
  scannerReady = null;
3921
4322
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
@@ -4011,6 +4412,28 @@ var StreamerServer = class {
4011
4412
  this.cache?.updateFromLines(filePath, lines);
4012
4413
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
4013
4414
  if (watchedPath === filePath) {
4415
+ const priorToolUseId = this.pendingQuestions.get(sessionId)?.toolUseId;
4416
+ const { messages, pending } = questionsFromLines(sessionId, lines);
4417
+ for (const p of pending) {
4418
+ this.pendingQuestions.set(sessionId, p);
4419
+ const t = setTimeout(() => {
4420
+ if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
4421
+ this.cancelPendingQuestion(sessionId);
4422
+ }
4423
+ }, 6e4);
4424
+ t.unref();
4425
+ }
4426
+ for (const m of messages) {
4427
+ const key = questionContentKey(m.questions);
4428
+ const broadcast = shouldBroadcastQuestion({
4429
+ newContentKey: key,
4430
+ lastContentKey: this.pendingQuestionKey.get(sessionId),
4431
+ newToolUseId: m.toolUseId,
4432
+ priorToolUseId
4433
+ });
4434
+ this.pendingQuestionKey.set(sessionId, key);
4435
+ if (broadcast) this.wsHub.broadcast(m);
4436
+ }
4014
4437
  this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
4015
4438
  for (const line of lines) {
4016
4439
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
@@ -4042,6 +4465,19 @@ var StreamerServer = class {
4042
4465
  onOutput: (sessionId, data) => {
4043
4466
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
4044
4467
  },
4468
+ onPermissionChange: (sessionId, gate) => {
4469
+ this.handlePermissionChange(sessionId, gate);
4470
+ },
4471
+ onLiveQuestion: (sessionId, questions) => {
4472
+ this.handleLiveQuestion(sessionId, questions);
4473
+ },
4474
+ onLiveQuestionGone: (sessionId) => {
4475
+ this.pendingQuestionKey.delete(sessionId);
4476
+ const pq = this.pendingQuestions.get(sessionId);
4477
+ if (pq?.toolUseId.startsWith("screen:")) {
4478
+ this.cancelPendingQuestion(sessionId);
4479
+ }
4480
+ },
4045
4481
  onReady: (session) => {
4046
4482
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
4047
4483
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
@@ -4079,7 +4515,9 @@ var StreamerServer = class {
4079
4515
  if (filePath) {
4080
4516
  this.fileWatcher.unwatch(filePath);
4081
4517
  this.sessionFileMap.delete(session.id);
4518
+ this.cancelPendingQuestion(session.id);
4082
4519
  }
4520
+ this.pendingPermission.delete(session.id);
4083
4521
  }
4084
4522
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
4085
4523
  if (resp) {
@@ -4130,6 +4568,7 @@ var StreamerServer = class {
4130
4568
  handleGetSession: (id, res) => this.handleGetSession(id, res),
4131
4569
  handleGetOutput: (id, res) => this.handleGetOutput(id, res),
4132
4570
  handleSendInput: (id, req, res) => this.handleSendInput(id, req, res),
4571
+ handleSendAnswer: (id, req, res) => this.handleSendAnswer(id, req, res),
4133
4572
  handleCancel: (id, res) => this.handleCancel(id, res),
4134
4573
  handleStopSession: (id, res) => this.handleStopSession(id, res),
4135
4574
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
@@ -4265,16 +4704,28 @@ var StreamerServer = class {
4265
4704
  if (existing) clearTimeout(existing);
4266
4705
  const timer = setTimeout(() => {
4267
4706
  this.ptyGraceTimers.delete(sessionId);
4268
- this.sessionSubscribers.delete(sessionId);
4269
4707
  if (this.ptyManager.hasSession(sessionId)) {
4708
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
4709
+ if (resp?.status === "running") {
4710
+ this.log.info(
4711
+ `[grace] session ${sessionId} still running, deferring hold`,
4712
+ { sessionId, event: "pty.grace_defer" },
4713
+ "pino"
4714
+ );
4715
+ this.startGraceTimer(sessionId, delayMs);
4716
+ return;
4717
+ }
4718
+ this.sessionSubscribers.delete(sessionId);
4270
4719
  this.log.info(
4271
4720
  `[grace] killing idle PTY for ${sessionId}`,
4272
4721
  { sessionId, event: "pty.grace_kill" },
4273
4722
  "pino"
4274
4723
  );
4275
4724
  this.ptyManager.putOnHold(sessionId);
4276
- const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
4277
- if (resp) this.wsHub.broadcast({ type: "session_update", session: resp });
4725
+ const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
4726
+ if (held) this.wsHub.broadcast({ type: "session_update", session: held });
4727
+ } else {
4728
+ this.sessionSubscribers.delete(sessionId);
4278
4729
  }
4279
4730
  }, delayMs);
4280
4731
  this.ptyGraceTimers.set(sessionId, timer);
@@ -5253,6 +5704,65 @@ var StreamerServer = class {
5253
5704
  json(res, 400, { error: message });
5254
5705
  }
5255
5706
  }
5707
+ cancelPendingQuestion(sessionId) {
5708
+ const pq = this.pendingQuestions.get(sessionId);
5709
+ if (!pq) return;
5710
+ this.pendingQuestions.delete(sessionId);
5711
+ this.pendingQuestionKey.delete(sessionId);
5712
+ this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId: pq.toolUseId });
5713
+ }
5714
+ // Live AskUserQuestion detected from the rendered screen (ahead of JSONL).
5715
+ // Broadcasts the `question` event immediately and records the content key so
5716
+ // the later JSONL flush of the same question is de-duped. We synthesize a
5717
+ // screen-scoped toolUseId; the JSONL path overwrites pendingQuestions with the
5718
+ // real toolUseId when it lands, so answering works once JSONL catches up.
5719
+ handleLiveQuestion(sessionId, questions) {
5720
+ const key = questionContentKey(questions);
5721
+ if (this.pendingQuestionKey.get(sessionId) === key) return;
5722
+ const toolUseId = `screen:${sessionId}:${key.length}`;
5723
+ this.pendingQuestions.set(sessionId, { toolUseId, questions });
5724
+ this.pendingQuestionKey.set(sessionId, key);
5725
+ this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
5726
+ }
5727
+ // Permission gate opened/closed (OSC 777 + scraped options). Broadcasts the
5728
+ // additive `permission` / `permission_cancelled` events. Mobile answers by
5729
+ // sending the chosen option index via /input { keys } (e.g. "2\r").
5730
+ handlePermissionChange(sessionId, gate) {
5731
+ if (gate === null) {
5732
+ if (!this.pendingPermission.has(sessionId)) return;
5733
+ this.pendingPermission.delete(sessionId);
5734
+ this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
5735
+ return;
5736
+ }
5737
+ this.pendingPermission.set(sessionId, gate);
5738
+ this.wsHub.broadcast({
5739
+ type: "permission",
5740
+ sessionId,
5741
+ ...gate.prompt ? { prompt: gate.prompt } : {},
5742
+ options: gate.options,
5743
+ ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
5744
+ });
5745
+ }
5746
+ async handleSendAnswer(sessionId, req, res) {
5747
+ const body = await readBody(req);
5748
+ const pending = this.pendingQuestions.get(sessionId);
5749
+ const resolution = resolveAnswer(pending, body);
5750
+ if (!resolution.ok) {
5751
+ json(res, 400, { ok: false, reason: resolution.reason });
5752
+ return;
5753
+ }
5754
+ const toolUseId = pending?.toolUseId ?? "";
5755
+ try {
5756
+ this.ptyManager.sendKeys(sessionId, resolution.keys);
5757
+ } catch (err) {
5758
+ const message = err instanceof Error ? err.message : "Failed to send answer";
5759
+ json(res, 400, { ok: false, reason: message });
5760
+ return;
5761
+ }
5762
+ this.pendingQuestions.delete(sessionId);
5763
+ this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId });
5764
+ json(res, 200, { ok: true });
5765
+ }
5256
5766
  async handleUploadFile(sessionId, req, res) {
5257
5767
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
5258
5768
  if (!session) {