@threadbase-sh/streamer 1.69.6 → 1.70.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/index.js CHANGED
@@ -2169,7 +2169,7 @@ function toPublicSession(s) {
2169
2169
  }
2170
2170
 
2171
2171
  // src/pty-host/protocol.ts
2172
- var PTY_HOST_PROTOCOL_VERSION = 3;
2172
+ var PTY_HOST_PROTOCOL_VERSION = 4;
2173
2173
  function isHostEvent(message) {
2174
2174
  return "type" in message && message.type === "event";
2175
2175
  }
@@ -2257,8 +2257,12 @@ var RemoteSessionRunner = class _RemoteSessionRunner {
2257
2257
  }
2258
2258
  throw new PtyHostProtocolMismatchError(status.protocolVersion, PTY_HOST_PROTOCOL_VERSION);
2259
2259
  }
2260
- await runner.request({ type: "subscribe" });
2260
+ const subscribed = await runner.request({ type: "subscribe" });
2261
2261
  runner.refreshMirror(status);
2262
+ runner.restorePromptSnapshots({
2263
+ ...status,
2264
+ promptSnapshots: subscribed.promptSnapshots ?? status.promptSnapshots
2265
+ });
2262
2266
  return runner;
2263
2267
  }
2264
2268
  constructor(transport, options) {
@@ -2351,6 +2355,19 @@ var RemoteSessionRunner = class _RemoteSessionRunner {
2351
2355
  this.pids.set(session.id, entry.pid);
2352
2356
  }
2353
2357
  }
2358
+ restorePromptSnapshots(status) {
2359
+ for (const snapshot of status.promptSnapshots ?? []) {
2360
+ if (snapshot.kind === "permission") {
2361
+ this.options.onPermissionChange?.(snapshot.sessionId, snapshot.gate, snapshot.occurrenceId);
2362
+ } else {
2363
+ this.options.onLiveQuestion?.(
2364
+ snapshot.sessionId,
2365
+ snapshot.questions,
2366
+ snapshot.occurrenceId
2367
+ );
2368
+ }
2369
+ }
2370
+ }
2354
2371
  async heartbeat(state, timeoutMs = HOST_HEARTBEAT_REQUEST_TIMEOUT_MS) {
2355
2372
  await this.request({ type: "heartbeat", ...state }, timeoutMs);
2356
2373
  }
@@ -2405,13 +2422,13 @@ var RemoteSessionRunner = class _RemoteSessionRunner {
2405
2422
  break;
2406
2423
  }
2407
2424
  case "permission-change":
2408
- this.options.onPermissionChange?.(event.sessionId, event.gate);
2425
+ this.options.onPermissionChange?.(event.sessionId, event.gate, event.occurrenceId);
2409
2426
  break;
2410
2427
  case "phase-change":
2411
2428
  this.options.onPhaseChange?.(event.sessionId, event.phase);
2412
2429
  break;
2413
2430
  case "live-question":
2414
- this.options.onLiveQuestion?.(event.sessionId, event.questions);
2431
+ this.options.onLiveQuestion?.(event.sessionId, event.questions, event.occurrenceId);
2415
2432
  break;
2416
2433
  case "live-question-gone":
2417
2434
  this.options.onLiveQuestionGone?.(event.sessionId);
@@ -4086,10 +4103,134 @@ async function readGitBranch(dir) {
4086
4103
  }
4087
4104
  }
4088
4105
 
4106
+ // src/schemas/prompt.schema.ts
4107
+ import { z } from "zod";
4108
+ var PROMPT_SCHEMA_VERSION = 1;
4109
+ var OpaqueIdSchema = z.string().trim().min(1).max(200);
4110
+ var MeaningfulStringSchema = z.string().trim().min(1);
4111
+ var PromptOptionSchema = z.object({
4112
+ optionId: OpaqueIdSchema,
4113
+ label: MeaningfulStringSchema,
4114
+ description: z.string().optional(),
4115
+ preview: z.string().optional()
4116
+ });
4117
+ var PromptQuestionSchema = z.object({
4118
+ questionId: OpaqueIdSchema,
4119
+ text: MeaningfulStringSchema,
4120
+ header: z.string().optional(),
4121
+ inputMode: z.enum(["single", "multi", "text"]),
4122
+ options: z.array(PromptOptionSchema),
4123
+ allowOther: z.boolean(),
4124
+ secret: z.union([z.boolean(), z.literal("unknown")])
4125
+ }).superRefine((question, ctx) => {
4126
+ const optionIds = question.options.map((option) => option.optionId);
4127
+ if (new Set(optionIds).size !== optionIds.length) {
4128
+ ctx.addIssue({
4129
+ code: "custom",
4130
+ message: "optionId values must be unique",
4131
+ path: ["options"]
4132
+ });
4133
+ }
4134
+ if (question.inputMode === "text" && question.options.length !== 0) {
4135
+ ctx.addIssue({
4136
+ code: "custom",
4137
+ message: "text questions cannot carry options",
4138
+ path: ["options"]
4139
+ });
4140
+ }
4141
+ if (question.inputMode !== "text" && question.options.length === 0) {
4142
+ ctx.addIssue({
4143
+ code: "custom",
4144
+ message: "select questions require options",
4145
+ path: ["options"]
4146
+ });
4147
+ }
4148
+ });
4149
+ var TERMINAL_PROMPT_STATES = /* @__PURE__ */ new Set(["resolved", "cancelled", "expired", "unavailable"]);
4150
+ var PromptSchema = z.object({
4151
+ schemaVersion: z.literal(PROMPT_SCHEMA_VERSION),
4152
+ sessionId: OpaqueIdSchema,
4153
+ promptId: OpaqueIdSchema,
4154
+ revision: z.number().int().positive(),
4155
+ state: z.enum(["open", "updated", "resolved", "cancelled", "expired", "unavailable"]),
4156
+ terminalReason: MeaningfulStringSchema.optional(),
4157
+ intent: z.enum(["approval", "question"]),
4158
+ title: z.string().optional(),
4159
+ message: z.string().optional(),
4160
+ detail: z.string().optional(),
4161
+ questions: z.array(PromptQuestionSchema).min(1),
4162
+ answerRequirement: z.enum(["blocking", "non_blocking", "unknown"]),
4163
+ expiresAt: z.string().datetime({ offset: true }).nullable(),
4164
+ provenance: z.object({
4165
+ source: z.enum(["provider", "screen", "transcript", "synthetic"]),
4166
+ confidence: z.enum(["authoritative", "inferred"])
4167
+ })
4168
+ }).superRefine((prompt, ctx) => {
4169
+ if (![prompt.title, prompt.message, prompt.detail].some((value) => value?.trim())) {
4170
+ ctx.addIssue({
4171
+ code: "custom",
4172
+ message: "prompt requires a meaningful title, message, or detail",
4173
+ path: ["message"]
4174
+ });
4175
+ }
4176
+ const questionIds = prompt.questions.map((question) => question.questionId);
4177
+ if (new Set(questionIds).size !== questionIds.length) {
4178
+ ctx.addIssue({
4179
+ code: "custom",
4180
+ message: "questionId values must be unique",
4181
+ path: ["questions"]
4182
+ });
4183
+ }
4184
+ const optionIds = prompt.questions.flatMap(
4185
+ (question) => question.options.map((option) => option.optionId)
4186
+ );
4187
+ if (new Set(optionIds).size !== optionIds.length) {
4188
+ ctx.addIssue({
4189
+ code: "custom",
4190
+ message: "optionId values must be unique within a prompt",
4191
+ path: ["questions"]
4192
+ });
4193
+ }
4194
+ const terminal = TERMINAL_PROMPT_STATES.has(prompt.state);
4195
+ if (terminal !== (prompt.terminalReason !== void 0)) {
4196
+ ctx.addIssue({
4197
+ code: "custom",
4198
+ message: terminal ? "terminal prompts require terminalReason" : "actionable prompts cannot carry terminalReason",
4199
+ path: ["terminalReason"]
4200
+ });
4201
+ }
4202
+ });
4203
+ var OptionResponseSchema = z.object({
4204
+ questionId: OpaqueIdSchema,
4205
+ optionIds: z.array(OpaqueIdSchema).min(1).refine((ids) => new Set(ids).size === ids.length, "optionIds must be unique"),
4206
+ text: z.never().optional()
4207
+ });
4208
+ var TextResponseSchema = z.object({
4209
+ questionId: OpaqueIdSchema,
4210
+ text: z.string(),
4211
+ optionIds: z.never().optional()
4212
+ });
4213
+ var PromptResponseSchema = z.union([OptionResponseSchema, TextResponseSchema]);
4214
+ var PromptAnswerSchema = z.object({
4215
+ promptId: OpaqueIdSchema,
4216
+ revision: z.number().int().positive(),
4217
+ responses: z.array(PromptResponseSchema).min(1),
4218
+ idempotencyKey: OpaqueIdSchema
4219
+ }).superRefine((answer, ctx) => {
4220
+ const questionIds = answer.responses.map((response) => response.questionId);
4221
+ if (new Set(questionIds).size !== questionIds.length) {
4222
+ ctx.addIssue({
4223
+ code: "custom",
4224
+ message: "each questionId can be answered only once",
4225
+ path: ["responses"]
4226
+ });
4227
+ }
4228
+ });
4229
+
4089
4230
  // src/server.ts
