@threadbase-sh/streamer 1.16.1 → 1.17.1

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
@@ -124790,12 +124790,23 @@ async function tryBind(start, offset) {
124790
124790
  if (free) return port;
124791
124791
  return tryBind(start, offset + 1);
124792
124792
  }
124793
+ function isPidAlive2(pid) {
124794
+ try {
124795
+ process.kill(pid, 0);
124796
+ return true;
124797
+ } catch {
124798
+ return false;
124799
+ }
124800
+ }
124793
124801
  function takeoverProd(opts) {
124794
124802
  const existing = readMarker();
124795
124803
  if (existing) {
124796
- throw new Error(
124797
- `prod is already suspended by dev pid ${existing.devPid} (since ${existing.suspendedAt}). Stop that dev session first, or run 'tb-streamer prod doctor'.`
124798
- );
124804
+ if (isPidAlive2(existing.devPid)) {
124805
+ throw new Error(
124806
+ `prod is already suspended by dev pid ${existing.devPid} (since ${existing.suspendedAt}). Stop that dev session first, or run 'tb-streamer prod doctor'.`
124807
+ );
124808
+ }
124809
+ log6.info(`stale marker found (pid ${existing.devPid} is gone) \u2014 clearing and proceeding`);
124799
124810
  }
124800
124811
  getSupervisor().bootoutAgent();
124801
124812
  writeMarker({
@@ -133470,6 +133481,10 @@ var createSessionRoutes = (deps) => {
133470
133481
  await deps.handleSendInput(c.req.param("id"), c.env.incoming, c.env.outgoing);
133471
133482
  return alreadyHandled6();
133472
133483
  });
133484
+ app.post("/:id/answer", async (c) => {
133485
+ await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
133486
+ return alreadyHandled6();
133487
+ });
133473
133488
  app.post("/:id/files", async (c) => {
133474
133489
  await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
133475
133490
  return alreadyHandled6();
@@ -135452,6 +135467,164 @@ var import_crypto5 = require("crypto");
135452
135467
  var import_fs11 = require("fs");
135453
135468
  var import_path12 = require("path");
135454
135469
  init_logger();
135470
+
135471
+ // src/services/questions/detectPermissionGate.ts
135472
+ var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
135473
+ function hasPermissionOsc(rawData) {
135474
+ return OSC_777_RE.test(rawData);
135475
+ }
135476
+ var OPTION_RE = /^\s*(❯)?\s*(\d+)\.\s+(.+?)\s*$/;
135477
+ var FOOTER_RE = /Enter to select|Esc to cancel|↑|↓|to navigate|to cancel/i;
135478
+ var BOX_ONLY_RE = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
135479
+ var PROMPT_ARROW_RE = /^[\s]*[❯›>]\s*$/;
135480
+ function stripGutter(line) {
135481
+ return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
135482
+ }
135483
+ function scrapePermissionGate(lines) {
135484
+ const options = [];
135485
+ let cursor;
135486
+ let firstOptionLine = -1;
135487
+ for (let i = 0; i < lines.length; i++) {
135488
+ const m2 = OPTION_RE.exec(stripGutter(lines[i]));
135489
+ if (!m2) continue;
135490
+ const index = Number.parseInt(m2[2], 10);
135491
+ if (!Number.isFinite(index)) continue;
135492
+ if (firstOptionLine === -1) firstOptionLine = i;
135493
+ if (m2[1]) cursor = index;
135494
+ options.push({ index, label: m2[3] });
135495
+ }
135496
+ if (options.length === 0) return null;
135497
+ let prompt;
135498
+ for (let i = firstOptionLine - 1; i >= 0; i--) {
135499
+ const t2 = stripGutter(lines[i]).trim();
135500
+ if (t2.length === 0) continue;
135501
+ if (BOX_ONLY_RE.test(lines[i].trim()) || FOOTER_RE.test(t2) || PROMPT_ARROW_RE.test(t2)) continue;
135502
+ prompt = t2 || void 0;
135503
+ break;
135504
+ }
135505
+ return { ...prompt ? { prompt } : {}, options, ...cursor !== void 0 ? { cursor } : {} };
135506
+ }
135507
+
135508
+ // src/services/questions/detectQuestionFromScreen.ts
135509
+ var ASK_FOOTER_RE = /Enter to select/i;
135510
+ var ESC_FOOTER_RE = /Esc to cancel|to navigate/i;
135511
+ var OPTION_RE2 = /^\s*(?:❯)?\s*(\d+)\.\s+(.+?)\s*$/;
135512
+ var QUESTION_RE = /\?\s*$/;
135513
+ var BOX_ONLY_RE2 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
135514
+ var PERMISSION_LABEL_RE = /^(Yes|No)\b/i;
135515
+ function stripBoxGutter(line) {
135516
+ return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
135517
+ }
135518
+ function detectQuestionFromScreen(lines) {
135519
+ let footerIdx = -1;
135520
+ for (let i = lines.length - 1; i >= 0; i--) {
135521
+ if (ASK_FOOTER_RE.test(lines[i])) {
135522
+ footerIdx = i;
135523
+ break;
135524
+ }
135525
+ }
135526
+ if (footerIdx === -1) return null;
135527
+ const options = [];
135528
+ let firstOptionIdx = -1;
135529
+ for (let i = footerIdx - 1; i >= 0; i--) {
135530
+ const line = lines[i];
135531
+ const inner = stripBoxGutter(line);
135532
+ const trimmed = inner.trim();
135533
+ if (trimmed.length === 0) {
135534
+ if (options.length === 0) continue;
135535
+ break;
135536
+ }
135537
+ if (BOX_ONLY_RE2.test(line.trim())) continue;
135538
+ if (ESC_FOOTER_RE.test(line) && options.length === 0) continue;
135539
+ if (options.length > 0 && QUESTION_RE.test(trimmed) && !OPTION_RE2.test(inner)) break;
135540
+ const m2 = OPTION_RE2.exec(inner);
135541
+ if (m2) {
135542
+ const label = m2[2].trim();
135543
+ if (PERMISSION_LABEL_RE.test(label)) return null;
135544
+ options.unshift({ label, description: "" });
135545
+ firstOptionIdx = i;
135546
+ }
135547
+ }
135548
+ if (options.length < 2 || firstOptionIdx === -1) return null;
135549
+ let question;
135550
+ for (let i = firstOptionIdx - 1; i >= 0; i--) {
135551
+ const raw2 = stripBoxGutter(lines[i]);
135552
+ const trimmed = raw2.trim();
135553
+ if (trimmed.length === 0) continue;
135554
+ if (BOX_ONLY_RE2.test(lines[i].trim())) continue;
135555
+ if (QUESTION_RE.test(trimmed)) {
135556
+ question = trimmed;
135557
+ }
135558
+ break;
135559
+ }
135560
+ if (!question) return null;
135561
+ return {
135562
+ questions: [{ question, header: "", multiSelect: false, options }]
135563
+ };
135564
+ }
135565
+ function questionContentKey(questions) {
135566
+ return questions.map((q2) => `${q2.question} ${q2.options.map((o) => o.label).join(",")}`).join("::");
135567
+ }
135568
+
135569
+ // src/services/questions/detectShellPrompt.ts
135570
+ var ENTER = "\r";
135571
+ var YN_RE = /[[(]\s*y\s*\/\s*n\s*[\])]/i;
135572
+ var PRESS_ENTER_RE = /press\s+(enter|return|any key)/i;
135573
+ var CONTINUE_RE = /\bcontinue\b\s*\??\s*$/i;
135574
+ var NUMBERED_RE = /^\s*(?:❯|>)?\s*(\d+)[.)]\s+(.+?)\s*$/;
135575
+ var CLAUDE_CHROME_RE = /Enter to select|Esc to cancel|╭|╰|│.*│/;
135576
+ var BOX_ONLY_RE3 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
135577
+ function lastNonBlank(lines) {
135578
+ for (let i = lines.length - 1; i >= 0; i--) {
135579
+ const t2 = lines[i].trim();
135580
+ if (t2.length > 0) return { text: t2, idx: i };
135581
+ }
135582
+ return null;
135583
+ }
135584
+ function detectShellPrompt(lines) {
135585
+ if (lines.some((l) => CLAUDE_CHROME_RE.test(l))) return null;
135586
+ const last = lastNonBlank(lines);
135587
+ if (!last) return null;
135588
+ if (YN_RE.test(last.text)) {
135589
+ return {
135590
+ prompt: last.text,
135591
+ options: [
135592
+ { index: 1, label: "Yes", answerKeys: `y${ENTER}` },
135593
+ { index: 2, label: "No", answerKeys: `n${ENTER}` }
135594
+ ]
135595
+ };
135596
+ }
135597
+ if (NUMBERED_RE.test(last.text)) {
135598
+ const options = [];
135599
+ for (let i = 0; i <= last.idx; i++) {
135600
+ const m2 = NUMBERED_RE.exec(lines[i]);
135601
+ if (!m2) continue;
135602
+ const num = Number.parseInt(m2[1], 10);
135603
+ if (!Number.isFinite(num)) continue;
135604
+ options.push({ index: num, label: m2[2].trim(), answerKeys: `${num}${ENTER}` });
135605
+ }
135606
+ if (options.length >= 2) {
135607
+ const firstRow = lines.findIndex((l) => NUMBERED_RE.test(l));
135608
+ let prompt = "";
135609
+ for (let i = firstRow - 1; i >= 0; i--) {
135610
+ const t2 = lines[i].trim();
135611
+ if (t2.length === 0 || BOX_ONLY_RE3.test(t2)) continue;
135612
+ prompt = t2;
135613
+ break;
135614
+ }
135615
+ return { prompt: prompt || "Select an option", options };
135616
+ }
135617
+ }
135618
+ if (PRESS_ENTER_RE.test(last.text) || CONTINUE_RE.test(last.text)) {
135619
+ return {
135620
+ prompt: last.text,
135621
+ options: [{ index: 1, label: "Continue", answerKeys: ENTER }]
135622
+ };
135623
+ }
135624
+ return null;
135625
+ }
135626
+
135627
+ // src/pty-manager.ts
135455
135628
  var OUTPUT_BUFFER_MAX = 65536;
135456
135629
  var PTY_COLS = 120;
135457
135630
  var PTY_ROWS = 40;
@@ -135501,6 +135674,21 @@ var PTYManager = class {
135501
135674
  onOutput;
135502
135675
  onStatusChange;
135503
135676
  onReady;
135677
+ onPermissionChange;
135678
+ onLiveQuestion;
135679
+ onLiveQuestionGone;
135680
+ // Per-session permission-gate state. True between an OSC 777 (gate open) and
135681
+ // the next prompt-ready without a fresh 777 (gate closed). Prevents
135682
+ // re-broadcasting open/close on every chunk.
135683
+ permissionOpen = /* @__PURE__ */ new Set();
135684
+ // Content key of the last AskUserQuestion broadcast from the rendered screen,
135685
+ // per session — de-dupes the same menu firing on consecutive repaints.
135686
+ lastScreenQuestionKey = /* @__PURE__ */ new Map();
135687
+ // Content key of the last unstructured shell prompt (detectShellPrompt)
135688
+ // broadcast per session — present between open and resolve so we can clear it
135689
+ // on a prompt-ready/marker return and de-dupe consecutive repaints. Modelled
135690
+ // on permissionOpen but keyed by content (a shell prompt has no OSC trigger).
135691
+ shellPromptOpen = /* @__PURE__ */ new Map();
135504
135692
  // Tracks sessions (both fresh and resume) whose PTY has spawned but Claude
135505
135693
  // hasn't yet reached an interactive prompt — i.e. onReady hasn't fired.
135506
135694
  pendingReady = /* @__PURE__ */ new Set();
@@ -135523,6 +135711,9 @@ var PTYManager = class {
135523
135711
  this.onOutput = options.onOutput;
135524
135712
  this.onStatusChange = options.onStatusChange;
135525
135713
  this.onReady = options.onReady;
135714
+ this.onPermissionChange = options.onPermissionChange;
135715
+ this.onLiveQuestion = options.onLiveQuestion;
135716
+ this.onLiveQuestionGone = options.onLiveQuestionGone;
135526
135717
  this.log = options.logger ?? getLogger("pty");
135527
135718
  }
135528
135719
  // Resume an existing Claude conversation. sessionId is the JSONL UUID.
@@ -135765,6 +135956,9 @@ var PTYManager = class {
135765
135956
  this.pendingReady.delete(sessionId);
135766
135957
  this.queuedInputs.delete(sessionId);
135767
135958
  this.firstChunkAt.delete(sessionId);
135959
+ this.permissionOpen.delete(sessionId);
135960
+ this.lastScreenQuestionKey.delete(sessionId);
135961
+ this.shellPromptOpen.delete(sessionId);
135768
135962
  try {
135769
135963
  session.process.kill("SIGINT");
135770
135964
  } catch {
@@ -135824,6 +136018,9 @@ var PTYManager = class {
135824
136018
  this.firstChunkAt.clear();
135825
136019
  this.chunkIndex.clear();
135826
136020
  this.lastChunkAt.clear();
136021
+ this.permissionOpen.clear();
136022
+ this.lastScreenQuestionKey.clear();
136023
+ this.shellPromptOpen.clear();
135827
136024
  }
135828
136025
  handleOutput(sessionId, data) {
135829
136026
  const session = this.sessions.get(sessionId);
@@ -135867,6 +136064,87 @@ var PTYManager = class {
135867
136064
  this.markReady(sessionId, session, "fallback:timeout");
135868
136065
  }
135869
136066
  this.onOutput?.(sessionId, data);
136067
+ this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
136068
+ this.log.warn("[pty.prompt_detect] failed", {
136069
+ event: "pty.prompt_detect_failed",
136070
+ sessionId,
136071
+ err
136072
+ });
136073
+ });
136074
+ }
136075
+ // Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
136076
+ // menus from the rendered screen, firing the additive callbacks. Async because
136077
+ // reading the rendered buffer needs the xterm write queue flushed. Pure
136078
+ // detection lives in services/questions/*; this only orchestrates triggers,
136079
+ // per-session debounce, and the callbacks.
136080
+ async detectLivePrompts(sessionId, rawData, stripped) {
136081
+ const session = this.sessions.get(sessionId);
136082
+ if (!session) return;
136083
+ const oscPermission = hasPermissionOsc(rawData);
136084
+ const hasAskFooter = /Enter to select/i.test(stripped);
136085
+ const hasPromptMarker = CLAUDE_PROMPT_MARKERS.some((m2) => stripped.includes(m2));
136086
+ const hasShellPromptHint = /[[(]\s*y\s*\/\s*n\s*[\])]|press\s+(enter|return|any key)|\bcontinue\b\s*\?|^\s*(?:❯|>)?\s*\d+[.)]\s+\S/im.test(
136087
+ stripped
136088
+ );
136089
+ if (!oscPermission && !hasAskFooter && !hasShellPromptHint && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
136090
+ return;
136091
+ }
136092
+ const lines = await this.getOutputLines(sessionId, 60);
136093
+ const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
136094
+ if (oscPermission || hasAskFooter || askFooterOnScreen) {
136095
+ this.log.debug?.(`[pty.prompt_detect] ${sessionId.slice(0, 8)} trigger`, {
136096
+ event: "pty.prompt_detect",
136097
+ sessionId,
136098
+ oscPermission,
136099
+ hasAskFooter,
136100
+ askFooterOnScreen,
136101
+ permGate: oscPermission && !askFooterOnScreen ? scrapePermissionGate(lines) : void 0,
136102
+ askQuestion: askFooterOnScreen ? detectQuestionFromScreen(lines) : void 0,
136103
+ renderedTail: lines.slice(-25)
136104
+ });
136105
+ }
136106
+ if (oscPermission && !askFooterOnScreen) {
136107
+ const gate = scrapePermissionGate(lines);
136108
+ this.permissionOpen.add(sessionId);
136109
+ this.onPermissionChange?.(sessionId, gate ?? { options: [] });
136110
+ } else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
136111
+ const gate = scrapePermissionGate(lines);
136112
+ if (gate) {
136113
+ this.onPermissionChange?.(sessionId, gate);
136114
+ } else if (hasPromptMarker) {
136115
+ this.permissionOpen.delete(sessionId);
136116
+ this.onPermissionChange?.(sessionId, null);
136117
+ }
136118
+ }
136119
+ if (askFooterOnScreen) {
136120
+ const detected = detectQuestionFromScreen(lines);
136121
+ if (detected) {
136122
+ const key = questionContentKey(detected.questions);
136123
+ if (this.lastScreenQuestionKey.get(sessionId) !== key) {
136124
+ this.lastScreenQuestionKey.set(sessionId, key);
136125
+ this.onLiveQuestion?.(sessionId, detected.questions);
136126
+ }
136127
+ }
136128
+ } else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
136129
+ this.lastScreenQuestionKey.delete(sessionId);
136130
+ this.onLiveQuestionGone?.(sessionId);
136131
+ }
136132
+ if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
136133
+ const shell = detectShellPrompt(lines);
136134
+ if (shell) {
136135
+ const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
136136
+ if (this.shellPromptOpen.get(sessionId) !== key) {
136137
+ this.shellPromptOpen.set(sessionId, key);
136138
+ this.onPermissionChange?.(sessionId, {
136139
+ prompt: shell.prompt,
136140
+ options: shell.options
136141
+ });
136142
+ }
136143
+ } else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
136144
+ this.shellPromptOpen.delete(sessionId);
136145
+ this.onPermissionChange?.(sessionId, null);
136146
+ }
136147
+ }
135870
136148
  }
