@threadbase-sh/streamer 1.67.4 → 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) {
@@ -6388,6 +6391,10 @@ var createSessionRoutes = (deps) => {
6388
6391
  await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6389
6392
  return alreadyHandled6();
6390
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
+ });
6391
6398
  app.post("/:id/files", async (c) => {
6392
6399
  await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
6393
6400
  return alreadyHandled6();
@@ -10546,9 +10553,98 @@ var SessionHandlers = class {
10546
10553
  ...gate.prompt ? { prompt: gate.prompt } : {},
10547
10554
  ...gate.detail ? { detail: gate.detail } : {},
10548
10555
  options: gate.options,
10549
- ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
10556
+ ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {},
10557
+ contentKey: permissionGateKey(gate)
10550
10558
  });
10551
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
+ }
10552
10648
  async handleSendAnswer(sessionId, req, res) {
10553
10649
  const body = await readBody2(req);
10554
10650
  const pending = this.pendingQuestions.get(sessionId);
@@ -12984,6 +13080,7 @@ function createApiDeps(deps) {
12984
13080
  handleGetOutput: (id, res) => deps.sessionHandlers.handleGetOutput(id, res),
12985
13081
  handleSendInput: (id, req, res) => deps.sessionHandlers.handleSendInput(id, req, res),
12986
13082
  handleSendAnswer: (id, req, res) => deps.sessionHandlers.handleSendAnswer(id, req, res),
13083
+ handlePermissionAnswer: (id, req, res) => deps.sessionHandlers.handlePermissionAnswer(id, req, res),
12987
13084
  handleCancel: (id, res) => deps.sessionHandlers.handleCancel(id, res),
12988
13085
  handleStopSession: (id, res) => deps.sessionHandlers.handleStopSession(id, res),
12989
13086
  handleSetSessionName: (id, req, res) => deps.sessionHandlers.handleSetSessionName(id, req, res),
@@ -13067,7 +13164,8 @@ function createApiDeps(deps) {
13067
13164
  ...pendingGate.prompt ? { prompt: pendingGate.prompt } : {},
13068
13165
  ...pendingGate.detail ? { detail: pendingGate.detail } : {},
13069
13166
  options: pendingGate.options,
13070
- ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {}
13167
+ ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {},
13168
+ contentKey: permissionGateKey(pendingGate)
13071
13169
  })
13072
13170
  );
13073
13171
  }