@threadbase-sh/streamer 1.67.3 → 1.68.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
@@ -2569,6 +2569,9 @@ import { basename as basename2 } from "path";
2569
2569
  function permissionContentKey(gate) {
2570
2570
  return `${gate.prompt ?? ""}::${gate.detail ?? ""}::${gate.options.map((o) => `${o.index}.${o.label}`).join(",")}::${gate.cursor ?? ""}`;
2571
2571
  }
2572
+ function permissionGateKey(gate) {
2573
+ return permissionContentKey({ ...gate, cursor: void 0 });
2574
+ }
2572
2575
  var OSC_777_PERMISSION_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*needs your permission/;
2573
2576
  var OSC_777_WAITING_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*waiting for your input/;
2574
2577
  function hasPermissionOsc(rawData) {
@@ -2787,6 +2790,18 @@ function detectShellPrompt(lines) {
2787
2790
  return null;
2788
2791
  }
2789
2792
 
2793
+ // src/services/questions/permissionAnswerKeys.ts
2794
+ var ENTER2 = "\r";
2795
+ function permissionAnswerKeys(index) {
2796
+ if (!Number.isInteger(index) || index < 0) {
2797
+ throw new Error(`Invalid permission option index: ${index}`);
2798
+ }
2799
+ return `${index}${ENTER2}`;
2800
+ }
2801
+ function isPermissionAnswer(gate, keys) {
2802
+ return gate.options.some((o) => keys === (o.answerKeys ?? permissionAnswerKeys(o.index)));
2803
+ }
2804
+
2790
2805
  // src/utils/deriveSessionName.ts
2791
2806
  function deriveSessionName(firstMessageText) {
2792
2807
  const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
@@ -2829,10 +2844,12 @@ var PTYManager = class {
2829
2844
  onLiveQuestion;
2830
2845
  onLiveQuestionGone;
2831
2846
  onUserMessage;
2832
- // Per-session permission-gate state. True between an OSC 777 (gate open) and
2833
- // the next prompt-ready without a fresh 777 (gate closed). Prevents
2834
- // re-broadcasting open/close on every chunk.
2835
- permissionOpen = /* @__PURE__ */ new Set();
2847
+ // Per-session permission-gate state: the gate currently open on a session's
2848
+ // screen. Set between an OSC 777 / paint-time claim (gate open) and the next
2849
+ // prompt-ready without a fresh 777 (gate closed). Prevents re-broadcasting
2850
+ // open/close on every chunk. Holds the gate itself rather than just the id so
2851
+ // sendKeys can tell whether the bytes it is writing are that gate's answer.
2852
+ permissionOpen = /* @__PURE__ */ new Map();
2836
2853
  // Last chunk's raw tail per session — prepended to the next chunk before
2837
2854
  // the OSC regex test so a split escape still matches. Consumed on match.
2838
2855
  oscTail = /* @__PURE__ */ new Map();
@@ -3067,6 +3084,11 @@ var PTYManager = class {
3067
3084
  );
3068
3085
  session.process.write(keys);
3069
3086
  session.lastActivityAt = /* @__PURE__ */ new Date();
3087
+ const openGate = this.permissionOpen.get(sessionId);
3088
+ if (openGate && isPermissionAnswer(openGate, keys)) {
3089
+ this.permissionOpen.delete(sessionId);
3090
+ this.onPermissionChange?.(sessionId, null);
3091
+ }
3070
3092
  }
3071
3093
  sendInput(sessionId, input) {
3072
3094
  const session = this.sessions.get(sessionId);
@@ -3413,7 +3435,7 @@ var PTYManager = class {
3413
3435
  }
3414
3436
  if (oscPermission && !askFooterOnScreen) {
3415
3437
  const gate = scrapePermissionGate(lines);
3416
- this.permissionOpen.add(sessionId);
3438
+ this.permissionOpen.set(sessionId, gate ?? { options: [] });
3417
3439
  this.onPermissionChange?.(sessionId, gate ?? { options: [] });
3418
3440
  } else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
3419
3441
  const gate = detectGateScreen(lines);
@@ -3427,6 +3449,7 @@ var PTYManager = class {
3427
3449
  this.closedGateKey.delete(sessionId);
3428
3450
  }
3429
3451
  } else if (gate) {
3452
+ this.permissionOpen.set(sessionId, gate);
3430
3453
  this.onPermissionChange?.(sessionId, gate);
3431
3454
  }
3432
3455
  } else if (!askFooterOnScreen && !oscWaitingForInput) {
@@ -3434,7 +3457,7 @@ var PTYManager = class {
3434
3457
  if (gate) {
3435
3458
  const key = permissionContentKey({ ...gate, cursor: void 0 });
3436
3459
  if (this.closedGateKey.get(sessionId) !== key) {
3437
- this.permissionOpen.add(sessionId);
3460
+ this.permissionOpen.set(sessionId, gate);
3438
3461
  this.onPermissionChange?.(sessionId, gate);
3439
3462
  }
3440
3463
  } else {
@@ -6320,6 +6343,10 @@ var createSessionRoutes = (deps) => {
6320
6343
  await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6321
6344
  return alreadyHandled6();
6322
6345
  });
6346
+ app.post("/:id/permission/answer", async (c) => {
6347
+ await deps.handlePermissionAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6348
+ return alreadyHandled6();
6349
+ });
6323
6350
  app.post("/:id/files", async (c) => {
6324
6351
  await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
6325
6352
  return alreadyHandled6();
@@ -9553,7 +9580,7 @@ function parseStatusLine(lines) {
9553
9580
 
9554
9581
  // src/services/questions/answersToKeystrokes.ts
9555
9582
  var DOWN = "\x1B[B";
9556
- var ENTER2 = "\r";
9583
+ var ENTER3 = "\r";
9557
9584
  var UnknownOptionError = class extends Error {
9558
9585
  constructor(question, value) {
9559
9586
  super(`No option labelled "${value}" for question "${question}"`);
@@ -9574,7 +9601,7 @@ function answersToKeystrokes(questions, answers) {
9574
9601
  const label = Array.isArray(raw) ? raw[0] : raw;
9575
9602
  const target = q.options.findIndex((o) => o.label === label);
9576
9603
  if (target < 0) throw new UnknownOptionError(q.question, label);
9577
- out += DOWN.repeat(target) + ENTER2;
9604
+ out += DOWN.repeat(target) + ENTER3;
9578
9605
  }
9579
9606
  return out;
9580
9607
  }
@@ -10486,9 +10513,98 @@ var SessionHandlers = class {
10486
10513
  ...gate.prompt ? { prompt: gate.prompt } : {},
10487
10514
  ...gate.detail ? { detail: gate.detail } : {},
10488
10515
  options: gate.options,
10489
- ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
10516
+ ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {},
10517
+ contentKey: permissionGateKey(gate)
10490
10518
  });
10491
10519
  }
10520
+ /**
10521
+ * Answer a permission gate — the validated counterpart of POST /:id/input.
10522
+ *
10523
+ * `/input` is a raw-bytes conduit (arrow-key nav uses it too) and stays that
10524
+ * way; this route is the semantic one, mirroring the /answer split. Two
10525
+ * things make it more than validation theatre:
10526
+ *
10527
+ * - The client sends `{ contentKey, optionIndex }` and NO keystrokes. The
10528
+ * keys are derived here from our own copy of the gate, so the client's
10529
+ * key-derivation can never drift from the server's.
10530
+ * - `contentKey` binds the answer to a specific gate. `isPermissionAnswer`
10531
+ * matches structurally, and approval gates repeat constantly ("2. Yes /
10532
+ * 3. No" for every tool call), so without this a delayed answer to gate A
10533
+ * could be written as gate B's answer — a user approving a bash command
10534
+ * they never saw, with a 200 and a normal permission_cancelled. Treat the
10535
+ * check as a security boundary.
10536
+ *
10537
+ * Every refusal happens BEFORE sendKeys. On success we deliberately broadcast
10538
+ * nothing: the PTY-side close (isPermissionAnswer in pty-manager) recognises
10539
+ * the bytes we just wrote and fires permission_cancelled itself.
10540
+ */
10541
+ async handlePermissionAnswer(sessionId, req, res) {
10542
+ const body = await readBody2(req);
10543
+ const contentKey = body?.contentKey;
10544
+ const optionIndex = body?.optionIndex;
10545
+ if (typeof contentKey !== "string" || !Number.isInteger(optionIndex) || optionIndex < 0) {
10546
+ json(res, 400, { ok: false, reason: "Expected { contentKey: string, optionIndex: number }" });
10547
+ return;
10548
+ }
10549
+ const gateClosed = () => {
10550
+ this.pendingPermission.delete(sessionId);
10551
+ this.pendingPermissionKey.delete(sessionId);
10552
+ this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
10553
+ json(res, 409, { ok: false, reason: "gate_closed" });
10554
+ };
10555
+ const gate = this.pendingPermission.get(sessionId);
10556
+ if (!gate) {
10557
+ gateClosed();
10558
+ return;
10559
+ }
10560
+ if (permissionGateKey(gate) !== contentKey) {
10561
+ json(res, 409, { ok: false, reason: "gate_mismatch" });
10562
+ return;
10563
+ }
10564
+ const option = gate.options[optionIndex];
10565
+ if (!option) {
10566
+ json(res, 409, { ok: false, reason: "unknown_option" });
10567
+ return;
10568
+ }
10569
+ if (!await this.permissionGateStillOpen(sessionId, contentKey)) {
10570
+ gateClosed();
10571
+ return;
10572
+ }
10573
+ try {
10574
+ this.ptyManager.sendKeys(sessionId, option.answerKeys ?? permissionAnswerKeys(option.index));
10575
+ } catch (err) {
10576
+ const message = err instanceof Error ? err.message : "Failed to send answer";
10577
+ json(res, 400, { ok: false, reason: message });
10578
+ return;
10579
+ }
10580
+ json(res, 200, { ok: true });
10581
+ }
10582
+ /**
10583
+ * Is THIS gate still the one on screen?
10584
+ *
10585
+ * Deliberately stricter than questionMenuStillOpen's "is a menu up": the
10586
+ * staleness window this exists to cover (the ~300ms scrape throttle plus the
10587
+ * wait for the next PTY chunk) is exactly where pendingPermission still says
10588
+ * gate A while the screen has moved to gate B — and since approval gates
10589
+ * repeat their shape, "some gate is open" would wave that through.
10590
+ *
10591
+ * Reads 60 lines because that is the window the detector that produced the
10592
+ * pending gate uses (pty-manager's scrape); `detail` walks up to 6 lines
10593
+ * above the prompt, so a shorter window can truncate it and manufacture a
10594
+ * mismatch on a healthy gate.
10595
+ *
10596
+ * Best-effort, like questionMenuStillOpen: a session we hold no PTY for, or
10597
+ * one that raced away mid-read, is not ours to veto.
10598
+ */
10599
+ async permissionGateStillOpen(sessionId, contentKey) {
10600
+ if (!this.ptyManager.hasSession(sessionId)) return true;
10601
+ try {
10602
+ const onScreen = scrapePermissionGate(await this.ptyManager.getOutputLines(sessionId, 60));
10603
+ return onScreen !== null && permissionGateKey(onScreen) === contentKey;
10604
+ } catch {
10605
+ return true;
10606
+ }
10607
+ }
10492
10608
  async handleSendAnswer(sessionId, req, res) {
10493
10609
  const body = await readBody2(req);
10494
10610
  const pending = this.pendingQuestions.get(sessionId);
@@ -12935,6 +13051,7 @@ function createApiDeps(deps) {
12935
13051
  handleGetOutput: (id, res) => deps.sessionHandlers.handleGetOutput(id, res),
12936
13052
  handleSendInput: (id, req, res) => deps.sessionHandlers.handleSendInput(id, req, res),
12937
13053
  handleSendAnswer: (id, req, res) => deps.sessionHandlers.handleSendAnswer(id, req, res),
13054
+ handlePermissionAnswer: (id, req, res) => deps.sessionHandlers.handlePermissionAnswer(id, req, res),
12938
13055
  handleCancel: (id, res) => deps.sessionHandlers.handleCancel(id, res),
12939
13056
  handleStopSession: (id, res) => deps.sessionHandlers.handleStopSession(id, res),
12940
13057
  handleSetSessionName: (id, req, res) => deps.sessionHandlers.handleSetSessionName(id, req, res),
@@ -13018,7 +13135,8 @@ function createApiDeps(deps) {
13018
13135
  ...pendingGate.prompt ? { prompt: pendingGate.prompt } : {},
13019
13136
  ...pendingGate.detail ? { detail: pendingGate.detail } : {},
13020
13137
  options: pendingGate.options,
13021
- ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {}
13138
+ ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {},
13139
+ contentKey: permissionGateKey(pendingGate)
13022
13140
  })
13023
13141
  );
13024
13142
  }