@threadbase-sh/streamer 1.70.0 → 1.70.2

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
@@ -9741,7 +9741,7 @@ var PromptRegistry = class {
9741
9741
  this.maxRecordsPerSession = options.maxRecordsPerSession ?? PROMPT_MAX_RECORDS_PER_SESSION;
9742
9742
  }
9743
9743
  open(draft, adapter, promptId = this.createId()) {
9744
- this.prune(draft.sessionId);
9744
+ this.sweepExpired(draft.sessionId);
9745
9745
  const held = this.byId.get(promptId);
9746
9746
  if (held && (held.prompt.state === "open" || held.prompt.state === "updated")) {
9747
9747
  throw new Error(`Prompt id already exists: ${promptId}`);
@@ -9831,6 +9831,58 @@ var PromptRegistry = class {
9831
9831
  this.publish(entry);
9832
9832
  return copyPrompt(entry.prompt);
9833
9833
  }
9834
+ /**
9835
+ * The provider's prompt left the screen.
9836
+ *
9837
+ * Distinct from `transition` because the detector cannot tell a teardown it
9838
+ * observed on its own from one OUR OWN write caused. On a screen-scraped gate
9839
+ * the answer keys remove the box, and pty-manager's sendKeys fires the close
9840
+ * synchronously from inside the write — so the close always arrives before
9841
+ * the answer that caused it has settled, and reporting it as a cancel failed
9842
+ * every answer whose bytes had already landed (#720).
9843
+ *
9844
+ * While an answer is writing, the close is deferred and the answer decides:
9845
+ * `resolved` if the write landed, the deferred close if it did not. Success
9846
+ * is the adapter's own result, never the prompt's absence from the screen —
9847
+ * a prompt that does not vanish when answered (a multi-select form) simply
9848
+ * never reaches here, and is settled by the same adapter result (#721).
9849
+ *
9850
+ * ONLY this path defers. `replaced` from a different gate taking the screen,
9851
+ * `unavailable` from invalidateSession and `expired` all still go through
9852
+ * `transition` and still win, because none of them was caused by our write.
9853
+ */
9854
+ providerClosed(promptId, reason) {
9855
+ const entry = this.byId.get(promptId);
9856
+ if (!entry) return null;
9857
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") return null;
9858
+ if (entry.answering) {
9859
+ entry.deferredClose = reason;
9860
+ return null;
9861
+ }
9862
+ return this.transition(promptId, "cancelled", reason);
9863
+ }
9864
+ /**
9865
+ * Run a provider write that may close the prompt as a side effect, so the
9866
+ * close it causes is deferred rather than applied. For callers outside this
9867
+ * class that write without going through `answer()` — the legacy permission
9868
+ * route. `performAnswer` manages the same two fields directly, because it
9869
+ * needs the deferred value in its own control flow.
9870
+ *
9871
+ * The write's own failure needs no unwinding here: pty-manager fires the
9872
+ * close as the last statement of a successful sendKeys, so a throw means no
9873
+ * close was ever deferred and the record is still open for the caller.
9874
+ */
9875
+ whileAnswering(promptId, write) {
9876
+ const entry = this.byId.get(promptId);
9877
+ if (!entry) return write();
9878
+ entry.answering = true;
9879
+ try {
9880
+ return write();
9881
+ } finally {
9882
+ entry.answering = false;
9883
+ entry.deferredClose = void 0;
9884
+ }
9885
+ }
9834
9886
  invalidateSession(sessionId, reason = "session_ended") {
9835
9887
  const transitioned = [];
9836
9888
  for (const entry of this.bySession.get(sessionId)?.values() ?? []) {
@@ -9843,17 +9895,17 @@ var PromptRegistry = class {
9843
9895
  get(promptId) {
9844
9896
  const entry = this.byId.get(promptId);
9845
9897
  if (!entry) return null;
9846
- this.prune(entry.prompt.sessionId);
9898
+ this.sweepExpired(entry.prompt.sessionId);
9847
9899
  return this.byId.has(promptId) ? copyPrompt(entry.prompt) : null;
9848
9900
  }
9849
9901
  hasActionable(sessionId) {
9850
- this.prune(sessionId);
9902
+ this.sweepExpired(sessionId);
9851
9903
  return [...this.bySession.get(sessionId)?.values() ?? []].some(
9852
9904
  (entry) => entry.prompt.state === "open" || entry.prompt.state === "updated"
9853
9905
  );
9854
9906
  }
9855
9907
  snapshot(sessionId) {
9856
- this.prune(sessionId);
9908
+ this.sweepExpired(sessionId);
9857
9909
  return {
9858
9910
  type: "prompt_snapshot",
9859
9911
  schemaVersion: PROMPT_SCHEMA_VERSION,
@@ -9868,7 +9920,7 @@ var PromptRegistry = class {
9868
9920
  for (const entry of this.byId.values()) this.clearExpiration(entry);
9869
9921
  }
9870
9922
  answer(sessionId, answer) {
9871
- this.prune(sessionId);
9923
+ this.sweepExpired(sessionId);
9872
9924
  const entry = this.byId.get(answer.promptId);
9873
9925
  if (!entry || entry.prompt.sessionId !== sessionId) {
9874
9926
  return Promise.resolve({ ok: false, code: "prompt_not_found" });
@@ -9908,21 +9960,33 @@ var PromptRegistry = class {
9908
9960
  const responseError = this.validateResponses(prompt, answer);
9909
9961
  if (responseError) return { ok: false, code: responseError };
9910
9962
  if (!entry.adapter) return { ok: false, code: "prompt_unavailable" };
9963
+ entry.answering = true;
9911
9964
  let adapterResult;
9965
+ let threw = false;
9912
9966
  try {
9913
9967
  adapterResult = await entry.adapter({ prompt: copyPrompt(prompt), answer });
9914
9968
  } catch {
9915
- return { ok: false, code: "provider_error" };
9969
+ threw = true;
9970
+ adapterResult = { ok: false, code: "provider_error" };
9971
+ } finally {
9972
+ entry.answering = false;
9916
9973
  }
9974
+ const deferred = entry.deferredClose;
9975
+ entry.deferredClose = void 0;
9976
+ const settle = (outcome) => {
9977
+ if (deferred) this.providerClosed(prompt.promptId, deferred);
9978
+ return outcome;
9979
+ };
9980
+ if (threw) return settle({ ok: false, code: "provider_error" });
9917
9981
  if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") {
9918
- return { ok: false, code: terminalError(entry.prompt.state) };
9982
+ return settle({ ok: false, code: terminalError(entry.prompt.state) });
9919
9983
  }
9920
9984
  if (entry.prompt.revision !== answer.revision) {
9921
- return {
9985
+ return settle({
9922
9986
  ok: false,
9923
9987
  code: "prompt_revision_mismatch",
9924
9988
  currentRevision: entry.prompt.revision
9925
- };
9989
+ });
9926
9990
  }
9927
9991
  if (!adapterResult.ok) {
9928
9992
  if (adapterResult.terminal) {
@@ -9932,7 +9996,7 @@ var PromptRegistry = class {
9932
9996
  adapterResult.terminal.reason
9933
9997
  );
9934
9998
  }
9935
- return { ok: false, code: adapterResult.code };
9999
+ return settle({ ok: false, code: adapterResult.code });
9936
10000
  }
9937
10001
  return { ok: true, prompt: this.transition(prompt.promptId, "resolved", "answered") };
9938
10002
  }
@@ -9998,7 +10062,7 @@ var PromptRegistry = class {
9998
10062
  if (!entry) throw new Error(`Unknown prompt: ${promptId}`);
9999
10063
  return entry;
10000
10064
  }
10001
- prune(sessionId) {
10065
+ sweepExpired(sessionId) {
10002
10066
  const now = this.now();
10003
10067
  const session = this.bySession.get(sessionId);
10004
10068
  if (!session) return;
@@ -11022,19 +11086,26 @@ var SessionHandlers = class {
11022
11086
  json(res, 400, { error: "Missing input field" });
11023
11087
  return;
11024
11088
  }
11025
- this.promptRegistry.hasActionable(sessionId);
11089
+ this.promptRegistry.sweepExpired(sessionId);
11026
11090
  const openPrompt = this.pendingPermission.has(sessionId) ? "permission" : this.pendingQuestions.has(sessionId) ? "question" : null;
11027
11091
  if (openPrompt) {
11028
- this.log.info(`[input.prompt_pending] ${sessionId.slice(0, 8)} kind=${openPrompt}`, {
11029
- event: "input.prompt_pending",
11030
- sessionId,
11031
- promptKind: openPrompt
11032
- });
11092
+ const pendingGate = openPrompt === "permission" ? this.pendingPermission.get(sessionId) : void 0;
11093
+ const promptState = pendingGate?.promptId !== void 0 && this.promptRegistry.get(pendingGate.promptId)?.state === "resolved" ? "answered" : "open";
11094
+ this.log.info(
11095
+ `[input.prompt_pending] ${sessionId.slice(0, 8)} kind=${openPrompt} state=${promptState}`,
11096
+ {
11097
+ event: "input.prompt_pending",
11098
+ sessionId,
11099
+ promptKind: openPrompt,
11100
+ promptState
11101
+ }
11102
+ );
11033
11103
  json(res, 409, {
11034
11104
  ok: false,
11035
11105
  reason: "prompt_pending",
11036
11106
  promptKind: openPrompt,
11037
- error: "A prompt is waiting for an answer; answer or dismiss it before sending text"
11107
+ promptState,
11108
+ error: promptState === "answered" ? "Your answer was sent; wait for the prompt to close before sending text" : "A prompt is waiting for an answer; answer or dismiss it before sending text"
11038
11109
  });
11039
11110
  return;
11040
11111
  }
@@ -11136,10 +11207,7 @@ var SessionHandlers = class {
11136
11207
  if (gate === null) {
11137
11208
  const prior2 = this.pendingPermission.get(sessionId);
11138
11209
  if (!prior2) return;
11139
- const prompt2 = prior2.promptId ? this.promptRegistry.get(prior2.promptId) : null;
11140
- if (prompt2?.state === "open" || prompt2?.state === "updated") {
11141
- this.promptRegistry.transition(prompt2.promptId, "cancelled", "provider_closed");
11142
- }
11210
+ if (prior2.promptId) this.promptRegistry.providerClosed(prior2.promptId, "provider_closed");
11143
11211
  this.pendingPermission.delete(sessionId);
11144
11212
  this.pendingPermissionKey.delete(sessionId);
11145
11213
  this.broadcastToSession(sessionId, { type: "permission_cancelled", sessionId });
@@ -11364,7 +11432,13 @@ var SessionHandlers = class {
11364
11432
  return;
11365
11433
  }
11366
11434
  try {
11367
- this.ptyManager.sendKeys(sessionId, option.answerKeys ?? permissionAnswerKeys(option.index));
11435
+ this.promptRegistry.whileAnswering(
11436
+ gate.promptId ?? "",
11437
+ () => this.ptyManager.sendKeys(
11438
+ sessionId,
11439
+ option.answerKeys ?? permissionAnswerKeys(option.index)
11440
+ )
11441
+ );
11368
11442
  } catch (err) {
11369
11443
  const message = err instanceof Error ? err.message : "Failed to send answer";
11370
11444
  json(res, 400, { ok: false, reason: message });