@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.cjs CHANGED
@@ -34,7 +34,13 @@ __export(index_exports, {
34
34
  CODEX_CLI_PROVIDER: () => CODEX_CLI_PROVIDER,
35
35
  ConversationWatcher: () => ConversationWatcher,
36
36
  LiveSessionManager: () => LiveSessionManager,
37
+ PROMPT_SCHEMA_VERSION: () => PROMPT_SCHEMA_VERSION,
37
38
  PTYManager: () => PTYManager,
39
+ PromptAnswerSchema: () => PromptAnswerSchema,
40
+ PromptOptionSchema: () => PromptOptionSchema,
41
+ PromptQuestionSchema: () => PromptQuestionSchema,
42
+ PromptResponseSchema: () => PromptResponseSchema,
43
+ PromptSchema: () => PromptSchema,
38
44
  SessionStore: () => SessionStore,
39
45
  StreamerServer: () => StreamerServer,
40
46
  WSHub: () => WSHub,
@@ -2222,7 +2228,7 @@ function toPublicSession(s) {
2222
2228
  }
2223
2229
 
2224
2230
  // src/pty-host/protocol.ts
2225
- var PTY_HOST_PROTOCOL_VERSION = 3;
2231
+ var PTY_HOST_PROTOCOL_VERSION = 4;
2226
2232
  function isHostEvent(message) {
2227
2233
  return "type" in message && message.type === "event";
2228
2234
  }
@@ -2310,8 +2316,12 @@ var RemoteSessionRunner = class _RemoteSessionRunner {
2310
2316
  }
2311
2317
  throw new PtyHostProtocolMismatchError(status.protocolVersion, PTY_HOST_PROTOCOL_VERSION);
2312
2318
  }
2313
- await runner.request({ type: "subscribe" });
2319
+ const subscribed = await runner.request({ type: "subscribe" });
2314
2320
  runner.refreshMirror(status);
2321
+ runner.restorePromptSnapshots({
2322
+ ...status,
2323
+ promptSnapshots: subscribed.promptSnapshots ?? status.promptSnapshots
2324
+ });
2315
2325
  return runner;
2316
2326
  }
2317
2327
  constructor(transport, options) {
@@ -2404,6 +2414,19 @@ var RemoteSessionRunner = class _RemoteSessionRunner {
2404
2414
  this.pids.set(session.id, entry.pid);
2405
2415
  }
2406
2416
  }
2417
+ restorePromptSnapshots(status) {
2418
+ for (const snapshot of status.promptSnapshots ?? []) {
2419
+ if (snapshot.kind === "permission") {
2420
+ this.options.onPermissionChange?.(snapshot.sessionId, snapshot.gate, snapshot.occurrenceId);
2421
+ } else {
2422
+ this.options.onLiveQuestion?.(
2423
+ snapshot.sessionId,
2424
+ snapshot.questions,
2425
+ snapshot.occurrenceId
2426
+ );
2427
+ }
2428
+ }
2429
+ }
2407
2430
  async heartbeat(state, timeoutMs = HOST_HEARTBEAT_REQUEST_TIMEOUT_MS) {
2408
2431
  await this.request({ type: "heartbeat", ...state }, timeoutMs);
2409
2432
  }
@@ -2458,13 +2481,13 @@ var RemoteSessionRunner = class _RemoteSessionRunner {
2458
2481
  break;
2459
2482
  }
2460
2483
  case "permission-change":
2461
- this.options.onPermissionChange?.(event.sessionId, event.gate);
2484
+ this.options.onPermissionChange?.(event.sessionId, event.gate, event.occurrenceId);
2462
2485
  break;
2463
2486
  case "phase-change":
2464
2487
  this.options.onPhaseChange?.(event.sessionId, event.phase);
2465
2488
  break;
2466
2489
  case "live-question":
2467
- this.options.onLiveQuestion?.(event.sessionId, event.questions);
2490
+ this.options.onLiveQuestion?.(event.sessionId, event.questions, event.occurrenceId);
2468
2491
  break;
2469
2492
  case "live-question-gone":
2470
2493
  this.options.onLiveQuestionGone?.(event.sessionId);
@@ -4139,6 +4162,130 @@ async function readGitBranch(dir) {
4139
4162
  }
4140
4163
  }
4141
4164
 
4165
+ // src/schemas/prompt.schema.ts
4166
+ var import_zod = require("zod");
4167
+ var PROMPT_SCHEMA_VERSION = 1;
4168
+ var OpaqueIdSchema = import_zod.z.string().trim().min(1).max(200);
4169
+ var MeaningfulStringSchema = import_zod.z.string().trim().min(1);
4170
+ var PromptOptionSchema = import_zod.z.object({
4171
+ optionId: OpaqueIdSchema,
4172
+ label: MeaningfulStringSchema,
4173
+ description: import_zod.z.string().optional(),
4174
+ preview: import_zod.z.string().optional()
4175
+ });
4176
+ var PromptQuestionSchema = import_zod.z.object({
4177
+ questionId: OpaqueIdSchema,
4178
+ text: MeaningfulStringSchema,
4179
+ header: import_zod.z.string().optional(),
4180
+ inputMode: import_zod.z.enum(["single", "multi", "text"]),
4181
+ options: import_zod.z.array(PromptOptionSchema),
4182
+ allowOther: import_zod.z.boolean(),
4183
+ secret: import_zod.z.union([import_zod.z.boolean(), import_zod.z.literal("unknown")])
4184
+ }).superRefine((question, ctx) => {
4185
+ const optionIds = question.options.map((option) => option.optionId);
4186
+ if (new Set(optionIds).size !== optionIds.length) {
4187
+ ctx.addIssue({
4188
+ code: "custom",
4189
+ message: "optionId values must be unique",
4190
+ path: ["options"]
4191
+ });
4192
+ }
4193
+ if (question.inputMode === "text" && question.options.length !== 0) {
4194
+ ctx.addIssue({
4195
+ code: "custom",
4196
+ message: "text questions cannot carry options",
4197
+ path: ["options"]
4198
+ });
4199
+ }
4200
+ if (question.inputMode !== "text" && question.options.length === 0) {
4201
+ ctx.addIssue({
4202
+ code: "custom",
4203
+ message: "select questions require options",
4204
+ path: ["options"]
4205
+ });
4206
+ }
4207
+ });
4208
+ var TERMINAL_PROMPT_STATES = /* @__PURE__ */ new Set(["resolved", "cancelled", "expired", "unavailable"]);
4209
+ var PromptSchema = import_zod.z.object({
4210
+ schemaVersion: import_zod.z.literal(PROMPT_SCHEMA_VERSION),
4211
+ sessionId: OpaqueIdSchema,
4212
+ promptId: OpaqueIdSchema,
4213
+ revision: import_zod.z.number().int().positive(),
4214
+ state: import_zod.z.enum(["open", "updated", "resolved", "cancelled", "expired", "unavailable"]),
4215
+ terminalReason: MeaningfulStringSchema.optional(),
4216
+ intent: import_zod.z.enum(["approval", "question"]),
4217
+ title: import_zod.z.string().optional(),
4218
+ message: import_zod.z.string().optional(),
4219
+ detail: import_zod.z.string().optional(),
4220
+ questions: import_zod.z.array(PromptQuestionSchema).min(1),
4221
+ answerRequirement: import_zod.z.enum(["blocking", "non_blocking", "unknown"]),
4222
+ expiresAt: import_zod.z.string().datetime({ offset: true }).nullable(),
4223
+ provenance: import_zod.z.object({
4224
+ source: import_zod.z.enum(["provider", "screen", "transcript", "synthetic"]),
4225
+ confidence: import_zod.z.enum(["authoritative", "inferred"])
4226
+ })
4227
+ }).superRefine((prompt, ctx) => {
4228
+ if (![prompt.title, prompt.message, prompt.detail].some((value) => value?.trim())) {
4229
+ ctx.addIssue({
4230
+ code: "custom",
4231
+ message: "prompt requires a meaningful title, message, or detail",
4232
+ path: ["message"]
4233
+ });
4234
+ }
4235
+ const questionIds = prompt.questions.map((question) => question.questionId);
4236
+ if (new Set(questionIds).size !== questionIds.length) {
4237
+ ctx.addIssue({
4238
+ code: "custom",
4239
+ message: "questionId values must be unique",
4240
+ path: ["questions"]
4241
+ });
4242
+ }
4243
+ const optionIds = prompt.questions.flatMap(
4244
+ (question) => question.options.map((option) => option.optionId)
4245
+ );
4246
+ if (new Set(optionIds).size !== optionIds.length) {
4247
+ ctx.addIssue({
4248
+ code: "custom",
4249
+ message: "optionId values must be unique within a prompt",
4250
+ path: ["questions"]
4251
+ });
4252
+ }
4253
+ const terminal = TERMINAL_PROMPT_STATES.has(prompt.state);
4254
+ if (terminal !== (prompt.terminalReason !== void 0)) {
4255
+ ctx.addIssue({
4256
+ code: "custom",
4257
+ message: terminal ? "terminal prompts require terminalReason" : "actionable prompts cannot carry terminalReason",
4258
+ path: ["terminalReason"]
4259
+ });
4260
+ }
4261
+ });
4262
+ var OptionResponseSchema = import_zod.z.object({
4263
+ questionId: OpaqueIdSchema,
4264
+ optionIds: import_zod.z.array(OpaqueIdSchema).min(1).refine((ids) => new Set(ids).size === ids.length, "optionIds must be unique"),
4265
+ text: import_zod.z.never().optional()
4266
+ });
4267
+ var TextResponseSchema = import_zod.z.object({
4268
+ questionId: OpaqueIdSchema,
4269
+ text: import_zod.z.string(),
4270
+ optionIds: import_zod.z.never().optional()
4271
+ });
4272
+ var PromptResponseSchema = import_zod.z.union([OptionResponseSchema, TextResponseSchema]);
4273
+ var PromptAnswerSchema = import_zod.z.object({
4274
+ promptId: OpaqueIdSchema,
4275
+ revision: import_zod.z.number().int().positive(),
4276
+ responses: import_zod.z.array(PromptResponseSchema).min(1),
4277
+ idempotencyKey: OpaqueIdSchema
4278
+ }).superRefine((answer, ctx) => {
4279
+ const questionIds = answer.responses.map((response) => response.questionId);
4280
+ if (new Set(questionIds).size !== questionIds.length) {
4281
+ ctx.addIssue({
4282
+ code: "custom",
4283
+ message: "each questionId can be answered only once",
4284
+ path: ["responses"]
4285
+ });
4286
+ }
4287
+ });
4288
+
4142
4289
  // src/server.ts
