@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.cjs CHANGED
@@ -2622,6 +2622,9 @@ var import_path6 = require("path");
2622
2622
  function permissionContentKey(gate) {
2623
2623
  return `${gate.prompt ?? ""}::${gate.detail ?? ""}::${gate.options.map((o) => `${o.index}.${o.label}`).join(",")}::${gate.cursor ?? ""}`;
2624
2624
  }
2625
+ function permissionGateKey(gate) {
2626
+ return permissionContentKey({ ...gate, cursor: void 0 });
2627
+ }
2625
2628
  var OSC_777_PERMISSION_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*needs your permission/;
2626
2629
  var OSC_777_WAITING_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*waiting for your input/;
2627
2630
  function hasPermissionOsc(rawData) {
@@ -2840,6 +2843,18 @@ function detectShellPrompt(lines) {
2840
2843
  return null;
2841
2844
  }
2842
2845
 
2846
+ // src/services/questions/permissionAnswerKeys.ts
2847
+ var ENTER2 = "\r";
2848
+ function permissionAnswerKeys(index) {
2849
+ if (!Number.isInteger(index) || index < 0) {
2850
+ throw new Error(`Invalid permission option index: ${index}`);
2851
+ }
2852
+ return `${index}${ENTER2}`;
2853
+ }
2854
+ function isPermissionAnswer(gate, keys) {
2855
+ return gate.options.some((o) => keys === (o.answerKeys ?? permissionAnswerKeys(o.index)));
2856
+ }
2857
+
2843
2858
  // src/utils/deriveSessionName.ts
2844
2859
  function deriveSessionName(firstMessageText) {
2845
2860
  const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
@@ -2882,10 +2897,12 @@ var PTYManager = class {
2882
2897
  onLiveQuestion;
2883
2898
  onLiveQuestionGone;
2884
2899
  onUserMessage;
2885
- // Per-session permission-gate state. True between an OSC 777 (gate open) and
2886
- // the next prompt-ready without a fresh 777 (gate closed). Prevents
2887
- // re-broadcasting open/close on every chunk.
2888
- permissionOpen = /* @__PURE__ */ new Set();
2900
+ // Per-session permission-gate state: the gate currently open on a session's
2901
+ // screen. Set between an OSC 777 / paint-time claim (gate open) and the next
2902
+ // prompt-ready without a fresh 777 (gate closed). Prevents re-broadcasting
2903
+ // open/close on every chunk. Holds the gate itself rather than just the id so
2904
+ // sendKeys can tell whether the bytes it is writing are that gate's answer.
2905
+ permissionOpen = /* @__PURE__ */ new Map();
2889
2906
  // Last chunk's raw tail per session — prepended to the next chunk before
2890
2907
  // the OSC regex test so a split escape still matches. Consumed on match.
2891
2908
  oscTail = /* @__PURE__ */ new Map();
@@ -3120,6 +3137,11 @@ var PTYManager = class {
3120
3137
  );
3121
3138
  session.process.write(keys);
3122
3139
  session.lastActivityAt = /* @__PURE__ */ new Date();
3140
+ const openGate = this.permissionOpen.get(sessionId);
3141
+ if (openGate && isPermissionAnswer(openGate, keys)) {
3142
+ this.permissionOpen.delete(sessionId);
3143
+ this.onPermissionChange?.(sessionId, null);
3144
+ }
3123
3145
  }
3124
3146
  sendInput(sessionId, input) {
3125
3147
  const session = this.sessions.get(sessionId);
@@ -3466,7 +3488,7 @@ var PTYManager = class {
3466
3488
  }
3467
3489
  if (oscPermission && !askFooterOnScreen) {
3468
3490
  const gate = scrapePermissionGate(lines);
3469
- this.permissionOpen.add(sessionId);
3491
+ this.permissionOpen.set(sessionId, gate ?? { options: [] });
3470
3492
  this.onPermissionChange?.(sessionId, gate ?? { options: [] });
3471
3493
  } else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
3472
3494
  const gate = detectGateScreen(lines);
@@ -3480,6 +3502,7 @@ var PTYManager = class {
3480
3502
  this.closedGateKey.delete(sessionId);
3481
3503
  }
3482
3504
  } else if (gate) {
3505
+ this.permissionOpen.set(sessionId, gate);
3483
3506
  this.onPermissionChange?.(sessionId, gate);
3484
3507
  }
3485
3508
  } else if (!askFooterOnScreen && !oscWaitingForInput) {
@@ -3487,7 +3510,7 @@ var PTYManager = class {
3487
3510
  if (gate) {
3488
3511
  const key = permissionContentKey({ ...gate, cursor: void 0 });
3489
3512
  if (this.closedGateKey.get(sessionId) !== key) {
3490
- this.permissionOpen.add(sessionId);
3513
+ this.permissionOpen.set(sessionId, gate);
3491
3514
  this.onPermissionChange?.(sessionId, gate);
3492
3515
  }
3493
3516
  } else {
@@ -6368,6 +6391,10 @@ var createSessionRoutes = (deps) => {
6368
6391
  await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6369
6392
  return alreadyHandled6();
6370
6393
  });
6394
+ app.post("/:id/permission/answer", async (c) => {
6395
+ await deps.handlePermissionAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6396
+ return alreadyHandled6();
6397
+ });
6371
6398
  app.post("/:id/files", async (c) => {
6372
6399
  await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
6373
6400
  return alreadyHandled6();
@@ -9593,7 +9620,7 @@ function parseStatusLine(lines) {
9593
9620
 
9594
9621
  // src/services/questions/answersToKeystrokes.ts
9595
9622
  var DOWN = "\x1B[B";
9596
- var ENTER2 = "\r";
9623
+ var ENTER3 = "\r";
9597
9624
  var UnknownOptionError = class extends Error {
9598
9625
  constructor(question, value) {
9599
9626
  super(`No option labelled "${value}" for question "${question}"`);
@@ -9614,7 +9641,7 @@ function answersToKeystrokes(questions, answers) {
9614
9641
  const label = Array.isArray(raw) ? raw[0] : raw;
9615
9642
  const target = q.options.findIndex((o) => o.label === label);
9616
9643
  if (target < 0) throw new UnknownOptionError(q.question, label);
9617
- out += DOWN.repeat(target) + ENTER2;
9644
+ out += DOWN.repeat(target) + ENTER3;
9618
9645
  }
9619
9646
  return out;
9620
9647
  }
@@ -10526,9 +10553,98 @@ var SessionHandlers = class {
10526
10553
  ...gate.prompt ? { prompt: gate.prompt } : {},
10527
10554
  ...gate.detail ? { detail: gate.detail } : {},
10528
10555
  options: gate.options,
10529
- ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
10556
+ ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {},
10557
+ contentKey: permissionGateKey(gate)
10530
10558
  });
10531
10559
  }
10560
+ /**
10561
+ * Answer a permission gate — the validated counterpart of POST /:id/input.
10562
+ *
10563
+ * `/input` is a raw-bytes conduit (arrow-key nav uses it too) and stays that
10564
+ * way; this route is the semantic one, mirroring the /answer split. Two
10565
+ * things make it more than validation theatre:
10566
+ *
10567
+ * - The client sends `{ contentKey, optionIndex }` and NO keystrokes. The
10568
+ * keys are derived here from our own copy of the gate, so the client's
10569
+ * key-derivation can never drift from the server's.
10570
+ * - `contentKey` binds the answer to a specific gate. `isPermissionAnswer`
10571
+ * matches structurally, and approval gates repeat constantly ("2. Yes /
10572
+ * 3. No" for every tool call), so without this a delayed answer to gate A
10573
+ * could be written as gate B's answer — a user approving a bash command
10574
+ * they never saw, with a 200 and a normal permission_cancelled. Treat the
10575
+ * check as a security boundary.
10576
+ *
10577
+ * Every refusal happens BEFORE sendKeys. On success we deliberately broadcast
10578
+ * nothing: the PTY-side close (isPermissionAnswer in pty-manager) recognises
10579
+ * the bytes we just wrote and fires permission_cancelled itself.
10580
+ */
10581
+ async handlePermissionAnswer(sessionId, req, res) {
10582
+ const body = await readBody2(req);
10583
+ const contentKey = body?.contentKey;
10584
+ const optionIndex = body?.optionIndex;
10585
+ if (typeof contentKey !== "string" || !Number.isInteger(optionIndex) || optionIndex < 0) {
10586
+ json(res, 400, { ok: false, reason: "Expected { contentKey: string, optionIndex: number }" });
10587
+ return;
10588
+ }
10589
+ const gateClosed = () => {
10590
+ this.pendingPermission.delete(sessionId);
10591
+ this.pendingPermissionKey.delete(sessionId);
10592
+ this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
10593
+ json(res, 409, { ok: false, reason: "gate_closed" });
10594
+ };
10595
+ const gate = this.pendingPermission.get(sessionId);
10596
+ if (!gate) {
10597
+ gateClosed();
10598
+ return;
10599
+ }
10600
+ if (permissionGateKey(gate) !== contentKey) {
10601
+ json(res, 409, { ok: false, reason: "gate_mismatch" });
10602
+ return;
10603
+ }
10604
+ const option = gate.options[optionIndex];
10605
+ if (!option) {
10606
+ json(res, 409, { ok: false, reason: "unknown_option" });
10607
+ return;
10608
+ }
10609
+ if (!await this.permissionGateStillOpen(sessionId, contentKey)) {
10610
+ gateClosed();
10611
+ return;
10612
+ }
10613
+ try {
10614
+ this.ptyManager.sendKeys(sessionId, option.answerKeys ?? permissionAnswerKeys(option.index));
10615
+ } catch (err) {
10616
+ const message = err instanceof Error ? err.message : "Failed to send answer";
10617
+ json(res, 400, { ok: false, reason: message });
10618
+ return;
10619
+ }
10620
+ json(res, 200, { ok: true });
10621
+ }
10622
+ /**
10623
+ * Is THIS gate still the one on screen?
10624
+ *
10625
+ * Deliberately stricter than questionMenuStillOpen's "is a menu up": the
10626
+ * staleness window this exists to cover (the ~300ms scrape throttle plus the
10627
+ * wait for the next PTY chunk) is exactly where pendingPermission still says
10628
+ * gate A while the screen has moved to gate B — and since approval gates
10629
+ * repeat their shape, "some gate is open" would wave that through.
10630
+ *
10631
+ * Reads 60 lines because that is the window the detector that produced the
10632
+ * pending gate uses (pty-manager's scrape); `detail` walks up to 6 lines
10633
+ * above the prompt, so a shorter window can truncate it and manufacture a
10634
+ * mismatch on a healthy gate.
10635
+ *
10636
+ * Best-effort, like questionMenuStillOpen: a session we hold no PTY for, or
10637
+ * one that raced away mid-read, is not ours to veto.
10638
+ */
10639
+ async permissionGateStillOpen(sessionId, contentKey) {
10640
+ if (!this.ptyManager.hasSession(sessionId)) return true;
10641
+ try {
10642
+ const onScreen = scrapePermissionGate(await this.ptyManager.getOutputLines(sessionId, 60));
10643
+ return onScreen !== null && permissionGateKey(onScreen) === contentKey;
10644
+ } catch {
10645
+ return true;
10646
+ }
10647
+ }
10532
10648
  async handleSendAnswer(sessionId, req, res) {
10533
10649
  const body = await readBody2(req);
10534
10650
  const pending = this.pendingQuestions.get(sessionId);
@@ -12964,6 +13080,7 @@ function createApiDeps(deps) {
12964
13080
  handleGetOutput: (id, res) => deps.sessionHandlers.handleGetOutput(id, res),
12965
13081
  handleSendInput: (id, req, res) => deps.sessionHandlers.handleSendInput(id, req, res),
12966
13082
  handleSendAnswer: (id, req, res) => deps.sessionHandlers.handleSendAnswer(id, req, res),
13083
+ handlePermissionAnswer: (id, req, res) => deps.sessionHandlers.handlePermissionAnswer(id, req, res),
12967
13084
  handleCancel: (id, res) => deps.sessionHandlers.handleCancel(id, res),
12968
13085
  handleStopSession: (id, res) => deps.sessionHandlers.handleStopSession(id, res),
12969
13086
  handleSetSessionName: (id, req, res) => deps.sessionHandlers.handleSetSessionName(id, req, res),
@@ -13047,7 +13164,8 @@ function createApiDeps(deps) {
13047
13164
  ...pendingGate.prompt ? { prompt: pendingGate.prompt } : {},
13048
13165
  ...pendingGate.detail ? { detail: pendingGate.detail } : {},
13049
13166
  options: pendingGate.options,
13050
- ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {}
13167
+ ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {},
13168
+ contentKey: permissionGateKey(pendingGate)
13051
13169
  })
13052
13170
  );
13053
13171
  }