135871
136149
  // Transition a session from "running" to "waiting_input", clear pendingReady,
135872
136150
  // and flush any queued input. Idempotent: callers can invoke at any chunk.
@@ -135905,6 +136183,9 @@ var PTYManager = class {
135905
136183
  this.sessions.delete(sessionId);
135906
136184
  this.queuedInputs.delete(sessionId);
135907
136185
  this.firstChunkAt.delete(sessionId);
136186
+ this.permissionOpen.delete(sessionId);
136187
+ this.lastScreenQuestionKey.delete(sessionId);
136188
+ this.shellPromptOpen.delete(sessionId);
135908
136189
  }
135909
136190
  };
135910
136191
  function toPublicSession(s3) {
@@ -137830,6 +138111,129 @@ function pruneAgentConversations(cache) {
137830
138111
  return { scanned: rows.length, pruned, missing };
137831
138112
  }
137832
138113
 
138114
+ // src/services/questions/detectAskUserQuestion.ts
138115
+ function normalizeContent2(raw2) {
138116
+ if (Array.isArray(raw2)) return raw2;
138117
+ if (typeof raw2 === "string") return [{ type: "text", text: raw2 }];
138118
+ return [];
138119
+ }
138120
+ function coerceOptions(raw2) {
138121
+ if (!Array.isArray(raw2)) return null;
138122
+ const out = [];
138123
+ for (const o of raw2) {
138124
+ if (o && typeof o === "object" && typeof o.label === "string") {
138125
+ const opt = o;
138126
+ out.push({
138127
+ label: opt.label,
138128
+ description: typeof opt.description === "string" ? opt.description : "",
138129
+ ...typeof opt.preview === "string" ? { preview: opt.preview } : {}
138130
+ });
138131
+ }
138132
+ }
138133
+ return out.length > 0 ? out : null;
138134
+ }
138135
+ function coerceQuestions(raw2) {
138136
+ if (!Array.isArray(raw2)) return null;
138137
+ const out = [];
138138
+ for (const q2 of raw2) {
138139
+ if (!q2 || typeof q2 !== "object") continue;
138140
+ const qq = q2;
138141
+ const options = coerceOptions(qq.options);
138142
+ if (typeof qq.question !== "string" || !options) continue;
138143
+ out.push({
138144
+ question: qq.question,
138145
+ header: typeof qq.header === "string" ? qq.header : "",
138146
+ multiSelect: qq.multiSelect === true,
138147
+ options
138148
+ });
138149
+ }
138150
+ return out.length > 0 ? out : null;
138151
+ }
138152
+ function detectAskUserQuestion(rawLine) {
138153
+ let parsed;
138154
+ try {
138155
+ parsed = JSON.parse(rawLine);
138156
+ } catch {
138157
+ return null;
138158
+ }
138159
+ const blocks = normalizeContent2(parsed.message?.content ?? parsed.content);
138160
+ for (const b2 of blocks) {
138161
+ if (b2.type === "tool_use" && b2.name === "AskUserQuestion" && typeof b2.id === "string") {
138162
+ const input = b2.input;
138163
+ const questions = coerceQuestions(input?.questions);
138164
+ if (questions) return { toolUseId: b2.id, questions };
138165
+ }
138166
+ }
138167
+ return null;
138168
+ }
138169
+
138170
+ // src/services/questions/questionBroadcast.ts
138171
+ function questionsFromLines(sessionId, lines) {
138172
+ const messages = [];
138173
+ const pending = [];
138174
+ for (const line of lines) {
138175
+ const detected = detectAskUserQuestion(line);
138176
+ if (detected) {
138177
+ messages.push({
138178
+ type: "question",
138179
+ sessionId,
138180
+ toolUseId: detected.toolUseId,
138181
+ questions: detected.questions
138182
+ });
138183
+ pending.push(detected);
138184
+ }
138185
+ }
138186
+ return { messages, pending };
138187
+ }
138188
+ function shouldBroadcastQuestion(args) {
138189
+ const alreadyShown = args.lastContentKey === args.newContentKey;
138190
+ if (!alreadyShown) return true;
138191
+ return args.priorToolUseId !== args.newToolUseId;
138192
+ }
138193
+
138194
+ // src/services/questions/answersToKeystrokes.ts
138195
+ var DOWN = "\x1B[B";
138196
+ var ENTER2 = "\r";
138197
+ var UnknownOptionError = class extends Error {
138198
+ constructor(question, value) {
138199
+ super(`No option labelled "${value}" for question "${question}"`);
138200
+ this.question = question;
138201
+ this.value = value;
138202
+ this.name = "UnknownOptionError";
138203
+ }
138204
+ question;
138205
+ value;
138206
+ };
138207
+ function answersToKeystrokes(questions, answers) {
138208
+ let out = "";
138209
+ for (const q2 of questions) {
138210
+ const raw2 = answers[q2.question];
138211
+ if (raw2 === void 0) {
138212
+ throw new Error(`Missing answer for question "${q2.question}"`);
138213
+ }
138214
+ const label = Array.isArray(raw2) ? raw2[0] : raw2;
138215
+ const target = q2.options.findIndex((o) => o.label === label);
138216
+ if (target < 0) throw new UnknownOptionError(q2.question, label);
138217
+ out += DOWN.repeat(target) + ENTER2;
138218
+ }
138219
+ return out;
138220
+ }
138221
+
138222
+ // src/services/questions/resolveAnswer.ts
138223
+ function resolveAnswer(pending, body) {
138224
+ if (!pending) return { ok: false, reason: "no_pending_question" };
138225
+ if (typeof body.toolUseId !== "string" || body.toolUseId !== pending.toolUseId) {
138226
+ return { ok: false, reason: "tool_use_mismatch" };
138227
+ }
138228
+ const answers = body.answers ?? {};
138229
+ try {
138230
+ return { ok: true, keys: answersToKeystrokes(pending.questions, answers) };
138231
+ } catch (e) {
138232
+ if (e instanceof UnknownOptionError) return { ok: false, reason: "unknown_option" };
138233
+ throw e;
138234
+ }
138235
+ }
138236
+
137833
138237
  // src/agent/dedupe.ts
137834
138238
  function createProgressDedupeLRU(capacity) {
137835
138239
  if (!Number.isFinite(capacity) || capacity < 1) {
@@ -138286,6 +138690,16 @@ var StreamerServer = class {
138286
138690
  fileWatcher;
138287
138691
  sessionFileMap = /* @__PURE__ */ new Map();
138288
138692
  // sessionId → JSONL filePath
138693
+ pendingQuestions = /* @__PURE__ */ new Map();
138694
+ // Content key of the AskUserQuestion currently broadcast for a session (from
138695
+ // either the rendered screen or JSONL), used to de-dupe the two paths: when
138696
+ // the screen detection fires first, the later JSONL flush of the same question
138697
+ // is suppressed. Cleared alongside pendingQuestions.
138698
+ pendingQuestionKey = /* @__PURE__ */ new Map();
138699
+ // Per-session permission gate currently open (scraped via OSC 777). Parallel
138700
+ // to pendingQuestions; mobile answers it by sending the option index via
138701
+ // /input { keys }. Cleared when the gate closes.
138702
+ pendingPermission = /* @__PURE__ */ new Map();
138289
138703
  scanner = null;
138290
138704
  scannerReady = null;
138291
138705
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
@@ -138381,6 +138795,28 @@ var StreamerServer = class {
138381
138795
  this.cache?.updateFromLines(filePath, lines);
138382
138796
  for (const [sessionId, watchedPath] of this.sessionFileMap) {
138383
138797
  if (watchedPath === filePath) {
138798
+ const priorToolUseId = this.pendingQuestions.get(sessionId)?.toolUseId;
138799
+ const { messages, pending } = questionsFromLines(sessionId, lines);
138800
+ for (const p2 of pending) {
138801
+ this.pendingQuestions.set(sessionId, p2);
138802
+ const t2 = setTimeout(() => {
138803
+ if (this.pendingQuestions.get(sessionId)?.toolUseId === p2.toolUseId) {
138804
+ this.cancelPendingQuestion(sessionId);
138805
+ }
138806
+ }, 6e4);
138807
+ t2.unref();
138808
+ }
138809
+ for (const m2 of messages) {
138810
+ const key = questionContentKey(m2.questions);
138811
+ const broadcast = shouldBroadcastQuestion({
138812
+ newContentKey: key,
138813
+ lastContentKey: this.pendingQuestionKey.get(sessionId),
138814
+ newToolUseId: m2.toolUseId,
138815
+ priorToolUseId
138816
+ });
138817
+ this.pendingQuestionKey.set(sessionId, key);
138818
+ if (broadcast) this.wsHub.broadcast(m2);
138819
+ }
138384
138820
  this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
138385
138821
  for (const line of lines) {
138386
138822
  this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
@@ -138412,6 +138848,19 @@ var StreamerServer = class {
138412
138848
  onOutput: (sessionId, data) => {
138413
138849
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
138414
138850
  },
138851
+ onPermissionChange: (sessionId, gate) => {
138852
+ this.handlePermissionChange(sessionId, gate);
138853
+ },
138854
+ onLiveQuestion: (sessionId, questions) => {
138855
+ this.handleLiveQuestion(sessionId, questions);
138856
+ },
138857
+ onLiveQuestionGone: (sessionId) => {
138858
+ this.pendingQuestionKey.delete(sessionId);
138859
+ const pq = this.pendingQuestions.get(sessionId);
138860
+ if (pq?.toolUseId.startsWith("screen:")) {
138861
+ this.cancelPendingQuestion(sessionId);
138862
+ }
138863
+ },
138415
138864
  onReady: (session) => {
138416
138865
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
138417
138866
  if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
@@ -138449,7 +138898,9 @@ var StreamerServer = class {
138449
138898
  if (filePath) {
138450
138899
  this.fileWatcher.unwatch(filePath);
138451
138900
  this.sessionFileMap.delete(session.id);
138901
+ this.cancelPendingQuestion(session.id);
138452
138902
  }
138903
+ this.pendingPermission.delete(session.id);
138453
138904
  }
138454
138905
  const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
138455
138906
  if (resp) {
@@ -138500,6 +138951,7 @@ var StreamerServer = class {
138500
138951
  handleGetSession: (id, res) => this.handleGetSession(id, res),
138501
138952
  handleGetOutput: (id, res) => this.handleGetOutput(id, res),
138502
138953
  handleSendInput: (id, req, res) => this.handleSendInput(id, req, res),
138954
+ handleSendAnswer: (id, req, res) => this.handleSendAnswer(id, req, res),
138503
138955
  handleCancel: (id, res) => this.handleCancel(id, res),
138504
138956
  handleStopSession: (id, res) => this.handleStopSession(id, res),
138505
138957
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
@@ -138635,16 +139087,28 @@ var StreamerServer = class {
138635
139087
  if (existing) clearTimeout(existing);
138636
139088
  const timer = setTimeout(() => {
138637
139089
  this.ptyGraceTimers.delete(sessionId);
138638
- this.sessionSubscribers.delete(sessionId);
138639
139090
  if (this.ptyManager.hasSession(sessionId)) {
139091
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
139092
+ if (resp?.status === "running") {
139093
+ this.log.info(
139094
+ `[grace] session ${sessionId} still running, deferring hold`,
139095
+ { sessionId, event: "pty.grace_defer" },
139096
+ "pino"
139097
+ );
139098
+ this.startGraceTimer(sessionId, delayMs);
139099
+ return;
139100
+ }
139101
+ this.sessionSubscribers.delete(sessionId);
138640
139102
  this.log.info(
138641
139103
  `[grace] killing idle PTY for ${sessionId}`,
138642
139104
  { sessionId, event: "pty.grace_kill" },
138643
139105
  "pino"
138644
139106
  );
138645
139107
  this.ptyManager.putOnHold(sessionId);
138646
- const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
138647
- if (resp) this.wsHub.broadcast({ type: "session_update", session: resp });
139108
+ const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
139109
+ if (held) this.wsHub.broadcast({ type: "session_update", session: held });
139110
+ } else {
139111
+ this.sessionSubscribers.delete(sessionId);
138648
139112
  }
138649
139113
  }, delayMs);
138650
139114
  this.ptyGraceTimers.set(sessionId, timer);
@@ -139623,6 +140087,65 @@ var StreamerServer = class {
139623
140087
  json2(res, 400, { error: message });
139624
140088
  }
139625
140089
  }
140090
+ cancelPendingQuestion(sessionId) {
140091
+ const pq = this.pendingQuestions.get(sessionId);
140092
+ if (!pq) return;
140093
+ this.pendingQuestions.delete(sessionId);
140094
+ this.pendingQuestionKey.delete(sessionId);
140095
+ this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId: pq.toolUseId });
140096
+ }
140097
+ // Live AskUserQuestion detected from the rendered screen (ahead of JSONL).
140098
+ // Broadcasts the `question` event immediately and records the content key so
140099
+ // the later JSONL flush of the same question is de-duped. We synthesize a
140100
+ // screen-scoped toolUseId; the JSONL path overwrites pendingQuestions with the
140101
+ // real toolUseId when it lands, so answering works once JSONL catches up.
140102
+ handleLiveQuestion(sessionId, questions) {
140103
+ const key = questionContentKey(questions);
140104
+ if (this.pendingQuestionKey.get(sessionId) === key) return;
140105
+ const toolUseId = `screen:${sessionId}:${key.length}`;
140106
+ this.pendingQuestions.set(sessionId, { toolUseId, questions });
140107
+ this.pendingQuestionKey.set(sessionId, key);
140108
+ this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
140109
+ }
140110
+ // Permission gate opened/closed (OSC 777 + scraped options). Broadcasts the
140111
+ // additive `permission` / `permission_cancelled` events. Mobile answers by
140112
+ // sending the chosen option index via /input { keys } (e.g. "2\r").
140113
+ handlePermissionChange(sessionId, gate) {
140114
+ if (gate === null) {
140115
+ if (!this.pendingPermission.has(sessionId)) return;
140116
+ this.pendingPermission.delete(sessionId);
140117
+ this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
140118
+ return;
140119
+ }
140120
+ this.pendingPermission.set(sessionId, gate);
140121
+ this.wsHub.broadcast({
140122
+ type: "permission",
140123
+ sessionId,
140124
+ ...gate.prompt ? { prompt: gate.prompt } : {},
140125
+ options: gate.options,
140126
+ ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
140127
+ });
140128
+ }
140129
+ async handleSendAnswer(sessionId, req, res) {
140130
+ const body = await readBody(req);
140131
+ const pending = this.pendingQuestions.get(sessionId);
140132
+ const resolution = resolveAnswer(pending, body);
140133
+ if (!resolution.ok) {
140134
+ json2(res, 400, { ok: false, reason: resolution.reason });
140135
+ return;
140136
+ }
140137
+ const toolUseId = pending?.toolUseId ?? "";
140138
+ try {
140139
+ this.ptyManager.sendKeys(sessionId, resolution.keys);
140140
+ } catch (err) {
140141
+ const message = err instanceof Error ? err.message : "Failed to send answer";
140142
+ json2(res, 400, { ok: false, reason: message });
140143
+ return;
140144
+ }
140145
+ this.pendingQuestions.delete(sessionId);
140146
+ this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId });
140147
+ json2(res, 200, { ok: true });
140148
+ }
139626
140149
  async handleUploadFile(sessionId, req, res) {
139627
140150
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
139628
140151
  if (!session) {