4143
4290
  var import_node_ws = require("@hono/node-ws");
4144
4291
  var import_client = require("@temporalio/client");
@@ -4778,11 +4925,11 @@ var createBrowseRoutes = (deps) => {
4778
4925
  var import_hono4 = require("hono");
4779
4926
 
4780
4927
  // src/schemas/cacheAlert.schema.ts
4781
- var import_zod = require("zod");
4782
- var ResolveCacheAlertSchema = import_zod.z.object({
4783
- fingerprint: import_zod.z.string(),
4784
- action: import_zod.z.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
4785
- ids: import_zod.z.array(import_zod.z.string()).optional()
4928
+ var import_zod2 = require("zod");
4929
+ var ResolveCacheAlertSchema = import_zod2.z.object({
4930
+ fingerprint: import_zod2.z.string(),
4931
+ action: import_zod2.z.enum(["prune_all", "prune_selected", "ignore", "reset_rescan"]),
4932
+ ids: import_zod2.z.array(import_zod2.z.string()).optional()
4786
4933
  }).refine((v) => v.action !== "prune_selected" || v.ids !== void 0 && v.ids.length > 0, {
4787
4934
  message: "prune_selected requires a non-empty ids array",
4788
4935
  path: ["ids"]
@@ -4838,12 +4985,12 @@ var createCacheAlertRoutes = (deps) => {
4838
4985
  var import_hono5 = require("hono");
4839
4986
 
4840
4987
  // src/schemas/claudeFlags.schema.ts
4841
- var import_zod2 = require("zod");
4842
- var ClaudeFlagsBodySchema = import_zod2.z.object({
4843
- values: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.union([import_zod2.z.string(), import_zod2.z.boolean(), import_zod2.z.array(import_zod2.z.string())])).default({}),
4988
+ var import_zod3 = require("zod");
4989
+ var ClaudeFlagsBodySchema = import_zod3.z.object({
4990
+ values: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.union([import_zod3.z.string(), import_zod3.z.boolean(), import_zod3.z.array(import_zod3.z.string())])).default({}),
4844
4991
  // A newline would corrupt the flat one-line-per-key server.yaml, so reject
4845
4992
  // it here with a field error instead of silently stripping it.
4846
- extraArgs: import_zod2.z.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
4993
+ extraArgs: import_zod3.z.string().refine((v) => !/[\r\n]/.test(v), "extraArgs must not contain newlines").optional()
4847
4994
  }).strict();
4848
4995
 
4849
4996
  // src/api/routes/config.routes.ts
@@ -5377,15 +5524,15 @@ var import_node_path5 = require("path");
5377
5524
  var import_yaml = require("yaml");
5378
5525
 
5379
5526
  // src/schemas/updateConfig.schema.ts
5380
- var import_zod3 = require("zod");
5381
- var UpdateConfigSchema = import_zod3.z.object({
5382
- auto_update: import_zod3.z.boolean().default(false),
5383
- channel: import_zod3.z.enum(["stable", "next"]).default("stable"),
5384
- allow: import_zod3.z.array(import_zod3.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
5385
- poll_interval_minutes: import_zod3.z.number().int().min(0).default(1440),
5386
- defer_if_active_sessions: import_zod3.z.boolean().default(true),
5387
- github_repo: import_zod3.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
5388
- webhook_secret: import_zod3.z.string().min(1).nullable().default(null)
5527
+ var import_zod4 = require("zod");
5528
+ var UpdateConfigSchema = import_zod4.z.object({
5529
+ auto_update: import_zod4.z.boolean().default(false),
5530
+ channel: import_zod4.z.enum(["stable", "next"]).default("stable"),
5531
+ allow: import_zod4.z.array(import_zod4.z.enum(["patch", "minor", "major"])).default(["patch", "minor"]),
5532
+ poll_interval_minutes: import_zod4.z.number().int().min(0).default(1440),
5533
+ defer_if_active_sessions: import_zod4.z.boolean().default(true),
5534
+ github_repo: import_zod4.z.string().regex(/^[^/]+\/[^/]+$/, "github_repo must be 'owner/name'"),
5535
+ webhook_secret: import_zod4.z.string().min(1).nullable().default(null)
5389
5536
  }).strict();
5390
5537
 
5391
5538
  // src/config/update-config.ts
@@ -6010,7 +6157,13 @@ var createMiscRoutes = (deps) => {
6010
6157
  // the box is starved. Additive capability flag only — live readings stay
6011
6158
  // off this polled endpoint. Absent means an older server that never
6012
6159
  // samples. Informational: pressure never holds, kills, or refuses sessions.
6013
- hostPressure: true
6160
+ hostPressure: true,
6161
+ // Provider-neutral prompt contract: normalized prompt events, opaque ids
6162
+ // and the atomic /prompt/answer route. A prompt_snapshot on subscribe
6163
+ // carries RETAINED prompts, terminal ones included — render on `state`,
6164
+ // not on presence — and an answer retry after that retention window is
6165
+ // answered 404 prompt_not_found rather than the recorded outcome.
6166
+ promptContract: { schemaVersion: 1, atomicAnswer: true }
6014
6167
  });
6015
6168
  });
6016
6169
  app.get("/api/profiles", (c) => c.json([]));
@@ -6379,6 +6532,10 @@ var createSessionRoutes = (deps) => {
6379
6532
  await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6380
6533
  return alreadyHandled6();
6381
6534
  });
6535
+ app.post("/:id/prompt/answer", async (c) => {
6536
+ await deps.handlePromptAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6537
+ return alreadyHandled6();
6538
+ });
6382
6539
  app.post("/:id/permission/answer", async (c) => {
6383
6540
  await deps.handlePermissionAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6384
6541
  return alreadyHandled6();
@@ -9584,6 +9741,396 @@ async function recordUpload(pool2, instanceId, row) {
9584
9741
  );
9585
9742
  }
9586
9743
 
9744
+ // src/services/prompts/promptRegistry.ts
9745
+ var import_node_crypto5 = require("crypto");
9746
+ var PROMPT_TERMINAL_RETENTION_MS = 10 * 60 * 1e3;
9747
+ var PROMPT_MAX_RECORDS_PER_SESSION = 200;
9748
+ var MAX_TIMEOUT_MS = 2147483647;
9749
+ function copyPrompt(prompt) {
9750
+ return {
9751
+ ...prompt,
9752
+ questions: prompt.questions.map((question) => ({
9753
+ ...question,
9754
+ options: question.options.map((option) => ({ ...option }))
9755
+ })),
9756
+ provenance: { ...prompt.provenance }
9757
+ };
9758
+ }
9759
+ function terminalError(state) {
9760
+ switch (state) {
9761
+ case "resolved":
9762
+ return "already_resolved";
9763
+ case "expired":
9764
+ return "prompt_expired";
9765
+ case "cancelled":
9766
+ return "prompt_cancelled";
9767
+ case "unavailable":
9768
+ return "prompt_unavailable";
9769
+ }
9770
+ }
9771
+ var PromptRegistry = class {
9772
+ bySession = /* @__PURE__ */ new Map();
9773
+ byId = /* @__PURE__ */ new Map();
9774
+ sequences = /* @__PURE__ */ new Map();
9775
+ createId;
9776
+ emit;
9777
+ onExpire;
9778
+ now;
9779
+ terminalRetentionMs;
9780
+ maxRecordsPerSession;
9781
+ constructor(options = {}) {
9782
+ this.createId = options.createId ?? import_node_crypto5.randomUUID;
9783
+ this.emit = options.emit;
9784
+ this.onExpire = options.onExpire;
9785
+ this.now = options.now ?? Date.now;
9786
+ this.terminalRetentionMs = options.terminalRetentionMs ?? PROMPT_TERMINAL_RETENTION_MS;
9787
+ this.maxRecordsPerSession = options.maxRecordsPerSession ?? PROMPT_MAX_RECORDS_PER_SESSION;
9788
+ }
9789
+ open(draft, adapter, promptId = this.createId()) {
9790
+ this.sweepExpired(draft.sessionId);
9791
+ const held = this.byId.get(promptId);
9792
+ if (held && (held.prompt.state === "open" || held.prompt.state === "updated")) {
9793
+ throw new Error(`Prompt id already exists: ${promptId}`);
9794
+ }
9795
+ const id = held ? this.createId() : promptId;
9796
+ const prompt = PromptSchema.parse({
9797
+ ...draft,
9798
+ schemaVersion: PROMPT_SCHEMA_VERSION,
9799
+ promptId: id,
9800
+ revision: 1,
9801
+ state: "open",
9802
+ questions: draft.questions.map((question) => ({
9803
+ ...question,
9804
+ questionId: this.createId(),
9805
+ options: question.options.map((option) => ({ ...option, optionId: this.createId() }))
9806
+ })),
9807
+ provenance: { ...draft.provenance }
9808
+ });
9809
+ const entry = {
9810
+ prompt,
9811
+ adapter,
9812
+ queue: Promise.resolve(),
9813
+ inFlight: /* @__PURE__ */ new Map(),
9814
+ outcomes: /* @__PURE__ */ new Map()
9815
+ };
9816
+ const session = this.bySession.get(prompt.sessionId) ?? /* @__PURE__ */ new Map();
9817
+ session.set(prompt.promptId, entry);
9818
+ this.bySession.set(prompt.sessionId, session);
9819
+ this.byId.set(prompt.promptId, entry);
9820
+ this.publish(entry);
9821
+ this.scheduleExpiration(entry);
9822
+ this.enforceCap(prompt.sessionId);
9823
+ return copyPrompt(prompt);
9824
+ }
9825
+ update(promptId, draft, adapter) {
9826
+ const entry = this.requireEntry(promptId);
9827
+ this.expireIfDue(entry, this.now());
9828
+ if (entry.prompt.sessionId !== draft.sessionId) throw new Error("Prompt session cannot change");
9829
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") {
9830
+ throw new Error(`Cannot update terminal prompt ${promptId}`);
9831
+ }
9832
+ if (entry.prompt.questions.length !== draft.questions.length) {
9833
+ throw new Error("Prompt question cardinality cannot change during an update");
9834
+ }
9835
+ const questions = draft.questions.map((question, questionIndex) => {
9836
+ const prior = entry.prompt.questions[questionIndex];
9837
+ if (prior.options.length !== question.options.length) {
9838
+ throw new Error("Prompt option cardinality cannot change during an update");
9839
+ }
9840
+ return {
9841
+ ...question,
9842
+ questionId: prior.questionId,
9843
+ options: question.options.map((option, optionIndex) => ({
9844
+ ...option,
9845
+ optionId: prior.options[optionIndex].optionId
9846
+ }))
9847
+ };
9848
+ });
9849
+ const prompt = PromptSchema.parse({
9850
+ ...draft,
9851
+ schemaVersion: PROMPT_SCHEMA_VERSION,
9852
+ promptId,
9853
+ revision: entry.prompt.revision + 1,
9854
+ state: "updated",
9855
+ questions,
9856
+ provenance: { ...draft.provenance }
9857
+ });
9858
+ entry.prompt = prompt;
9859
+ if (adapter) entry.adapter = adapter;
9860
+ this.publish(entry);
9861
+ this.scheduleExpiration(entry);
9862
+ return copyPrompt(entry.prompt);
9863
+ }
9864
+ transition(promptId, state, reason) {
9865
+ const entry = this.requireEntry(promptId);
9866
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") {
9867
+ throw new Error(`Cannot transition terminal prompt ${promptId}`);
9868
+ }
9869
+ entry.prompt = {
9870
+ ...entry.prompt,
9871
+ revision: entry.prompt.revision + 1,
9872
+ state,
9873
+ terminalReason: reason
9874
+ };
9875
+ entry.terminalAt = this.now();
9876
+ this.clearExpiration(entry);
9877
+ this.publish(entry);
9878
+ return copyPrompt(entry.prompt);
9879
+ }
9880
+ invalidateSession(sessionId, reason = "session_ended") {
9881
+ const transitioned = [];
9882
+ for (const entry of this.bySession.get(sessionId)?.values() ?? []) {
9883
+ if (entry.prompt.state === "open" || entry.prompt.state === "updated") {
9884
+ transitioned.push(this.transition(entry.prompt.promptId, "unavailable", reason));
9885
+ }
9886
+ }
9887
+ return transitioned;
9888
+ }
9889
+ get(promptId) {
9890
+ const entry = this.byId.get(promptId);
9891
+ if (!entry) return null;
9892
+ this.sweepExpired(entry.prompt.sessionId);
9893
+ return this.byId.has(promptId) ? copyPrompt(entry.prompt) : null;
9894
+ }
9895
+ hasActionable(sessionId) {
9896
+ this.sweepExpired(sessionId);
9897
+ return [...this.bySession.get(sessionId)?.values() ?? []].some(
9898
+ (entry) => entry.prompt.state === "open" || entry.prompt.state === "updated"
9899
+ );
9900
+ }
9901
+ snapshot(sessionId) {
9902
+ this.sweepExpired(sessionId);
9903
+ return {
9904
+ type: "prompt_snapshot",
9905
+ schemaVersion: PROMPT_SCHEMA_VERSION,
9906
+ sessionId,
9907
+ sequence: this.sequences.get(sessionId) ?? 0,
9908
+ prompts: [...this.bySession.get(sessionId)?.values() ?? []].map(
9909
+ (entry) => copyPrompt(entry.prompt)
9910
+ )
9911
+ };
9912
+ }
9913
+ dispose() {
9914
+ for (const entry of this.byId.values()) this.clearExpiration(entry);
9915
+ }
9916
+ answer(sessionId, answer) {
9917
+ this.sweepExpired(sessionId);
9918
+ const entry = this.byId.get(answer.promptId);
9919
+ if (!entry || entry.prompt.sessionId !== sessionId) {
9920
+ return Promise.resolve({ ok: false, code: "prompt_not_found" });
9921
+ }
9922
+ this.pruneOutcomes(entry);
9923
+ const recorded = entry.outcomes.get(answer.idempotencyKey);
9924
+ if (recorded) return Promise.resolve(recorded.outcome);
9925
+ const pending = entry.inFlight.get(answer.idempotencyKey);
9926
+ if (pending) return pending;
9927
+ const task = entry.queue.then(() => this.performAnswer(entry, answer));
9928
+ entry.queue = task.then(
9929
+ () => void 0,
9930
+ () => void 0
9931
+ );
9932
+ entry.inFlight.set(answer.idempotencyKey, task);
9933
+ void task.then((outcome) => {
9934
+ entry.inFlight.delete(answer.idempotencyKey);
9935
+ entry.outcomes.set(answer.idempotencyKey, { at: this.now(), outcome });
9936
+ });
9937
+ return task;
9938
+ }
9939
+ async performAnswer(entry, answer) {
9940
+ const prompt = entry.prompt;
9941
+ if (prompt.state !== "open" && prompt.state !== "updated") {
9942
+ return { ok: false, code: terminalError(prompt.state) };
9943
+ }
9944
+ if (this.expireIfDue(entry, this.now())) {
9945
+ return { ok: false, code: "prompt_expired" };
9946
+ }
9947
+ if (prompt.revision !== answer.revision) {
9948
+ return {
9949
+ ok: false,
9950
+ code: "prompt_revision_mismatch",
9951
+ currentRevision: prompt.revision
9952
+ };
9953
+ }
9954
+ const responseError = this.validateResponses(prompt, answer);
9955
+ if (responseError) return { ok: false, code: responseError };
9956
+ if (!entry.adapter) return { ok: false, code: "prompt_unavailable" };
9957
+ let adapterResult;
9958
+ try {
9959
+ adapterResult = await entry.adapter({ prompt: copyPrompt(prompt), answer });
9960
+ } catch {
9961
+ return { ok: false, code: "provider_error" };
9962
+ }
9963
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") {
9964
+ return { ok: false, code: terminalError(entry.prompt.state) };
9965
+ }
9966
+ if (entry.prompt.revision !== answer.revision) {
9967
+ return {
9968
+ ok: false,
9969
+ code: "prompt_revision_mismatch",
9970
+ currentRevision: entry.prompt.revision
9971
+ };
9972
+ }
9973
+ if (!adapterResult.ok) {
9974
+ if (adapterResult.terminal) {
9975
+ this.transition(
9976
+ prompt.promptId,
9977
+ adapterResult.terminal.state,
9978
+ adapterResult.terminal.reason
9979
+ );
9980
+ }
9981
+ return { ok: false, code: adapterResult.code };
9982
+ }
9983
+ return { ok: true, prompt: this.transition(prompt.promptId, "resolved", "answered") };
9984
+ }
9985
+ validateResponses(prompt, answer) {
9986
+ const questions = new Map(prompt.questions.map((question) => [question.questionId, question]));
9987
+ for (const response of answer.responses) {
9988
+ if (!questions.has(response.questionId)) return "unknown_question";
9989
+ }
9990
+ if (answer.responses.length !== prompt.questions.length) return "incomplete_answer";
9991
+ const responses = new Map(answer.responses.map((response) => [response.questionId, response]));
9992
+ for (const question of prompt.questions) {
9993
+ const response = responses.get(question.questionId);
9994
+ if (!response) return "incomplete_answer";
9995
+ if (question.inputMode === "text") {
9996
+ if (typeof response.text !== "string") return "incomplete_answer";
9997
+ continue;
9998
+ }
9999
+ const optionIds = response.optionIds;
10000
+ if (!optionIds) return "incomplete_answer";
10001
+ if (question.inputMode === "single" && optionIds.length !== 1) {
10002
+ return "unsupported_prompt_shape";
10003
+ }
10004
+ const known = new Set(question.options.map((option) => option.optionId));
10005
+ if (optionIds.some((optionId) => !known.has(optionId))) return "unknown_option";
10006
+ }
10007
+ return null;
10008
+ }
10009
+ publish(entry) {
10010
+ const sessionId = entry.prompt.sessionId;
10011
+ const sequence = (this.sequences.get(sessionId) ?? 0) + 1;
10012
+ this.sequences.set(sessionId, sequence);
10013
+ this.emit?.({
10014
+ type: "prompt_event",
10015
+ sessionId,
10016
+ sequence,
10017
+ prompt: copyPrompt(entry.prompt)
10018
+ });
10019
+ }
10020
+ scheduleExpiration(entry) {
10021
+ this.clearExpiration(entry);
10022
+ const expiresAt = entry.prompt.expiresAt;
10023
+ if (expiresAt === null) return;
10024
+ const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, Date.parse(expiresAt) - this.now()));
10025
+ entry.expiryTimer = setTimeout(() => {
10026
+ entry.expiryTimer = void 0;
10027
+ if (!this.expireIfDue(entry, this.now())) this.scheduleExpiration(entry);
10028
+ }, delay);
10029
+ entry.expiryTimer.unref?.();
10030
+ }
10031
+ clearExpiration(entry) {
10032
+ if (entry.expiryTimer) clearTimeout(entry.expiryTimer);
10033
+ entry.expiryTimer = void 0;
10034
+ }
10035
+ expireIfDue(entry, now) {
10036
+ if (entry.prompt.state !== "open" && entry.prompt.state !== "updated") return false;
10037
+ if (entry.prompt.expiresAt === null || now < Date.parse(entry.prompt.expiresAt)) return false;
10038
+ const expired = this.transition(entry.prompt.promptId, "expired", "deadline_elapsed");
10039
+ this.onExpire?.(expired);
10040
+ return true;
10041
+ }
10042
+ requireEntry(promptId) {
10043
+ const entry = this.byId.get(promptId);
10044
+ if (!entry) throw new Error(`Unknown prompt: ${promptId}`);
10045
+ return entry;
10046
+ }
10047
+ sweepExpired(sessionId) {
10048
+ const now = this.now();
10049
+ const session = this.bySession.get(sessionId);
10050
+ if (!session) return;
10051
+ for (const [promptId, entry] of session) {
10052
+ this.expireIfDue(entry, now);
10053
+ if (entry.terminalAt !== void 0 && now - entry.terminalAt > this.terminalRetentionMs) {
10054
+ this.clearExpiration(entry);
10055
+ session.delete(promptId);
10056
+ this.byId.delete(promptId);
10057
+ }
10058
+ }
10059
+ if (session.size === 0) this.bySession.delete(sessionId);
10060
+ }
10061
+ enforceCap(sessionId) {
10062
+ const session = this.bySession.get(sessionId);
10063
+ if (!session || session.size <= this.maxRecordsPerSession) return;
10064
+ const terminal = [...session.entries()].filter(([, entry]) => entry.terminalAt !== void 0).sort((a, b) => (a[1].terminalAt ?? 0) - (b[1].terminalAt ?? 0));
10065
+ while (session.size > this.maxRecordsPerSession && terminal.length > 0) {
10066
+ const [promptId, entry] = terminal.shift();
10067
+ this.clearExpiration(entry);
10068
+ session.delete(promptId);
10069
+ this.byId.delete(promptId);
10070
+ }
10071
+ }
10072
+ pruneOutcomes(entry) {
10073
+ const now = this.now();
10074
+ for (const [key, recorded] of entry.outcomes) {
10075
+ if (now - recorded.at > this.terminalRetentionMs) entry.outcomes.delete(key);
10076
+ }
10077
+ }
10078
+ };
10079
+
10080
+ // src/services/prompts/ptyPromptAdapter.ts
10081
+ function permissionPromptDraft(sessionId, gate) {
10082
+ if (!gate) throw new Error("Cannot normalize an absent permission gate");
10083
+ const message = gate.prompt?.trim() || "Approval required";
10084
+ return {
10085
+ sessionId,
10086
+ intent: "approval",
10087
+ title: "Approval",
10088
+ message,
10089
+ ...gate.detail?.trim() ? { detail: gate.detail } : {},
10090
+ questions: [
10091
+ {
10092
+ text: message,
10093
+ header: "Approval",
10094
+ inputMode: "single",
10095
+ options: gate.options.map((option) => ({ label: option.label })),
10096
+ allowOther: false,
10097
+ secret: "unknown"
10098
+ }
10099
+ ],
10100
+ answerRequirement: "unknown",
10101
+ expiresAt: null,
10102
+ provenance: { source: "screen", confidence: "inferred" }
10103
+ };
10104
+ }
10105
+ function questionPromptDraft(sessionId, questions, source) {
10106
+ const first = questions[0];
10107
+ if (!first) throw new Error("Cannot normalize an empty question list");
10108
+ return {
10109
+ sessionId,
10110
+ intent: "question",
10111
+ ...first.header.trim() ? { title: first.header } : {},
10112
+ message: first.question,
10113
+ questions: questions.map((question) => ({
10114
+ text: question.question,
10115
+ ...question.header.trim() ? { header: question.header } : {},
10116
+ inputMode: question.multiSelect ? "multi" : "single",
10117
+ options: question.options.map((option) => ({
10118
+ label: option.label,
10119
+ ...option.description ? { description: option.description } : {},
10120
+ ...option.preview ? { preview: option.preview } : {}
10121
+ })),
10122
+ allowOther: false,
10123
+ secret: "unknown"
10124
+ })),
10125
+ answerRequirement: "unknown",
10126
+ expiresAt: null,
10127
+ provenance: {
10128
+ source,
10129
+ confidence: source === "transcript" ? "authoritative" : "inferred"
10130
+ }
10131
+ };
10132
+ }
10133
+
9587
10134
  // src/services/questions/parseStatusLine.ts
9588
10135
  var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
9589
10136
  var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
@@ -9946,6 +10493,21 @@ function codexSessionActiveBody(outcome) {
9946
10493
  ...outcome.ownerSource != null && { ownerSource: outcome.ownerSource }
9947
10494
  };
9948
10495
  }