4090
4231
  import { createNodeWebSocket } from "@hono/node-ws";
4091
4232
  import { Connection, Client as TemporalClient } from "@temporalio/client";
4092
- import { randomUUID as randomUUID6 } from "crypto";
4233
+ import { randomUUID as randomUUID7 } from "crypto";
4093
4234
  import { EventEmitter } from "events";
4094
4235
  import { existsSync as existsSync16 } from "fs";
4095
4236
  import { realpath as realpath2 } from "fs/promises";
@@ -4725,11 +4866,11 @@ var createBrowseRoutes = (deps) => {
4725
4866
  import { Hono as Hono4 } from "hono";
4726
4867
 
4727
4868
  // src/schemas/cacheAlert.schema.ts
4728
- import { z } from "zod";
4729
- var ResolveCacheAlertSchema = z.object({
4730
- fingerprint: z.string(),
4731
- action: z.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
4732
- ids: z.array(z.string()).optional()
4869
+ import { z as z2 } from "zod";
4870
+ var ResolveCacheAlertSchema = z2.object({
4871
+ fingerprint: z2.string(),
4872
+ action: z2.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
4873
+ ids: z2.array(z2.string()).optional()
4733
4874
  }).refine((v) => v.action !== "prune_selected" || v.ids !== void 0 && v.ids.length > 0, {
4734
4875
  message: "prune_selected requires a non-empty ids array",
4735
4876
  path: ["ids"]
@@ -4785,12 +4926,12 @@ var createCacheAlertRoutes = (deps) => {
4785
4926
  import { Hono as Hono5 } from "hono";
4786
4927
 
4787
4928
  // src/schemas/claudeFlags.schema.ts
4788
- import { z as z2 } from "zod";
4789
- var ClaudeFlagsBodySchema = z2.object({
4790
- values: z2.record(z2.string(), z2.union([z2.string(), z2.boolean(), z2.array(z2.string())])).default({}),
4929
+ import { z as z3 } from "zod";
4930
+ var ClaudeFlagsBodySchema = z3.object({
4931
+ values: z3.record(z3.string(), z3.union([z3.string(), z3.boolean(), z3.array(z3.string())])).default({}),
4791
4932
  // A newline would corrupt the flat one-line-per-key server.yaml, so reject
4792
4933
  // it here with a field error instead of silently stripping it.
4793
- extraArgs: z2.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
4934
+ extraArgs: z3.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
4794
4935
  }).strict();
4795
4936
 
4796
4937
  // src/api/routes/config.routes.ts
@@ -5324,15 +5465,15 @@ import { join as join9 } from "path";
5324
5465
  import { parse as parseYaml } from "yaml";
5325
5466
 
5326
5467
  // src/schemas/updateConfig.schema.ts
5327
- import { z as z3 } from "zod";
5328
- var UpdateConfigSchema = z3.object({
5329
- auto_update: z3.boolean().default(false),
5330
- channel: z3.enum(["stable", "next"]).default("stable"),
5331
- allow: z3.array(z3.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
5332
- poll_interval_minutes: z3.number().int().min(0).default(1440),
5333
- defer_if_active_sessions: z3.boolean().default(true),
5334
- github_repo: z3.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
5335
- webhook_secret: z3.string().min(1).nullable().default(null)
5468
+ import { z as z4 } from "zod";
5469
+ var UpdateConfigSchema = z4.object({
5470
+ auto_update: z4.boolean().default(false),
5471
+ channel: z4.enum(["stable", "next"]).default("stable"),
5472
+ allow: z4.array(z4.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
5473
+ poll_interval_minutes: z4.number().int().min(0).default(1440),
5474
+ defer_if_active_sessions: z4.boolean().default(true),
5475
+ github_repo: z4.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
5476
+ webhook_secret: z4.string().min(1).nullable().default(null)
5336
5477
  }).strict();
5337
5478
 
5338
5479
  // src/config/update-config.ts
@@ -5962,7 +6103,13 @@ var createMiscRoutes = (deps) => {
5962
6103
  // the box is starved. Additive capability flag only — live readings stay
5963
6104
  // off this polled endpoint. Absent means an older server that never
5964
6105
  // samples. Informational: pressure never holds, kills, or refuses sessions.
5965
- hostPressure: true
6106
+ hostPressure: true,
6107
+ // Provider-neutral prompt contract: normalized prompt events, opaque ids
6108
+ // and the atomic /prompt/answer route. A prompt_snapshot on subscribe
6109
+ // carries RETAINED prompts, terminal ones included — render on `state`,
6110
+ // not on presence — and an answer retry after that retention window is
6111
+ // answered 404 prompt_not_found rather than the recorded outcome.
6112
+ promptContract: { schemaVersion: 1, atomicAnswer: true }
5966
6113
  });
5967
6114
  });
5968
6115
  app.get("/api/profiles", (c) => c.json([]));
@@ -6331,6 +6478,10 @@ var createSessionRoutes = (deps) => {
6331
6478
  await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6332
6479
  return alreadyHandled6();
6333
6480
  });
6481
+ app.post("/:id/prompt/answer", async (c) => {
6482
+ await deps.handlePromptAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6483
+ return alreadyHandled6();
6484
+ });
6334
6485
  app.post("/:id/permission/answer", async (c) => {
6335
6486
  await deps.handlePermissionAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6336
6487
  return alreadyHandled6();
@@ -9211,7 +9362,7 @@ var ConversationHandlers = class {
9211
9362
  };
9212
9363
 
9213
9364
  // src/api/handlers/sessions.handlers.ts
9214
- import { randomUUID as randomUUID4 } from "crypto";
9365
+ import { randomUUID as randomUUID5 } from "crypto";
9215
9366
  import { existsSync as existsSync10 } from "fs";
9216
9367
  import { basename as basename6, dirname as dirname9, join as join16 } from "path";
9217
9368
 
@@ -9544,6 +9695,396 @@ async function recordUpload(pool2, instanceId, row) {
9544
9695
  );
9545
9696
  }
9546
9697
 
9698
+ // src/services/prompts/promptRegistry.ts
9699
+ import { randomUUID as randomUUID4 } from "crypto";
9700
+ var PROMPT_TERMINAL_RETENTION_MS = 10 * 60 * 1e3;
9701
+ var PROMPT_MAX_RECORDS_PER_SESSION = 200;
9702
+ var MAX_TIMEOUT_MS = 2147483647;
9703
+ function copyPrompt(prompt) {
9704
+ return {
9705
+ ...prompt,
9706
+ questions: prompt.questions.map((question) => ({
9707
+ ...question,
9708
+ options: question.options.map((option) => ({ ...option }))
9709
+ })),
9710
+ provenance: { ...prompt.provenance }
9711
+ };
9712
+ }
9713
+ function terminalError(state) {
9714
+ switch (state) {
9715
+ case "resolved":
9716
+ return "already_resolved";
9717
+ case "expired":
9718
+ return "prompt_expired";
9719
+ case "cancelled":
9720
+ return "prompt_cancelled";
9721
+ case "unavailable":
9722
+ return "prompt_unavailable";
9723
+ }
9724
+ }
9725
+ var PromptRegistry = class {
9726
+ bySession = /* @__PURE__ */ new Map();
9727
+ byId = /* @__PURE__ */ new Map();
9728
+ sequences = /* @__PURE__ */ new Map();
9729
+ createId;
9730
+ emit;
9731
+ onExpire;
9732
+ now;
9733
+ terminalRetentionMs;
9734
+ maxRecordsPerSession;
9735
+ constructor(options = {}) {
9736
+ this.createId = options.createId ?? randomUUID4;
9737
+ this.emit = options.emit;
9738
+ this.onExpire = options.onExpire;
9739
+ this.now = options.now ?? Date.now;
9740
+ this.terminalRetentionMs = options.terminalRetentionMs ?? PROMPT_TERMINAL_RETENTION_MS;
9741
+ this.maxRecordsPerSession = options.maxRecordsPerSession ?? PROMPT_MAX_RECORDS_PER_SESSION;
9742
+ }
9743
+ open(draft, adapter, promptId = this.createId()) {
9744
+ this.sweepExpired(draft.sessionId);
9745
+ const held = this.byId.get(promptId);
9746
+ if (held && (held.prompt.state === "open" || held.prompt.state === "updated")) {
9747
+ throw new Error(`Prompt id already exists: ${promptId}`);
9748
+ }
9749
+ const id = held ? this.createId() : promptId;
9750
+ const prompt = PromptSchema.parse({
9751
+ ...draft,
9752
+ schemaVersion: PROMPT_SCHEMA_VERSION,
9753
+ promptId: id,
9754
+ revision: 1,
9755
+ state: "open",
9756
+ questions: draft.questions.map((question) => ({
9757
+ ...question,
9758
+ questionId: this.createId(),
9759
+ options: question.options.map((option) => ({ ...option, optionId: this.createId() }))
9760
+ })),
9761
+ provenance: { ...draft.provenance }
9762
+ });
9763
+ const entry = {
9764
+ prompt,
9765
+ adapter,
9766
+ queue: Promise.resolve(),
9767
+ inFlight: /* @__PURE__ */ new Map(),
9768
+ outcomes: /* @__PURE__ */ new Map()
9769
+ };
9770
+ const session = this.bySession.get(prompt.sessionId) ?? /* @__PURE__ */ new Map();
9771
+ session.set(prompt.promptId, entry);
9772
+ this.bySession.set(prompt.sessionId, session);
9773
+ this.byId.set(prompt.promptId, entry);
9774
+ this.publish(entry);
9775
+ this.scheduleExpiration(entry);
9776
+ this.enforceCap(prompt.sessionId);
9777
+ return copyPrompt(prompt);
9778
+ }
9779
+ update(promptId, draft, adapter) {
9780
+ const entry = this.requireEntry(promptId);
9781
+ this.expireIfDue(entry, this.now());
9782
+ if (entry.prompt.sessionId !== draft.sessionId) throw new Error("Prompt session cannot change");
9783
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") {
9784
+ throw new Error(`Cannot update terminal prompt ${promptId}`);
9785
+ }
9786
+ if (entry.prompt.questions.length !== draft.questions.length) {
9787
+ throw new Error("Prompt question cardinality cannot change during an update");
9788
+ }
9789
+ const questions = draft.questions.map((question, questionIndex) => {
9790
+ const prior = entry.prompt.questions[questionIndex];
9791
+ if (prior.options.length !== question.options.length) {
9792
+ throw new Error("Prompt option cardinality cannot change during an update");
9793
+ }
9794
+ return {
9795
+ ...question,
9796
+ questionId: prior.questionId,
9797
+ options: question.options.map((option, optionIndex) => ({
9798
+ ...option,
9799
+ optionId: prior.options[optionIndex].optionId
9800
+ }))
9801
+ };
9802
+ });
9803
+ const prompt = PromptSchema.parse({
9804
+ ...draft,
9805
+ schemaVersion: PROMPT_SCHEMA_VERSION,
9806
+ promptId,
9807
+ revision: entry.prompt.revision + 1,
9808
+ state: "updated",
9809
+ questions,
9810
+ provenance: { ...draft.provenance }
9811
+ });
9812
+ entry.prompt = prompt;
9813
+ if (adapter) entry.adapter = adapter;
9814
+ this.publish(entry);
9815
+ this.scheduleExpiration(entry);
9816
+ return copyPrompt(entry.prompt);
9817
+ }
9818
+ transition(promptId, state, reason) {
9819
+ const entry = this.requireEntry(promptId);
9820
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") {
9821
+ throw new Error(`Cannot transition terminal prompt ${promptId}`);
9822
+ }
9823
+ entry.prompt = {
9824
+ ...entry.prompt,
9825
+ revision: entry.prompt.revision + 1,
9826
+ state,
9827
+ terminalReason: reason
9828
+ };
9829
+ entry.terminalAt = this.now();
9830
+ this.clearExpiration(entry);
9831
+ this.publish(entry);
9832
+ return copyPrompt(entry.prompt);
9833
+ }
9834
+ invalidateSession(sessionId, reason = "session_ended") {
9835
+ const transitioned = [];
9836
+ for (const entry of this.bySession.get(sessionId)?.values() ?? []) {
9837
+ if (entry.prompt.state === "open" || entry.prompt.state === "updated") {
9838
+ transitioned.push(this.transition(entry.prompt.promptId, "unavailable", reason));
9839
+ }
9840
+ }
9841
+ return transitioned;
9842
+ }
9843
+ get(promptId) {
9844
+ const entry = this.byId.get(promptId);
9845
+ if (!entry) return null;
9846
+ this.sweepExpired(entry.prompt.sessionId);
9847
+ return this.byId.has(promptId) ? copyPrompt(entry.prompt) : null;
9848
+ }
9849
+ hasActionable(sessionId) {
9850
+ this.sweepExpired(sessionId);
9851
+ return [...this.bySession.get(sessionId)?.values() ?? []].some(
9852
+ (entry) => entry.prompt.state === "open" || entry.prompt.state === "updated"
9853
+ );
9854
+ }
9855
+ snapshot(sessionId) {
9856
+ this.sweepExpired(sessionId);
9857
+ return {
9858
+ type: "prompt_snapshot",
9859
+ schemaVersion: PROMPT_SCHEMA_VERSION,
9860
+ sessionId,
9861
+ sequence: this.sequences.get(sessionId) ?? 0,
9862
+ prompts: [...this.bySession.get(sessionId)?.values() ?? []].map(
9863
+ (entry) => copyPrompt(entry.prompt)
9864
+ )
9865
+ };
9866
+ }
9867
+ dispose() {
9868
+ for (const entry of this.byId.values()) this.clearExpiration(entry);
9869
+ }
9870
+ answer(sessionId, answer) {
9871
+ this.sweepExpired(sessionId);
9872
+ const entry = this.byId.get(answer.promptId);
9873
+ if (!entry || entry.prompt.sessionId !== sessionId) {
9874
+ return Promise.resolve({ ok: false, code: "prompt_not_found" });
9875
+ }
9876
+ this.pruneOutcomes(entry);
9877
+ const recorded = entry.outcomes.get(answer.idempotencyKey);
9878
+ if (recorded) return Promise.resolve(recorded.outcome);
9879
+ const pending = entry.inFlight.get(answer.idempotencyKey);
9880
+ if (pending) return pending;
9881
+ const task = entry.queue.then(() => this.performAnswer(entry, answer));
9882
+ entry.queue = task.then(
9883
+ () => void 0,
9884
+ () => void 0
9885
+ );
9886
+ entry.inFlight.set(answer.idempotencyKey, task);
9887
+ void task.then((outcome) => {
9888
+ entry.inFlight.delete(answer.idempotencyKey);
9889
+ entry.outcomes.set(answer.idempotencyKey, { at: this.now(), outcome });
9890
+ });
9891
+ return task;
9892
+ }
9893
+ async performAnswer(entry, answer) {
9894
+ const prompt = entry.prompt;
9895
+ if (prompt.state !== "open" && prompt.state !== "updated") {
9896
+ return { ok: false, code: terminalError(prompt.state) };
9897
+ }
9898
+ if (this.expireIfDue(entry, this.now())) {
9899
+ return { ok: false, code: "prompt_expired" };
9900
+ }
9901
+ if (prompt.revision !== answer.revision) {
9902
+ return {
9903
+ ok: false,
9904
+ code: "prompt_revision_mismatch",
9905
+ currentRevision: prompt.revision
9906
+ };
9907
+ }
9908
+ const responseError = this.validateResponses(prompt, answer);
9909
+ if (responseError) return { ok: false, code: responseError };
9910
+ if (!entry.adapter) return { ok: false, code: "prompt_unavailable" };
9911
+ let adapterResult;
9912
+ try {
9913
+ adapterResult = await entry.adapter({ prompt: copyPrompt(prompt), answer });
9914
+ } catch {
9915
+ return { ok: false, code: "provider_error" };
9916
+ }
9917
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") {
9918
+ return { ok: false, code: terminalError(entry.prompt.state) };
9919
+ }
9920
+ if (entry.prompt.revision !== answer.revision) {
9921
+ return {
9922
+ ok: false,
9923
+ code: "prompt_revision_mismatch",
9924
+ currentRevision: entry.prompt.revision
9925
+ };
9926
+ }
9927
+ if (!adapterResult.ok) {
9928
+ if (adapterResult.terminal) {
9929
+ this.transition(
9930
+ prompt.promptId,
9931
+ adapterResult.terminal.state,
9932
+ adapterResult.terminal.reason
9933
+ );
9934
+ }
9935
+ return { ok: false, code: adapterResult.code };
9936
+ }
9937
+ return { ok: true, prompt: this.transition(prompt.promptId, "resolved", "answered") };
9938
+ }
9939
+ validateResponses(prompt, answer) {
9940
+ const questions = new Map(prompt.questions.map((question) => [question.questionId, question]));
9941
+ for (const response of answer.responses) {
9942
+ if (!questions.has(response.questionId)) return "unknown_question";
9943
+ }
9944
+ if (answer.responses.length !== prompt.questions.length) return "incomplete_answer";
9945
+ const responses = new Map(answer.responses.map((response) => [response.questionId, response]));
9946
+ for (const question of prompt.questions) {
9947
+ const response = responses.get(question.questionId);
9948
+ if (!response) return "incomplete_answer";
9949
+ if (question.inputMode === "text") {
9950
+ if (typeof response.text !== "string") return "incomplete_answer";
9951
+ continue;
9952
+ }
9953
+ const optionIds = response.optionIds;
9954
+ if (!optionIds) return "incomplete_answer";
9955
+ if (question.inputMode === "single" && optionIds.length !== 1) {
9956
+ return "unsupported_prompt_shape";
9957
+ }
9958
+ const known = new Set(question.options.map((option) => option.optionId));
9959
+ if (optionIds.some((optionId) => !known.has(optionId))) return "unknown_option";
9960
+ }
9961
+ return null;
9962
+ }
9963
+ publish(entry) {
9964
+ const sessionId = entry.prompt.sessionId;
9965
+ const sequence = (this.sequences.get(sessionId) ?? 0) + 1;
9966
+ this.sequences.set(sessionId, sequence);
9967
+ this.emit?.({
9968
+ type: "prompt_event",
9969
+ sessionId,
9970
+ sequence,
9971
+ prompt: copyPrompt(entry.prompt)
9972
+ });
9973
+ }
9974
+ scheduleExpiration(entry) {
9975
+ this.clearExpiration(entry);
9976
+ const expiresAt = entry.prompt.expiresAt;
9977
+ if (expiresAt === null) return;
9978
+ const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, Date.parse(expiresAt) - this.now()));
9979
+ entry.expiryTimer = setTimeout(() => {
9980
+ entry.expiryTimer = void 0;
9981
+ if (!this.expireIfDue(entry, this.now())) this.scheduleExpiration(entry);
9982
+ }, delay);
9983
+ entry.expiryTimer.unref?.();
9984
+ }
9985
+ clearExpiration(entry) {
9986
+ if (entry.expiryTimer) clearTimeout(entry.expiryTimer);
9987
+ entry.expiryTimer = void 0;
9988
+ }
9989
+ expireIfDue(entry, now) {
9990
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") return false;
9991
+ if (entry.prompt.expiresAt === null || now < Date.parse(entry.prompt.expiresAt)) return false;
9992
+ const expired = this.transition(entry.prompt.promptId, "expired", "deadline_elapsed");
9993
+ this.onExpire?.(expired);
9994
+ return true;
9995
+ }
9996
+ requireEntry(promptId) {
9997
+ const entry = this.byId.get(promptId);
9998
+ if (!entry) throw new Error(`Unknown prompt: ${promptId}`);
9999
+ return entry;
10000
+ }
10001
+ sweepExpired(sessionId) {
10002
+ const now = this.now();
10003
+ const session = this.bySession.get(sessionId);
10004
+ if (!session) return;
10005
+ for (const [promptId, entry] of session) {
10006
+ this.expireIfDue(entry, now);
10007
+ if (entry.terminalAt !== void 0 && now - entry.terminalAt > this.terminalRetentionMs) {
10008
+ this.clearExpiration(entry);
10009
+ session.delete(promptId);
10010
+ this.byId.delete(promptId);
10011
+ }
10012
+ }
10013
+ if (session.size === 0) this.bySession.delete(sessionId);
10014
+ }
10015
+ enforceCap(sessionId) {
10016
+ const session = this.bySession.get(sessionId);
10017
+ if (!session || session.size <= this.maxRecordsPerSession) return;
10018
+ const terminal = [...session.entries()].filter(([, entry]) => entry.terminalAt !== void 0).sort((a, b) => (a[1].terminalAt ?? 0) - (b[1].terminalAt ?? 0));
10019
+ while (session.size > this.maxRecordsPerSession && terminal.length > 0) {
10020
+ const [promptId, entry] = terminal.shift();
10021
+ this.clearExpiration(entry);
10022
+ session.delete(promptId);
10023
+ this.byId.delete(promptId);
10024
+ }
10025
+ }
10026
+ pruneOutcomes(entry) {
10027
+ const now = this.now();
10028
+ for (const [key, recorded] of entry.outcomes) {
10029
+ if (now - recorded.at > this.terminalRetentionMs) entry.outcomes.delete(key);
10030
+ }
10031
+ }
10032
+ };
10033
+
10034
+ // src/services/prompts/ptyPromptAdapter.ts
10035
+ function permissionPromptDraft(sessionId, gate) {
10036
+ if (!gate) throw new Error("Cannot normalize an absent permission gate");
10037
+ const message = gate.prompt?.trim() || "Approval required";
10038
+ return {
10039
+ sessionId,
10040
+ intent: "approval",
10041
+ title: "Approval",
10042
+ message,
10043
+ ...gate.detail?.trim() ? { detail: gate.detail } : {},
10044
+ questions: [
10045
+ {
10046
+ text: message,
10047
+ header: "Approval",
10048
+ inputMode: "single",
10049
+ options: gate.options.map((option) => ({ label: option.label })),
10050
+ allowOther: false,
10051
+ secret: "unknown"
10052
+ }
10053
+ ],
10054
+ answerRequirement: "unknown",
10055
+ expiresAt: null,
10056
+ provenance: { source: "screen", confidence: "inferred" }
10057
+ };
10058
+ }
10059
+ function questionPromptDraft(sessionId, questions, source) {
10060
+ const first = questions[0];
10061
+ if (!first) throw new Error("Cannot normalize an empty question list");
10062
+ return {
10063
+ sessionId,
10064
+ intent: "question",
10065
+ ...first.header.trim() ? { title: first.header } : {},
10066
+ message: first.question,
10067
+ questions: questions.map((question) => ({
10068
+ text: question.question,
10069
+ ...question.header.trim() ? { header: question.header } : {},
10070
+ inputMode: question.multiSelect ? "multi" : "single",
10071
+ options: question.options.map((option) => ({
10072
+ label: option.label,
10073
+ ...option.description ? { description: option.description } : {},
10074
+ ...option.preview ? { preview: option.preview } : {}
10075
+ })),
10076
+ allowOther: false,
10077
+ secret: "unknown"
10078
+ })),
10079
+ answerRequirement: "unknown",
10080
+ expiresAt: null,
10081
+ provenance: {
10082
+ source,
10083
+ confidence: source === "transcript" ? "authoritative" : "inferred"
10084
+ }
10085
+ };
10086
+ }
10087
+
9547
10088
  // src/services/questions/parseStatusLine.ts
9548
10089
  var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
9549
10090
  var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
@@ -9906,6 +10447,21 @@ function codexSessionActiveBody(outcome) {
9906
10447
  ...outcome.ownerSource != null && { ownerSource: outcome.ownerSource }
9907
10448
  };
9908
10449
  }
10450
+ function promptAnswerStatus(code) {
10451
+ switch (code) {
10452
+ case "prompt_not_found":
10453
+ return 404;
10454
+ case "provider_error":
10455
+ return 502;
10456
+ case "unknown_question":
10457
+ case "unknown_option":
10458
+ case "incomplete_answer":
10459
+ case "unsupported_prompt_shape":
10460
+ return 400;
10461
+ default:
10462
+ return 409;
10463
+ }
10464
+ }
9909
10465
  var SessionHandlers = class {
9910
10466
  constructor(deps) {
9911
10467
  this.deps = deps;
@@ -9944,6 +10500,10 @@ var SessionHandlers = class {
9944
10500
  get pendingQuestions() {
9945
10501
  return this.deps.pendingQuestions;
9946
10502
  }
10503
+ get promptRegistry() {
10504
+ if (!this.deps.promptRegistry) this.deps.promptRegistry = new PromptRegistry();
10505
+ return this.deps.promptRegistry;
10506
+ }
9947
10507
  get pendingQuestionKey() {
9948
10508
  return this.deps.pendingQuestionKey;
9949
10509
  }
@@ -10462,18 +11022,26 @@ var SessionHandlers = class {
10462
11022
  json(res, 400, { error: "Missing input field" });
10463
11023
  return;
10464
11024
  }
11025
+ this.promptRegistry.sweepExpired(sessionId);
10465
11026
  const openPrompt = this.pendingPermission.has(sessionId) ? "permission" : this.pendingQuestions.has(sessionId) ? "question" : null;
10466
11027
  if (openPrompt) {
10467
- this.log.info(`[input.prompt_pending] ${sessionId.slice(0, 8)} kind=${openPrompt}`, {
10468
- event: "input.prompt_pending",
10469
- sessionId,
10470
- promptKind: openPrompt
10471
- });
11028
+ const pendingGate = openPrompt === "permission" ? this.pendingPermission.get(sessionId) : void 0;
11029
+ const promptState = pendingGate?.promptId !== void 0 && this.promptRegistry.get(pendingGate.promptId)?.state === "resolved" ? "answered" : "open";
11030
+ this.log.info(
11031
+ `[input.prompt_pending] ${sessionId.slice(0, 8)} kind=${openPrompt} state=${promptState}`,
11032
+ {
11033
+ event: "input.prompt_pending",
11034
+ sessionId,
11035
+ promptKind: openPrompt,
11036
+ promptState
11037
+ }
11038
+ );
10472
11039
  json(res, 409, {
10473
11040
  ok: false,
10474
11041
  reason: "prompt_pending",
10475
11042
  promptKind: openPrompt,
10476
- error: "A prompt is waiting for an answer; answer or dismiss it before sending text"
11043
+ promptState,
11044
+ 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"
10477
11045
  });
10478
11046
  return;
10479
11047
  }
@@ -10517,30 +11085,99 @@ var SessionHandlers = class {
10517
11085
  // the later JSONL flush of the same question is de-duped. We synthesize a
10518
11086
  // screen-scoped toolUseId; the JSONL path overwrites pendingQuestions with the
10519
11087
  // real toolUseId when it lands, so answering works once JSONL catches up.
10520
- handleLiveQuestion(sessionId, questions) {
11088
+ handleLiveQuestion(sessionId, questions, occurrenceId) {
10521
11089
  const key = questionContentKey(questions);
10522
11090
  if (this.pendingQuestionKey.get(sessionId) === key) return;
10523
11091
  const toolUseId = `screen:${sessionId}:${key.length}`;
10524
- this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
11092
+ const prior = this.pendingQuestions.get(sessionId);
11093
+ const priorPrompt = prior ? this.promptRegistry.get(prior.promptId) : null;
11094
+ if (priorPrompt?.state === "open" || priorPrompt?.state === "updated") {
11095
+ this.promptRegistry.transition(priorPrompt.promptId, "cancelled", "replaced");
11096
+ }
11097
+ const prompt = this.promptRegistry.open(
11098
+ questionPromptDraft(sessionId, questions, "screen"),
11099
+ this.questionAnswerAdapter(sessionId),
11100
+ occurrenceId
11101
+ );
11102
+ this.pendingQuestions.set(sessionId, {
11103
+ toolUseId,
11104
+ questions,
11105
+ origin: "pty",
11106
+ promptId: prompt.promptId
11107
+ });
10525
11108
  this.pendingQuestionKey.set(sessionId, key);
10526
11109
  this.broadcastToSession(sessionId, { type: "question", sessionId, toolUseId, questions });
10527
11110
  }
11111
+ handleJsonlQuestion(sessionId, toolUseId, questions, origin) {
11112
+ const prior = this.pendingQuestions.get(sessionId);
11113
+ const sameQuestion = prior !== void 0 && questionContentKey(prior.questions) === questionContentKey(questions);
11114
+ let prompt;
11115
+ if (sameQuestion) {
11116
+ const current = this.promptRegistry.get(prior.promptId);
11117
+ prompt = current?.provenance.source === "transcript" ? current : this.promptRegistry.update(
11118
+ prior.promptId,
11119
+ questionPromptDraft(sessionId, questions, "transcript"),
11120
+ this.questionAnswerAdapter(sessionId)
11121
+ );
11122
+ } else {
11123
+ const priorPrompt = prior ? this.promptRegistry.get(prior.promptId) : null;
11124
+ if (priorPrompt?.state === "open" || priorPrompt?.state === "updated") {
11125
+ this.promptRegistry.transition(priorPrompt.promptId, "cancelled", "replaced");
11126
+ }
11127
+ prompt = this.promptRegistry.open(
11128
+ questionPromptDraft(sessionId, questions, "transcript"),
11129
+ this.questionAnswerAdapter(sessionId)
11130
+ );
11131
+ }
11132
+ this.pendingQuestions.set(sessionId, {
11133
+ toolUseId,
11134
+ questions,
11135
+ origin,
11136
+ promptId: prompt.promptId
11137
+ });
11138
+ }
10528
11139
  // Permission gate opened/closed (OSC 777 + scraped options). Broadcasts the
10529
11140
  // additive `permission` / `permission_cancelled` events. Mobile answers by
10530
11141
  // sending the chosen option index via /input { keys } (e.g. "2\r").
10531
- handlePermissionChange(sessionId, gate) {
11142
+ handlePermissionChange(sessionId, gate, occurrenceId) {
10532
11143
  if (gate === null) {
10533
- if (!this.pendingPermission.has(sessionId)) return;
11144
+ const prior2 = this.pendingPermission.get(sessionId);
11145
+ if (!prior2) return;
11146
+ const prompt2 = prior2.promptId ? this.promptRegistry.get(prior2.promptId) : null;
11147
+ if (prompt2?.state === "open" || prompt2?.state === "updated") {
11148
+ this.promptRegistry.transition(prompt2.promptId, "cancelled", "provider_closed");
11149
+ }
10534
11150
  this.pendingPermission.delete(sessionId);
10535
11151
  this.pendingPermissionKey.delete(sessionId);
10536
11152
  this.broadcastToSession(sessionId, { type: "permission_cancelled", sessionId });
10537
11153
  return;
10538
11154
  }
10539
11155
  const key = permissionContentKey(gate);
10540
- if (this.pendingPermissionKey.get(sessionId) === key) return;
10541
11156
  const prior = this.pendingPermission.get(sessionId);
10542
- const gateId = prior && permissionGateKey(prior) === permissionGateKey(gate) ? prior.gateId : randomUUID4();
10543
- this.pendingPermission.set(sessionId, { ...gate, gateId });
11157
+ const priorPromptId = prior?.promptId;
11158
+ if (this.pendingPermissionKey.get(sessionId) === key && (occurrenceId === void 0 || prior?.occurrenceId === occurrenceId)) {
11159
+ return;
11160
+ }
11161
+ const samePrompt = prior && priorPromptId !== void 0 && permissionGateKey(prior) === permissionGateKey(gate) && (occurrenceId === void 0 || prior.occurrenceId === occurrenceId);
11162
+ if (prior && !samePrompt) {
11163
+ const priorPrompt = prior.promptId ? this.promptRegistry.get(prior.promptId) : null;
11164
+ if (priorPrompt?.state === "open" || priorPrompt?.state === "updated") {
11165
+ this.promptRegistry.transition(priorPrompt.promptId, "cancelled", "replaced");
11166
+ }
11167
+ }
11168
+ const prompt = gate.options.length === 0 ? null : samePrompt ? this.promptRegistry.get(priorPromptId) : this.promptRegistry.open(
11169
+ permissionPromptDraft(sessionId, gate),
11170
+ this.permissionAnswerAdapter(sessionId),
11171
+ occurrenceId
11172
+ );
11173
+ if (samePrompt && !prompt) throw new Error("Pending permission prompt disappeared");
11174
+ const gateId = prompt?.promptId ?? occurrenceId ?? prior?.gateId ?? randomUUID5();
11175
+ this.pendingPermission.set(sessionId, {
11176
+ ...gate,
11177
+ gateId,
11178
+ ...prompt ? { promptId: prompt.promptId } : {},
11179
+ ...occurrenceId !== void 0 ? { occurrenceId } : {}
11180
+ });
10544
11181
  this.pendingPermissionKey.set(sessionId, key);
10545
11182
  const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
10546
11183
  this.log.info(
@@ -10558,6 +11195,107 @@ var SessionHandlers = class {
10558
11195
  gateId
10559
11196
  });
10560
11197
  }
11198
+ permissionAnswerAdapter(sessionId) {
11199
+ return async ({ prompt, answer }) => {
11200
+ const gate = this.pendingPermission.get(sessionId);
11201
+ if (!gate || gate.promptId !== prompt.promptId) {
11202
+ return {
11203
+ ok: false,
11204
+ code: "prompt_unavailable",
11205
+ terminal: { state: "unavailable", reason: "provider_prompt_missing" }
11206
+ };
11207
+ }
11208
+ const response = answer.responses[0];
11209
+ const selectedId = response?.optionIds?.[0];
11210
+ const selectedIndex = prompt.questions[0]?.options.findIndex(
11211
+ (option2) => option2.optionId === selectedId
11212
+ );
11213
+ if (selectedIndex === void 0 || selectedIndex < 0) {
11214
+ return { ok: false, code: "unknown_option" };
11215
+ }
11216
+ const option = gate.options[selectedIndex];
11217
+ if (!option) return { ok: false, code: "unknown_option" };
11218
+ const provider = this.sessionStore.getManaged(sessionId)?.provider;
11219
+ if (provider !== CODEX_CLI_PROVIDER && !await this.permissionGateStillOpen(sessionId, permissionGateKey(gate))) {
11220
+ return {
11221
+ ok: false,
11222
+ code: "prompt_cancelled",
11223
+ terminal: { state: "cancelled", reason: "provider_closed" }
11224
+ };
11225
+ }
11226
+ if (this.pendingPermission.get(sessionId)?.promptId !== prompt.promptId) {
11227
+ return { ok: false, code: "prompt_cancelled" };
11228
+ }
11229
+ try {
11230
+ this.ptyManager.sendKeys(
11231
+ sessionId,
11232
+ option.answerKeys ?? permissionAnswerKeys(option.index)
11233
+ );
11234
+ } catch {
11235
+ return { ok: false, code: "provider_error" };
11236
+ }
11237
+ return { ok: true };
11238
+ };
11239
+ }
11240
+ questionAnswerAdapter(sessionId) {
11241
+ return async ({ prompt, answer }) => {
11242
+ const pending = this.pendingQuestions.get(sessionId);
11243
+ if (!pending || pending.promptId !== prompt.promptId) {
11244
+ return {
11245
+ ok: false,
11246
+ code: "prompt_unavailable",
11247
+ terminal: { state: "unavailable", reason: "provider_prompt_missing" }
11248
+ };
11249
+ }
11250
+ const answers = {};
11251
+ for (const question of prompt.questions) {
11252
+ const response = answer.responses.find((item) => item.questionId === question.questionId);
11253
+ if (!response?.optionIds) return { ok: false, code: "unsupported_prompt_shape" };
11254
+ answers[question.text] = response.optionIds.map((optionId) => {
11255
+ const option = question.options.find((item) => item.optionId === optionId);
11256
+ return option?.label ?? "";
11257
+ });
11258
+ }
11259
+ const resolution = resolveAnswer(pending, {
11260
+ toolUseId: pending.toolUseId,
11261
+ answers
11262
+ });
11263
+ if (!resolution.ok) {
11264
+ const code = resolution.reason === "unknown_option" || resolution.reason === "incomplete_answer" || resolution.reason === "unsupported_prompt_shape" ? resolution.reason : "prompt_unavailable";
11265
+ return { ok: false, code };
11266
+ }
11267
+ if (!await this.questionMenuStillOpen(sessionId)) {
11268
+ this.pendingQuestions.delete(sessionId);
11269
+ this.pendingQuestionKey.delete(sessionId);
11270
+ this.broadcastToSession(sessionId, {
11271
+ type: "question_cancelled",
11272
+ sessionId,
11273
+ toolUseId: pending.toolUseId
11274
+ });
11275
+ return {
11276
+ ok: false,
11277
+ code: "prompt_cancelled",
11278
+ terminal: { state: "cancelled", reason: "provider_closed" }
11279
+ };
11280
+ }
11281
+ if (this.pendingQuestions.get(sessionId)?.promptId !== prompt.promptId) {
11282
+ return { ok: false, code: "prompt_cancelled" };
11283
+ }
11284
+ try {
11285
+ this.ptyManager.sendKeys(sessionId, resolution.keys);
11286
+ } catch {
11287
+ return { ok: false, code: "provider_error" };
11288
+ }
11289
+ this.pendingQuestions.delete(sessionId);
11290
+ this.pendingQuestionKey.delete(sessionId);
11291
+ this.broadcastToSession(sessionId, {
11292
+ type: "question_cancelled",
11293
+ sessionId,
11294
+ toolUseId: pending.toolUseId
11295
+ });
11296
+ return { ok: true };
11297
+ };
11298
+ }
10561
11299
  /**
10562
11300
  * Answer a permission gate — the validated counterpart of POST /:id/input.
10563
11301
  *
@@ -10593,6 +11331,11 @@ var SessionHandlers = class {
10593
11331
  return;
10594
11332
  }
10595
11333
  const gateClosed = () => {
11334
+ const pending = this.pendingPermission.get(sessionId);
11335
+ const prompt = pending?.promptId ? this.promptRegistry.get(pending.promptId) : null;
11336
+ if (prompt?.state === "open" || prompt?.state === "updated") {
11337
+ this.promptRegistry.transition(prompt.promptId, "cancelled", "provider_closed");
11338
+ }
10596
11339
  this.pendingPermission.delete(sessionId);
10597
11340
  this.pendingPermissionKey.delete(sessionId);
10598
11341
  this.broadcastToSession(sessionId, { type: "permission_cancelled", sessionId });
@@ -10634,6 +11377,10 @@ var SessionHandlers = class {
10634
11377
  json(res, 400, { ok: false, reason: message });
10635
11378
  return;
10636
11379
  }
11380
+ const normalized = gate.promptId ? this.promptRegistry.get(gate.promptId) : null;
11381
+ if (normalized?.state === "open" || normalized?.state === "updated") {
11382
+ this.promptRegistry.transition(normalized.promptId, "resolved", "answered_legacy");
11383
+ }
10637
11384
  json(res, 200, { ok: true });
10638
11385
  }
10639
11386
  /**
@@ -10679,6 +11426,10 @@ var SessionHandlers = class {
10679
11426
  }
10680
11427
  const toolUseId = pending?.toolUseId ?? "";
10681
11428
  if (!await this.questionMenuStillOpen(sessionId)) {
11429
+ const prompt = this.promptRegistry.get(pending?.promptId ?? "");
11430
+ if (prompt?.state === "open" || prompt?.state === "updated") {
11431
+ this.promptRegistry.transition(prompt.promptId, "cancelled", "provider_closed");
11432
+ }
10682
11433
  this.pendingQuestions.delete(sessionId);
10683
11434
  this.pendingQuestionKey.delete(sessionId);
10684
11435
  this.broadcastToSession(sessionId, { type: "question_cancelled", sessionId, toolUseId });
@@ -10692,10 +11443,41 @@ var SessionHandlers = class {
10692
11443
  json(res, 400, { ok: false, reason: message });
10693
11444
  return;
10694
11445
  }
11446
+ const normalized = this.promptRegistry.get(pending?.promptId ?? "");
11447
+ if (normalized?.state === "open" || normalized?.state === "updated") {
11448
+ this.promptRegistry.transition(normalized.promptId, "resolved", "answered_legacy");
11449
+ }
10695
11450
  this.pendingQuestions.delete(sessionId);
10696
11451
  this.broadcastToSession(sessionId, { type: "question_cancelled", sessionId, toolUseId });
10697
11452
  json(res, 200, { ok: true });
10698
11453
  }
11454
+ /**
11455
+ * Answer a normalized prompt by its opaque ids.
11456
+ *
11457
+ * Refusals are keyed by `code` — the stable machine taxonomy of the prompt
11458
+ * contract. The released legacy routes (`/answer`, `/permission/answer`) key
11459
+ * theirs by `reason` and keep doing so; a client reads whichever key belongs
11460
+ * to the route it called, and the two vocabularies are not merged.
11461
+ *
11462
+ * Status follows the same split as the legacy routes: a malformed or
11463
+ * unanswerable *request* is 400, a prompt whose *state* refuses the answer is
11464
+ * 409. A retry after PROMPT_TERMINAL_RETENTION_MS answers 404
11465
+ * `prompt_not_found`, not the recorded outcome — the record it would replay
11466
+ * is gone by then.
11467
+ */
11468
+ async handlePromptAnswer(sessionId, req, res) {
11469
+ const parsed = PromptAnswerSchema.safeParse(await readBody2(req));
11470
+ if (!parsed.success) {
11471
+ json(res, 400, { ok: false, code: "invalid_prompt_answer" });
11472
+ return;
11473
+ }
11474
+ const outcome = await this.promptRegistry.answer(sessionId, parsed.data);
11475
+ if (outcome.ok) {
11476
+ json(res, 200, outcome);
11477
+ return;
11478
+ }
11479
+ json(res, promptAnswerStatus(outcome.code), outcome);
11480
+ }
10699
11481
  // Best-effort: a session we don't own a PTY for, or one that raced away
10700
11482
  // mid-read, is not ours to veto — say yes and let the write decide.
10701
11483
  async questionMenuStillOpen(sessionId) {
@@ -11338,7 +12120,7 @@ var ManagedSessionsRepository = class {
11338
12120
  };
11339
12121
 
11340
12122
  // src/db/repositories/projects.repository.ts
11341
- import { randomUUID as randomUUID5 } from "crypto";
12123
+ import { randomUUID as randomUUID6 } from "crypto";
11342
12124
  function rowToProject(row) {
11343
12125
  return {
11344
12126
  id: row.id,
@@ -11423,7 +12205,7 @@ var ProjectsRepository = class {
11423
12205
  });
11424
12206
  return rowToProject(this.getById.get(existing.id));
11425
12207
  }
11426
- const id = randomUUID5();
12208
+ const id = randomUUID6();
11427
12209
  this.insert.run({
11428
12210
  id,
11429
12211
  path,
@@ -12850,6 +13632,27 @@ function handleListProjects(url, res) {
12850
13632
  function wsAllows(principal, required) {
12851
13633
  return principal === null || hasCapability(principal, required);
12852
13634
  }
13635
+ function clearExpiredPendingPrompt(deps, prompt) {
13636
+ const permission = deps.pendingPermission.get(prompt.sessionId);
13637
+ if (permission?.promptId === prompt.promptId) {
13638
+ deps.pendingPermission.delete(prompt.sessionId);
13639
+ deps.pendingPermissionKey.delete(prompt.sessionId);
13640
+ deps.wsHub.broadcastToClients(deps.sessionSubscribers.get(prompt.sessionId) ?? [], {
13641
+ type: "permission_cancelled",
13642
+ sessionId: prompt.sessionId
13643
+ });
13644
+ }
13645
+ const question = deps.pendingQuestions.get(prompt.sessionId);
13646
+ if (question?.promptId === prompt.promptId) {
13647
+ deps.pendingQuestions.delete(prompt.sessionId);
13648
+ deps.pendingQuestionKey.delete(prompt.sessionId);
13649
+ deps.wsHub.broadcastToClients(deps.sessionSubscribers.get(prompt.sessionId) ?? [], {
13650
+ type: "question_cancelled",
13651
+ sessionId: prompt.sessionId,
13652
+ toolUseId: question.toolUseId
13653
+ });
13654
+ }
13655
+ }
12853
13656
  function createConversationWatcherEvents(deps) {
12854
13657
  return {
12855
13658
  onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
@@ -12972,11 +13775,11 @@ function createLiveSessionOptions(deps) {
12972
13775
  ts
12973
13776
  });
12974
13777
  },
12975
- onPermissionChange: (sessionId, gate) => {
12976
- deps.sessionHandlers().handlePermissionChange(sessionId, gate);
13778
+ onPermissionChange: (sessionId, gate, occurrenceId) => {
13779
+ deps.sessionHandlers().handlePermissionChange(sessionId, gate, occurrenceId);
12977
13780
  },
12978
- onLiveQuestion: (sessionId, questions) => {
12979
- deps.sessionHandlers().handleLiveQuestion(sessionId, questions);
13781
+ onLiveQuestion: (sessionId, questions, occurrenceId) => {
13782
+ deps.sessionHandlers().handleLiveQuestion(sessionId, questions, occurrenceId);
12980
13783
  },
12981
13784
  onLiveQuestionGone: (sessionId) => {
12982
13785
  deps.pendingQuestionKey.delete(sessionId);
@@ -13057,10 +13860,11 @@ function createLiveSessionOptions(deps) {
13057
13860
  if (filePath) {
13058
13861
  deps.fileWatcher.unwatch(filePath);
13059
13862
  deps.sessionFileMap.delete(session.id);
13060
- deps.cancelPendingQuestion(session.id);
13061
13863
  }
13864
+ deps.cancelPendingQuestion(session.id);
13062
13865
  deps.pendingPermission.delete(session.id);
13063
13866
  deps.pendingPermissionKey.delete(session.id);
13867
+ deps.promptRegistry.invalidateSession(session.id, "session_ended");
13064
13868
  deps.contendedSessions.delete(session.id);
13065
13869
  deps.rememberSelfPtyEnded(session.id);
13066
13870
  }
@@ -13115,6 +13919,7 @@ function createApiDeps(deps) {
13115
13919
  handleGetOutput: (id, res) => deps.sessionHandlers.handleGetOutput(id, res),
13116
13920
  handleSendInput: (id, req, res) => deps.sessionHandlers.handleSendInput(id, req, res),
13117
13921
  handleSendAnswer: (id, req, res) => deps.sessionHandlers.handleSendAnswer(id, req, res),
13922
+ handlePromptAnswer: (id, req, res) => deps.sessionHandlers.handlePromptAnswer(id, req, res),
13118
13923
  handlePermissionAnswer: (id, req, res) => deps.sessionHandlers.handlePermissionAnswer(id, req, res),
13119
13924
  handleCancel: (id, res) => deps.sessionHandlers.handleCancel(id, res),
13120
13925
  handleStopSession: (id, res) => deps.sessionHandlers.handleStopSession(id, res),
@@ -13173,6 +13978,9 @@ function createApiDeps(deps) {
13173
13978
  return;
13174
13979
  }
13175
13980
  deps.addSessionSubscriber(msg.sessionId, ws);
13981
+ if (deps.promptRegistry) {
13982
+ ws.send(JSON.stringify(deps.promptRegistry.snapshot(msg.sessionId)));
13983
+ }
13176
13984
  if (deps.ptyManager.hasSession(msg.sessionId)) {
13177
13985
  const lines = await deps.ptyManager.getOutputLines(msg.sessionId, REPLAY_MAX_LINES);
13178
13986
  const userMessages = deps.ptyManager.getInputHistory(msg.sessionId);
@@ -15943,6 +16751,7 @@ var StreamerServer = class {
15943
16751
  // repaint of the same gate doesn't re-broadcast on every tick. Cleared
15944
16752
  // alongside pendingPermission.
15945
16753
  pendingPermissionKey = /* @__PURE__ */ new Map();
16754
+ promptRegistry;
15946
16755
  // Scanner lifecycle, freshness state and the cache↔disk reconcile.
15947
16756
  scannerManager;
15948
16757
  // Binds a live session to the JSONL/rollout its provider writes.
@@ -16059,7 +16868,7 @@ var StreamerServer = class {
16059
16868
  runtimeStore = null;
16060
16869
  // Identifies this streamer run. A registry row carrying a different id is a
16061
16870
  // session that outlived the process that started it.
16062
- streamerInstanceId = randomUUID6();
16871
+ streamerInstanceId = randomUUID7();
16063
16872
  cacheMetadataRepo = null;
16064
16873
  // Push registration + delivery state (C7). Null when the cache DB failed to
16065
16874
  // open — registration then degrades to a no-op rather than 500ing.
@@ -16195,6 +17004,20 @@ var StreamerServer = class {
16195
17004
  this.browserCors = config.browserCors ?? loadBrowserCors();
16196
17005
  this.sessionStore = new SessionStore();
16197
17006
  this.wsHub = new WSHub();
17007
+ this.promptRegistry = new PromptRegistry({
17008
+ emit: (event) => this.wsHub.broadcastToClients(this.sessionSubscribers.get(event.sessionId) ?? [], event),
17009
+ onExpire: (prompt) => clearExpiredPendingPrompt(
17010
+ {
17011
+ pendingPermission: this.pendingPermission,
17012
+ pendingPermissionKey: this.pendingPermissionKey,
17013
+ pendingQuestions: this.pendingQuestions,
17014
+ pendingQuestionKey: this.pendingQuestionKey,
17015
+ sessionSubscribers: this.sessionSubscribers,
17016
+ wsHub: this.wsHub
17017
+ },
17018
+ prompt
17019
+ )
17020
+ });
16198
17021
  this.fileWatcher = new ConversationWatcher(
16199
17022
  createConversationWatcherEvents({
16200
17023
  sessionFileMap: this.sessionFileMap,
@@ -16238,6 +17061,7 @@ var StreamerServer = class {
16238
17061
  pendingQuestionKey: this.pendingQuestionKey,
16239
17062
  pendingPermission: this.pendingPermission,
16240
17063
  pendingPermissionKey: this.pendingPermissionKey,
17064
+ promptRegistry: this.promptRegistry,
16241
17065
  contendedSessions: this.contendedSessions,
16242
17066
  // Thunks, not values: sessionHandlers is constructed below, the
16243
17067
  // registry repo and the push notifiers are bound during listen(), and
@@ -16328,6 +17152,7 @@ var StreamerServer = class {
16328
17152
  sessionStatusBus: this.sessionStatusBus,
16329
17153
  sessionFileMap: this.sessionFileMap,
16330
17154
  pendingQuestions: this.pendingQuestions,
17155
+ promptRegistry: this.promptRegistry,
16331
17156
  pendingQuestionKey: this.pendingQuestionKey,
16332
17157
  pendingPermission: this.pendingPermission,
16333
17158
  pendingPermissionKey: this.pendingPermissionKey,
@@ -16428,6 +17253,7 @@ var StreamerServer = class {
16428
17253
  terminalSeq: this.terminalSeq,
16429
17254
  pendingPermission: this.pendingPermission,
16430
17255
  pendingQuestions: this.pendingQuestions,
17256
+ promptRegistry: this.promptRegistry,
16431
17257
  agentClient,
16432
17258
  conversationWriter,
16433
17259
  agentConfig
@@ -17208,6 +18034,7 @@ var StreamerServer = class {
17208
18034
  if (!this.ptyManager.isRemote()) this.ptyManager.dispose();
17209
18035
  this.fileWatcher.dispose();
17210
18036
  this.externalTails.clear();
18037
+ this.promptRegistry.dispose();
17211
18038
  this.wsHub.dispose();
17212
18039
  this.pairTokens.dispose();
17213
18040
  this.liveActivityRenewal?.stop();
@@ -17796,7 +18623,7 @@ var StreamerServer = class {
17796
18623
  for (const p of pending) {
17797
18624
  if (contended || foreignVsPty(p.questions)) continue;
17798
18625
  const origin = priorPtyKey !== null && questionContentKey(p.questions) === priorPtyKey ? "pty" : "jsonl";
17799
- this.pendingQuestions.set(sessionId, { ...p, origin });
18626
+ this.sessionHandlers.handleJsonlQuestion(sessionId, p.toolUseId, p.questions, origin);
17800
18627
  const t = setTimeout(() => {
17801
18628
  if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
17802
18629
  this.cancelPendingQuestion(sessionId);
@@ -17822,6 +18649,10 @@ var StreamerServer = class {
17822
18649
  if (!pq) return;
17823
18650
  this.pendingQuestions.delete(sessionId);
17824
18651
  this.pendingQuestionKey.delete(sessionId);
18652
+ const prompt = this.promptRegistry.get(pq.promptId);
18653
+ if (prompt?.state === "open" || prompt?.state === "updated") {
18654
+ this.promptRegistry.transition(pq.promptId, "cancelled", "provider_closed");
18655
+ }
17825
18656
  this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
17826
18657
  type: "question_cancelled",
17827
18658
  sessionId,
@@ -17976,7 +18807,13 @@ export {
17976
18807
  CODEX_CLI_PROVIDER,
17977
18808
  ConversationWatcher,
17978
18809
  LiveSessionManager,
18810
+ PROMPT_SCHEMA_VERSION,
17979
18811
  PTYManager,
18812
+ PromptAnswerSchema,
18813
+ PromptOptionSchema,
18814
+ PromptQuestionSchema,
18815
+ PromptResponseSchema,
18816
+ PromptSchema,
17980
18817
  SessionStore,
17981
18818
  StreamerServer,
17982
18819
  WSHub,