10496
+ function promptAnswerStatus(code) {
10497
+ switch (code) {
10498
+ case "prompt_not_found":
10499
+ return 404;
10500
+ case "provider_error":
10501
+ return 502;
10502
+ case "unknown_question":
10503
+ case "unknown_option":
10504
+ case "incomplete_answer":
10505
+ case "unsupported_prompt_shape":
10506
+ return 400;
10507
+ default:
10508
+ return 409;
10509
+ }
10510
+ }
9949
10511
  var SessionHandlers = class {
9950
10512
  constructor(deps) {
9951
10513
  this.deps = deps;
@@ -9984,6 +10546,10 @@ var SessionHandlers = class {
9984
10546
  get pendingQuestions() {
9985
10547
  return this.deps.pendingQuestions;
9986
10548
  }
10549
+ get promptRegistry() {
10550
+ if (!this.deps.promptRegistry) this.deps.promptRegistry = new PromptRegistry();
10551
+ return this.deps.promptRegistry;
10552
+ }
9987
10553
  get pendingQuestionKey() {
9988
10554
  return this.deps.pendingQuestionKey;
9989
10555
  }
@@ -10502,18 +11068,26 @@ var SessionHandlers = class {
10502
11068
  json(res, 400, { error: "Missing input field" });
10503
11069
  return;
10504
11070
  }
11071
+ this.promptRegistry.sweepExpired(sessionId);
10505
11072
  const openPrompt = this.pendingPermission.has(sessionId) ? "permission" : this.pendingQuestions.has(sessionId) ? "question" : null;
10506
11073
  if (openPrompt) {
10507
- this.log.info(`[input.prompt_pending] ${sessionId.slice(0, 8)} kind=${openPrompt}`, {
10508
- event: "input.prompt_pending",
10509
- sessionId,
10510
- promptKind: openPrompt
10511
- });
11074
+ const pendingGate = openPrompt === "permission" ? this.pendingPermission.get(sessionId) : void 0;
11075
+ const promptState = pendingGate?.promptId !== void 0 && this.promptRegistry.get(pendingGate.promptId)?.state === "resolved" ? "answered" : "open";
11076
+ this.log.info(
11077
+ `[input.prompt_pending] ${sessionId.slice(0, 8)} kind=${openPrompt} state=${promptState}`,
11078
+ {
11079
+ event: "input.prompt_pending",
11080
+ sessionId,
11081
+ promptKind: openPrompt,
11082
+ promptState
11083
+ }
11084
+ );
10512
11085
  json(res, 409, {
10513
11086
  ok: false,
10514
11087
  reason: "prompt_pending",
10515
11088
  promptKind: openPrompt,
10516
- error: "A prompt is waiting for an answer; answer or dismiss it before sending text"
11089
+ promptState,
11090
+ 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"
10517
11091
  });
10518
11092
  return;
10519
11093
  }
@@ -10557,30 +11131,99 @@ var SessionHandlers = class {
10557
11131
  // the later JSONL flush of the same question is de-duped. We synthesize a
10558
11132
  // screen-scoped toolUseId; the JSONL path overwrites pendingQuestions with the
10559
11133
  // real toolUseId when it lands, so answering works once JSONL catches up.
10560
- handleLiveQuestion(sessionId, questions) {
11134
+ handleLiveQuestion(sessionId, questions, occurrenceId) {
10561
11135
  const key = questionContentKey(questions);
10562
11136
  if (this.pendingQuestionKey.get(sessionId) === key) return;
10563
11137
  const toolUseId = `screen:${sessionId}:${key.length}`;
10564
- this.pendingQuestions.set(sessionId, { toolUseId, questions, origin: "pty" });
11138
+ const prior = this.pendingQuestions.get(sessionId);
11139
+ const priorPrompt = prior ? this.promptRegistry.get(prior.promptId) : null;
11140
+ if (priorPrompt?.state === "open" || priorPrompt?.state === "updated") {
11141
+ this.promptRegistry.transition(priorPrompt.promptId, "cancelled", "replaced");
11142
+ }
11143
+ const prompt = this.promptRegistry.open(
11144
+ questionPromptDraft(sessionId, questions, "screen"),
11145
+ this.questionAnswerAdapter(sessionId),
11146
+ occurrenceId
11147
+ );
11148
+ this.pendingQuestions.set(sessionId, {
11149
+ toolUseId,
11150
+ questions,
11151
+ origin: "pty",
11152
+ promptId: prompt.promptId
11153
+ });
10565
11154
  this.pendingQuestionKey.set(sessionId, key);
10566
11155
  this.broadcastToSession(sessionId, { type: "question", sessionId, toolUseId, questions });
10567
11156
  }
11157
+ handleJsonlQuestion(sessionId, toolUseId, questions, origin) {
11158
+ const prior = this.pendingQuestions.get(sessionId);
11159
+ const sameQuestion = prior !== void 0 && questionContentKey(prior.questions) === questionContentKey(questions);
11160
+ let prompt;
11161
+ if (sameQuestion) {
11162
+ const current = this.promptRegistry.get(prior.promptId);
11163
+ prompt = current?.provenance.source === "transcript" ? current : this.promptRegistry.update(
11164
+ prior.promptId,
11165
+ questionPromptDraft(sessionId, questions, "transcript"),
11166
+ this.questionAnswerAdapter(sessionId)
11167
+ );
11168
+ } else {
11169
+ const priorPrompt = prior ? this.promptRegistry.get(prior.promptId) : null;
11170
+ if (priorPrompt?.state === "open" || priorPrompt?.state === "updated") {
11171
+ this.promptRegistry.transition(priorPrompt.promptId, "cancelled", "replaced");
11172
+ }
11173
+ prompt = this.promptRegistry.open(
11174
+ questionPromptDraft(sessionId, questions, "transcript"),
11175
+ this.questionAnswerAdapter(sessionId)
11176
+ );
11177
+ }
11178
+ this.pendingQuestions.set(sessionId, {
11179
+ toolUseId,
11180
+ questions,
11181
+ origin,
11182
+ promptId: prompt.promptId
11183
+ });
11184
+ }
10568
11185
  // Permission gate opened/closed (OSC 777 + scraped options). Broadcasts the
10569
11186
  // additive `permission` / `permission_cancelled` events. Mobile answers by
10570
11187
  // sending the chosen option index via /input { keys } (e.g. "2\r").
10571
- handlePermissionChange(sessionId, gate) {
11188
+ handlePermissionChange(sessionId, gate, occurrenceId) {
10572
11189
  if (gate === null) {
10573
- if (!this.pendingPermission.has(sessionId)) return;
11190
+ const prior2 = this.pendingPermission.get(sessionId);
11191
+ if (!prior2) return;
11192
+ const prompt2 = prior2.promptId ? this.promptRegistry.get(prior2.promptId) : null;
11193
+ if (prompt2?.state === "open" || prompt2?.state === "updated") {
11194
+ this.promptRegistry.transition(prompt2.promptId, "cancelled", "provider_closed");
11195
+ }
10574
11196
  this.pendingPermission.delete(sessionId);
10575
11197
  this.pendingPermissionKey.delete(sessionId);
10576
11198
  this.broadcastToSession(sessionId, { type: "permission_cancelled", sessionId });
10577
11199
  return;
10578
11200
  }
10579
11201
  const key = permissionContentKey(gate);
10580
- if (this.pendingPermissionKey.get(sessionId) === key) return;
10581
11202
  const prior = this.pendingPermission.get(sessionId);
10582
- const gateId = prior && permissionGateKey(prior) === permissionGateKey(gate) ? prior.gateId : (0, import_crypto9.randomUUID)();
10583
- this.pendingPermission.set(sessionId, { ...gate, gateId });
11203
+ const priorPromptId = prior?.promptId;
11204
+ if (this.pendingPermissionKey.get(sessionId) === key && (occurrenceId === void 0 || prior?.occurrenceId === occurrenceId)) {
11205
+ return;
11206
+ }
11207
+ const samePrompt = prior && priorPromptId !== void 0 && permissionGateKey(prior) === permissionGateKey(gate) && (occurrenceId === void 0 || prior.occurrenceId === occurrenceId);
11208
+ if (prior && !samePrompt) {
11209
+ const priorPrompt = prior.promptId ? this.promptRegistry.get(prior.promptId) : null;
11210
+ if (priorPrompt?.state === "open" || priorPrompt?.state === "updated") {
11211
+ this.promptRegistry.transition(priorPrompt.promptId, "cancelled", "replaced");
11212
+ }
11213
+ }
11214
+ const prompt = gate.options.length === 0 ? null : samePrompt ? this.promptRegistry.get(priorPromptId) : this.promptRegistry.open(
11215
+ permissionPromptDraft(sessionId, gate),
11216
+ this.permissionAnswerAdapter(sessionId),
11217
+ occurrenceId
11218
+ );
11219
+ if (samePrompt && !prompt) throw new Error("Pending permission prompt disappeared");
11220
+ const gateId = prompt?.promptId ?? occurrenceId ?? prior?.gateId ?? (0, import_crypto9.randomUUID)();
11221
+ this.pendingPermission.set(sessionId, {
11222
+ ...gate,
11223
+ gateId,
11224
+ ...prompt ? { promptId: prompt.promptId } : {},
11225
+ ...occurrenceId !== void 0 ? { occurrenceId } : {}
11226
+ });
10584
11227
  this.pendingPermissionKey.set(sessionId, key);
10585
11228
  const subscriberCount = this.sessionSubscribers.get(sessionId)?.size ?? 0;
10586
11229
  this.log.info(
@@ -10598,6 +11241,107 @@ var SessionHandlers = class {
10598
11241
  gateId
10599
11242
  });
10600
11243
  }
11244
+ permissionAnswerAdapter(sessionId) {
11245
+ return async ({ prompt, answer }) => {
11246
+ const gate = this.pendingPermission.get(sessionId);
11247
+ if (!gate || gate.promptId !== prompt.promptId) {
11248
+ return {
11249
+ ok: false,
11250
+ code: "prompt_unavailable",
11251
+ terminal: { state: "unavailable", reason: "provider_prompt_missing" }
11252
+ };
11253
+ }
11254
+ const response = answer.responses[0];
11255
+ const selectedId = response?.optionIds?.[0];
11256
+ const selectedIndex = prompt.questions[0]?.options.findIndex(
11257
+ (option2) => option2.optionId === selectedId
11258
+ );
11259
+ if (selectedIndex === void 0 || selectedIndex < 0) {
11260
+ return { ok: false, code: "unknown_option" };
11261
+ }
11262
+ const option = gate.options[selectedIndex];
11263
+ if (!option) return { ok: false, code: "unknown_option" };
11264
+ const provider = this.sessionStore.getManaged(sessionId)?.provider;
11265
+ if (provider !== CODEX_CLI_PROVIDER && !await this.permissionGateStillOpen(sessionId, permissionGateKey(gate))) {
11266
+ return {
11267
+ ok: false,
11268
+ code: "prompt_cancelled",
11269
+ terminal: { state: "cancelled", reason: "provider_closed" }
11270
+ };
11271
+ }
11272
+ if (this.pendingPermission.get(sessionId)?.promptId !== prompt.promptId) {
11273
+ return { ok: false, code: "prompt_cancelled" };
11274
+ }
11275
+ try {
11276
+ this.ptyManager.sendKeys(
11277
+ sessionId,
11278
+ option.answerKeys ?? permissionAnswerKeys(option.index)
11279
+ );
11280
+ } catch {
11281
+ return { ok: false, code: "provider_error" };
11282
+ }
11283
+ return { ok: true };
11284
+ };
11285
+ }
11286
+ questionAnswerAdapter(sessionId) {
11287
+ return async ({ prompt, answer }) => {
11288
+ const pending = this.pendingQuestions.get(sessionId);
11289
+ if (!pending || pending.promptId !== prompt.promptId) {
11290
+ return {
11291
+ ok: false,
11292
+ code: "prompt_unavailable",
11293
+ terminal: { state: "unavailable", reason: "provider_prompt_missing" }
11294
+ };
11295
+ }
11296
+ const answers = {};
11297
+ for (const question of prompt.questions) {
11298
+ const response = answer.responses.find((item) => item.questionId === question.questionId);
11299
+ if (!response?.optionIds) return { ok: false, code: "unsupported_prompt_shape" };
11300
+ answers[question.text] = response.optionIds.map((optionId) => {
11301
+ const option = question.options.find((item) => item.optionId === optionId);
11302
+ return option?.label ?? "";
11303
+ });
11304
+ }
11305
+ const resolution = resolveAnswer(pending, {
11306
+ toolUseId: pending.toolUseId,
11307
+ answers
11308
+ });
11309
+ if (!resolution.ok) {
11310
+ const code = resolution.reason === "unknown_option" || resolution.reason === "incomplete_answer" || resolution.reason === "unsupported_prompt_shape" ? resolution.reason : "prompt_unavailable";
11311
+ return { ok: false, code };
11312
+ }
11313
+ if (!await this.questionMenuStillOpen(sessionId)) {
11314
+ this.pendingQuestions.delete(sessionId);
11315
+ this.pendingQuestionKey.delete(sessionId);
11316
+ this.broadcastToSession(sessionId, {
11317
+ type: "question_cancelled",
11318
+ sessionId,
11319
+ toolUseId: pending.toolUseId
11320
+ });
11321
+ return {
11322
+ ok: false,
11323
+ code: "prompt_cancelled",
11324
+ terminal: { state: "cancelled", reason: "provider_closed" }
11325
+ };
11326
+ }
11327
+ if (this.pendingQuestions.get(sessionId)?.promptId !== prompt.promptId) {
11328
+ return { ok: false, code: "prompt_cancelled" };
11329
+ }
11330
+ try {
11331
+ this.ptyManager.sendKeys(sessionId, resolution.keys);
11332
+ } catch {
11333
+ return { ok: false, code: "provider_error" };
11334
+ }
11335
+ this.pendingQuestions.delete(sessionId);
11336
+ this.pendingQuestionKey.delete(sessionId);
11337
+ this.broadcastToSession(sessionId, {
11338
+ type: "question_cancelled",
11339
+ sessionId,
11340
+ toolUseId: pending.toolUseId
11341
+ });
11342
+ return { ok: true };
11343
+ };
11344
+ }
10601
11345
  /**
10602
11346
  * Answer a permission gate — the validated counterpart of POST /:id/input.
10603
11347
  *
@@ -10633,6 +11377,11 @@ var SessionHandlers = class {
10633
11377
  return;
10634
11378
  }
10635
11379
  const gateClosed = () => {
11380
+ const pending = this.pendingPermission.get(sessionId);
11381
+ const prompt = pending?.promptId ? this.promptRegistry.get(pending.promptId) : null;
11382
+ if (prompt?.state === "open" || prompt?.state === "updated") {
11383
+ this.promptRegistry.transition(prompt.promptId, "cancelled", "provider_closed");
11384
+ }
10636
11385
  this.pendingPermission.delete(sessionId);
10637
11386
  this.pendingPermissionKey.delete(sessionId);
10638
11387
  this.broadcastToSession(sessionId, { type: "permission_cancelled", sessionId });
@@ -10674,6 +11423,10 @@ var SessionHandlers = class {
10674
11423
  json(res, 400, { ok: false, reason: message });
10675
11424
  return;
10676
11425
  }
11426
+ const normalized = gate.promptId ? this.promptRegistry.get(gate.promptId) : null;
11427
+ if (normalized?.state === "open" || normalized?.state === "updated") {
11428
+ this.promptRegistry.transition(normalized.promptId, "resolved", "answered_legacy");
11429
+ }
10677
11430
  json(res, 200, { ok: true });
10678
11431
  }
10679
11432
  /**
@@ -10719,6 +11472,10 @@ var SessionHandlers = class {
10719
11472
  }
10720
11473
  const toolUseId = pending?.toolUseId ?? "";
10721
11474
  if (!await this.questionMenuStillOpen(sessionId)) {
11475
+ const prompt = this.promptRegistry.get(pending?.promptId ?? "");
11476
+ if (prompt?.state === "open" || prompt?.state === "updated") {
11477
+ this.promptRegistry.transition(prompt.promptId, "cancelled", "provider_closed");
11478
+ }
10722
11479
  this.pendingQuestions.delete(sessionId);
10723
11480
  this.pendingQuestionKey.delete(sessionId);
10724
11481
  this.broadcastToSession(sessionId, { type: "question_cancelled", sessionId, toolUseId });
@@ -10732,10 +11489,41 @@ var SessionHandlers = class {
10732
11489
  json(res, 400, { ok: false, reason: message });
10733
11490
  return;
10734
11491
  }
11492
+ const normalized = this.promptRegistry.get(pending?.promptId ?? "");
11493
+ if (normalized?.state === "open" || normalized?.state === "updated") {
11494
+ this.promptRegistry.transition(normalized.promptId, "resolved", "answered_legacy");
11495
+ }
10735
11496
  this.pendingQuestions.delete(sessionId);
10736
11497
  this.broadcastToSession(sessionId, { type: "question_cancelled", sessionId, toolUseId });
10737
11498
  json(res, 200, { ok: true });
10738
11499
  }
11500
+ /**
11501
+ * Answer a normalized prompt by its opaque ids.
11502
+ *
11503
+ * Refusals are keyed by `code` — the stable machine taxonomy of the prompt
11504
+ * contract. The released legacy routes (`/answer`, `/permission/answer`) key
11505
+ * theirs by `reason` and keep doing so; a client reads whichever key belongs
11506
+ * to the route it called, and the two vocabularies are not merged.
11507
+ *
11508
+ * Status follows the same split as the legacy routes: a malformed or
11509
+ * unanswerable *request* is 400, a prompt whose *state* refuses the answer is
11510
+ * 409. A retry after PROMPT_TERMINAL_RETENTION_MS answers 404
11511
+ * `prompt_not_found`, not the recorded outcome — the record it would replay
11512
+ * is gone by then.
11513
+ */
11514
+ async handlePromptAnswer(sessionId, req, res) {
11515
+ const parsed = PromptAnswerSchema.safeParse(await readBody2(req));
11516
+ if (!parsed.success) {
11517
+ json(res, 400, { ok: false, code: "invalid_prompt_answer" });
11518
+ return;
11519
+ }
11520
+ const outcome = await this.promptRegistry.answer(sessionId, parsed.data);
11521
+ if (outcome.ok) {
11522
+ json(res, 200, outcome);
11523
+ return;
11524
+ }
11525
+ json(res, promptAnswerStatus(outcome.code), outcome);
11526
+ }
10739
11527
  // Best-effort: a session we don't own a PTY for, or one that raced away
10740
11528
  // mid-read, is not ours to veto — say yes and let the write decide.
10741
11529
  async questionMenuStillOpen(sessionId) {
@@ -12879,6 +13667,27 @@ function handleListProjects(url, res) {
12879
13667
  function wsAllows(principal, required) {
12880
13668
  return principal === null || hasCapability(principal, required);
12881
13669
  }
13670
+ function clearExpiredPendingPrompt(deps, prompt) {
13671
+ const permission = deps.pendingPermission.get(prompt.sessionId);
13672
+ if (permission?.promptId === prompt.promptId) {
13673
+ deps.pendingPermission.delete(prompt.sessionId);
13674
+ deps.pendingPermissionKey.delete(prompt.sessionId);
13675
+ deps.wsHub.broadcastToClients(deps.sessionSubscribers.get(prompt.sessionId) ?? [], {
13676
+ type: "permission_cancelled",
13677
+ sessionId: prompt.sessionId
13678
+ });
13679
+ }
13680
+ const question = deps.pendingQuestions.get(prompt.sessionId);
13681
+ if (question?.promptId === prompt.promptId) {
13682
+ deps.pendingQuestions.delete(prompt.sessionId);
13683
+ deps.pendingQuestionKey.delete(prompt.sessionId);
13684
+ deps.wsHub.broadcastToClients(deps.sessionSubscribers.get(prompt.sessionId) ?? [], {
13685
+ type: "question_cancelled",
13686
+ sessionId: prompt.sessionId,
13687
+ toolUseId: question.toolUseId
13688
+ });
13689
+ }
13690
+ }
12882
13691
  function createConversationWatcherEvents(deps) {
12883
13692
  return {
12884
13693
  onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
@@ -13001,11 +13810,11 @@ function createLiveSessionOptions(deps) {
13001
13810
  ts
13002
13811
  });
13003
13812
  },
13004
- onPermissionChange: (sessionId, gate) => {
13005
- deps.sessionHandlers().handlePermissionChange(sessionId, gate);
13813
+ onPermissionChange: (sessionId, gate, occurrenceId) => {
13814
+ deps.sessionHandlers().handlePermissionChange(sessionId, gate, occurrenceId);
13006
13815
  },
13007
- onLiveQuestion: (sessionId, questions) => {
13008
- deps.sessionHandlers().handleLiveQuestion(sessionId, questions);
13816
+ onLiveQuestion: (sessionId, questions, occurrenceId) => {
13817
+ deps.sessionHandlers().handleLiveQuestion(sessionId, questions, occurrenceId);
13009
13818
  },
13010
13819
  onLiveQuestionGone: (sessionId) => {
13011
13820
  deps.pendingQuestionKey.delete(sessionId);
@@ -13086,10 +13895,11 @@ function createLiveSessionOptions(deps) {
13086
13895
  if (filePath) {
13087
13896
  deps.fileWatcher.unwatch(filePath);
13088
13897
  deps.sessionFileMap.delete(session.id);
13089
- deps.cancelPendingQuestion(session.id);
13090
13898
  }
13899
+ deps.cancelPendingQuestion(session.id);
13091
13900
  deps.pendingPermission.delete(session.id);
13092
13901
  deps.pendingPermissionKey.delete(session.id);
13902
+ deps.promptRegistry.invalidateSession(session.id, "session_ended");
13093
13903
  deps.contendedSessions.delete(session.id);
13094
13904
  deps.rememberSelfPtyEnded(session.id);
13095
13905
  }
@@ -13144,6 +13954,7 @@ function createApiDeps(deps) {
13144
13954
  handleGetOutput: (id, res) => deps.sessionHandlers.handleGetOutput(id, res),
13145
13955
  handleSendInput: (id, req, res) => deps.sessionHandlers.handleSendInput(id, req, res),
13146
13956
  handleSendAnswer: (id, req, res) => deps.sessionHandlers.handleSendAnswer(id, req, res),
13957
+ handlePromptAnswer: (id, req, res) => deps.sessionHandlers.handlePromptAnswer(id, req, res),
13147
13958
  handlePermissionAnswer: (id, req, res) => deps.sessionHandlers.handlePermissionAnswer(id, req, res),
13148
13959
  handleCancel: (id, res) => deps.sessionHandlers.handleCancel(id, res),
13149
13960
  handleStopSession: (id, res) => deps.sessionHandlers.handleStopSession(id, res),
@@ -13202,6 +14013,9 @@ function createApiDeps(deps) {
13202
14013
  return;
13203
14014
  }
13204
14015
  deps.addSessionSubscriber(msg.sessionId, ws);
14016
+ if (deps.promptRegistry) {
14017
+ ws.send(JSON.stringify(deps.promptRegistry.snapshot(msg.sessionId)));
14018
+ }
13205
14019
  if (deps.ptyManager.hasSession(msg.sessionId)) {
13206
14020
  const lines = await deps.ptyManager.getOutputLines(msg.sessionId, REPLAY_MAX_LINES);
13207
14021
  const userMessages = deps.ptyManager.getInputHistory(msg.sessionId);
@@ -15972,6 +16786,7 @@ var StreamerServer = class {
15972
16786
  // repaint of the same gate doesn't re-broadcast on every tick. Cleared
15973
16787
  // alongside pendingPermission.
15974
16788
  pendingPermissionKey = /* @__PURE__ */ new Map();
16789
+ promptRegistry;
15975
16790
  // Scanner lifecycle, freshness state and the cache↔disk reconcile.
15976
16791
  scannerManager;
15977
16792
  // Binds a live session to the JSONL/rollout its provider writes.
@@ -16224,6 +17039,20 @@ var StreamerServer = class {
16224
17039
  this.browserCors = config.browserCors ?? loadBrowserCors();
16225
17040
  this.sessionStore = new SessionStore();
16226
17041
  this.wsHub = new WSHub();
17042
+ this.promptRegistry = new PromptRegistry({
17043
+ emit: (event) => this.wsHub.broadcastToClients(this.sessionSubscribers.get(event.sessionId) ?? [], event),
17044
+ onExpire: (prompt) => clearExpiredPendingPrompt(
17045
+ {
17046
+ pendingPermission: this.pendingPermission,
17047
+ pendingPermissionKey: this.pendingPermissionKey,
17048
+ pendingQuestions: this.pendingQuestions,
17049
+ pendingQuestionKey: this.pendingQuestionKey,
17050
+ sessionSubscribers: this.sessionSubscribers,
17051
+ wsHub: this.wsHub
17052
+ },
17053
+ prompt
17054
+ )
17055
+ });
16227
17056
  this.fileWatcher = new ConversationWatcher(
16228
17057
  createConversationWatcherEvents({
16229
17058
  sessionFileMap: this.sessionFileMap,
@@ -16267,6 +17096,7 @@ var StreamerServer = class {
16267
17096
  pendingQuestionKey: this.pendingQuestionKey,
16268
17097
  pendingPermission: this.pendingPermission,
16269
17098
  pendingPermissionKey: this.pendingPermissionKey,
17099
+ promptRegistry: this.promptRegistry,
16270
17100
  contendedSessions: this.contendedSessions,
16271
17101
  // Thunks, not values: sessionHandlers is constructed below, the
16272
17102
  // registry repo and the push notifiers are bound during listen(), and
@@ -16357,6 +17187,7 @@ var StreamerServer = class {
16357
17187
  sessionStatusBus: this.sessionStatusBus,
16358
17188
  sessionFileMap: this.sessionFileMap,
16359
17189
  pendingQuestions: this.pendingQuestions,
17190
+ promptRegistry: this.promptRegistry,
16360
17191
  pendingQuestionKey: this.pendingQuestionKey,
16361
17192
  pendingPermission: this.pendingPermission,
16362
17193
  pendingPermissionKey: this.pendingPermissionKey,
@@ -16457,6 +17288,7 @@ var StreamerServer = class {
16457
17288
  terminalSeq: this.terminalSeq,
16458
17289
  pendingPermission: this.pendingPermission,
16459
17290
  pendingQuestions: this.pendingQuestions,
17291
+ promptRegistry: this.promptRegistry,
16460
17292
  agentClient,
16461
17293
  conversationWriter,
16462
17294
  agentConfig
@@ -17237,6 +18069,7 @@ var StreamerServer = class {
17237
18069
  if (!this.ptyManager.isRemote()) this.ptyManager.dispose();
17238
18070
  this.fileWatcher.dispose();
17239
18071
  this.externalTails.clear();
18072
+ this.promptRegistry.dispose();
17240
18073
  this.wsHub.dispose();
17241
18074
  this.pairTokens.dispose();
17242
18075
  this.liveActivityRenewal?.stop();
@@ -17825,7 +18658,7 @@ var StreamerServer = class {
17825
18658
  for (const p of pending) {
17826
18659
  if (contended || foreignVsPty(p.questions)) continue;
17827
18660
  const origin = priorPtyKey !== null && questionContentKey(p.questions) === priorPtyKey ? "pty" : "jsonl";
17828
- this.pendingQuestions.set(sessionId, { ...p, origin });
18661
+ this.sessionHandlers.handleJsonlQuestion(sessionId, p.toolUseId, p.questions, origin);
17829
18662
  const t = setTimeout(() => {
17830
18663
  if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
17831
18664
  this.cancelPendingQuestion(sessionId);
@@ -17851,6 +18684,10 @@ var StreamerServer = class {
17851
18684
  if (!pq) return;
17852
18685
  this.pendingQuestions.delete(sessionId);
17853
18686
  this.pendingQuestionKey.delete(sessionId);
18687
+ const prompt = this.promptRegistry.get(pq.promptId);
18688
+ if (prompt?.state === "open" || prompt?.state === "updated") {
18689
+ this.promptRegistry.transition(pq.promptId, "cancelled", "provider_closed");
18690
+ }
17854
18691
  this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
17855
18692
  type: "question_cancelled",
17856
18693
  sessionId,
@@ -18006,7 +18843,13 @@ function parseDirScanDebounceEnv(raw) {
18006
18843
  CODEX_CLI_PROVIDER,
18007
18844
  ConversationWatcher,
18008
18845
  LiveSessionManager,
18846
+ PROMPT_SCHEMA_VERSION,
18009
18847
  PTYManager,
18848
+ PromptAnswerSchema,
18849
+ PromptOptionSchema,
18850
+ PromptQuestionSchema,
18851
+ PromptResponseSchema,
18852
+ PromptSchema,
18010
18853
  SessionStore,
18011
18854
  StreamerServer,
18012
18855
  WSHub,