@jpcasabiancai/cayenne 0.5.6 → 0.5.7

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.
Files changed (2) hide show
  1. package/dist/cli.js +454 -180
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -31,7 +31,7 @@ var init_cloud = __esm({
31
31
  CLOUD_SUPABASE_ANON_KEY = assertPublishable(
32
32
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5kcXJrcXh3a3p4Z25oa3ZjdW5yIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODg3MTMyNzYsImV4cCI6MjEwNDI4OTI3Nn0.Dlsxw91E1QYZ4eoRDjP80Rf4feY77Z-T-fiIwUDY2k0"
33
33
  );
34
- CLOUD_APP_ORIGIN = "https://cayenne-pink.vercel.app";
34
+ CLOUD_APP_ORIGIN = "https://app.cayenne.sh";
35
35
  }
36
36
  });
37
37
 
@@ -2729,13 +2729,16 @@ var init_rpc = __esm({
2729
2729
  }
2730
2730
  }
2731
2731
  dispatch(line) {
2732
- let frame;
2732
+ let parsed;
2733
2733
  try {
2734
- frame = JSON.parse(line);
2734
+ parsed = JSON.parse(line);
2735
2735
  } catch {
2736
2736
  return;
2737
2737
  }
2738
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
2739
+ const frame = parsed;
2738
2740
  if (frame.id !== void 0 && frame.method === void 0) {
2741
+ if (typeof frame.id !== "number") return;
2739
2742
  const p = this.pending.get(frame.id);
2740
2743
  if (!p) return;
2741
2744
  this.pending.delete(frame.id);
@@ -2748,6 +2751,7 @@ var init_rpc = __esm({
2748
2751
  void (async () => {
2749
2752
  try {
2750
2753
  const result = await this.request_?.({
2754
+ id,
2751
2755
  method: frame.method,
2752
2756
  params: frame.params
2753
2757
  });
@@ -2755,7 +2759,11 @@ var init_rpc = __esm({
2755
2759
  } catch (err) {
2756
2760
  this.write({
2757
2761
  id,
2758
- error: { code: -32603, message: err instanceof Error ? err.message : String(err) }
2762
+ error: {
2763
+ // A handler that knows the JSON-RPC code for its refusal says so; else "internal".
2764
+ code: err instanceof RpcError ? err.code : -32603,
2765
+ message: err instanceof Error ? err.message : String(err)
2766
+ }
2759
2767
  });
2760
2768
  }
2761
2769
  })();
@@ -2770,6 +2778,17 @@ var init_rpc = __esm({
2770
2778
  // src/codex/detect.ts
2771
2779
  import { execFile as execFile2, spawn } from "child_process";
2772
2780
  import { promisify } from "util";
2781
+ async function allModels(request) {
2782
+ const models = [];
2783
+ let cursor;
2784
+ do {
2785
+ const page = await request(cursor ? { cursor } : {});
2786
+ const data = page.data ?? [];
2787
+ models.push(...data);
2788
+ cursor = data.length > 0 && page.nextCursor ? page.nextCursor : void 0;
2789
+ } while (cursor && models.length < MAX_MODELS);
2790
+ return models;
2791
+ }
2773
2792
  function findCodexOnPath(env) {
2774
2793
  return findOnPath("codex", env.PATH);
2775
2794
  }
@@ -2798,8 +2817,7 @@ async function listCodexModels(path) {
2798
2817
  clientInfo: { name: "cayenne-runner", title: "Cayenne", version: "0.0.1" }
2799
2818
  });
2800
2819
  rpc.notify("initialized", {});
2801
- const res = await rpc.request("model/list", {});
2802
- return res.data ?? [];
2820
+ return await allModels((params) => rpc.request("model/list", params));
2803
2821
  } finally {
2804
2822
  clearTimeout(timer);
2805
2823
  rpc.close();
@@ -2807,7 +2825,7 @@ async function listCodexModels(path) {
2807
2825
  }
2808
2826
  }
2809
2827
  function toProviderModel(m) {
2810
- const efforts = (m.supportedReasoningEfforts ?? []).map((e) => e.effort).filter((e) => Effort.options.includes(e));
2828
+ const efforts = (m.supportedReasoningEfforts ?? []).map((e) => e.reasoningEffort).filter((e) => Effort.options.includes(e));
2811
2829
  const fallback = Effort.options.includes(m.defaultReasoningEffort) ? m.defaultReasoningEffort : void 0;
2812
2830
  return {
2813
2831
  id: (m.id ?? "").slice(0, MAX_NAME),
@@ -7160,10 +7178,10 @@ async function syncDocuments(deps, pass) {
7160
7178
  if (error2) throw new Error(error2.message);
7161
7179
  const row = data;
7162
7180
  if (!row?.id) throw new Error("the insert returned no row");
7163
- const failed4 = await publish2(row, doc);
7164
- if (failed4 !== null) {
7181
+ const failed5 = await publish2(row, doc);
7182
+ if (failed5 !== null) {
7165
7183
  log.warn(
7166
- { err: failed4, relPath: doc.relPath, sessionId: deps.sessionId },
7184
+ { err: failed5, relPath: doc.relPath, sessionId: deps.sessionId },
7167
7185
  "could not upload a document; removing the row so the next pass retries it"
7168
7186
  );
7169
7187
  await takeBack(row.id, doc.relPath);
@@ -7176,10 +7194,10 @@ async function syncDocuments(deps, pass) {
7176
7194
  const restated = before.status !== "present" || before.title !== doc.title || before.kind !== doc.kind || before.size_bytes !== doc.size;
7177
7195
  if (!rewritten && !restated) continue;
7178
7196
  if (rewritten) {
7179
- const failed4 = await upload(before.storage_path, doc);
7180
- if (failed4 !== null) {
7197
+ const failed5 = await upload(before.storage_path, doc);
7198
+ if (failed5 !== null) {
7181
7199
  log.warn(
7182
- { err: failed4, relPath: doc.relPath, sessionId: deps.sessionId },
7200
+ { err: failed5, relPath: doc.relPath, sessionId: deps.sessionId },
7183
7201
  "could not overwrite a document; its row still describes the bytes in the bucket"
7184
7202
  );
7185
7203
  continue;
@@ -8047,15 +8065,37 @@ ${this.stderrTail.join("\n")}` : "";
8047
8065
  });
8048
8066
 
8049
8067
  // src/codex/normalize.ts
8068
+ function moveTarget(kind) {
8069
+ const named = [kind.move_path, kind.movePath].filter((target) => target != null);
8070
+ if (named.length === 0) return void 0;
8071
+ const [first] = named;
8072
+ return typeof first === "string" && named.every((target) => target === first) ? first : "";
8073
+ }
8074
+ function fileChanges(item) {
8075
+ if (!Array.isArray(item.changes)) return [];
8076
+ return item.changes.map((entry) => {
8077
+ const change = isRecord(entry) ? entry : {};
8078
+ const kind = isRecord(change.kind) ? change.kind : {};
8079
+ const movePath = moveTarget(kind);
8080
+ return {
8081
+ path: typeof change.path === "string" ? change.path : "",
8082
+ ...typeof kind.type === "string" ? { kind: kind.type } : {},
8083
+ ...typeof change.diff === "string" ? { diff: change.diff } : {},
8084
+ ...movePath === void 0 ? {} : { movePath }
8085
+ };
8086
+ });
8087
+ }
8050
8088
  function normalizeCodexItem(item, phase) {
8051
8089
  const base = { providerUuid: item.id, parentToolUseId: null, raw: item };
8052
- if (item.type === "agent_message") {
8090
+ if (item.type === "agentMessage") {
8053
8091
  if (phase !== "completed") return null;
8054
8092
  return { role: "assistant", content: [{ type: "text", text: item.text ?? "" }], ...base };
8055
8093
  }
8056
8094
  if (item.type === "reasoning") {
8057
8095
  if (phase !== "completed") return null;
8058
- return { role: "assistant", content: [{ type: "thinking", text: item.text ?? "" }], ...base };
8096
+ const text = (Array.isArray(item.summary) ? item.summary : []).join("\n\n");
8097
+ if (!text) return null;
8098
+ return { role: "assistant", content: [{ type: "thinking", text }], ...base };
8059
8099
  }
8060
8100
  const name = toolName(item);
8061
8101
  if (!name) return null;
@@ -8063,12 +8103,11 @@ function normalizeCodexItem(item, phase) {
8063
8103
  const block2 = { type: "tool_use", id: item.id, name, input: toolInput(item) };
8064
8104
  return { role: "assistant", content: [block2], ...base };
8065
8105
  }
8066
- const isError2 = item.status === "failed" || item.exit_code !== void 0 && item.exit_code !== 0;
8067
8106
  const block = {
8068
8107
  type: "tool_result",
8069
8108
  toolUseId: item.id,
8070
8109
  content: resultText(item),
8071
- isError: isError2
8110
+ isError: failed4(item)
8072
8111
  };
8073
8112
  return { role: "tool", content: [block], ...base };
8074
8113
  }
@@ -8089,20 +8128,21 @@ function deltaEvents(_itemId, kind, text, first) {
8089
8128
  function stopEvents() {
8090
8129
  return [{ type: "content_block_stop", index: 0 }, { type: "message_stop" }];
8091
8130
  }
8092
- var toolName, toolInput, resultText;
8131
+ var isRecord, toolName, toolInput, resultText, failed4;
8093
8132
  var init_normalize2 = __esm({
8094
8133
  "src/codex/normalize.ts"() {
8095
8134
  "use strict";
8096
8135
  init_src();
8136
+ isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
8097
8137
  toolName = (item) => {
8098
8138
  switch (item.type) {
8099
- case "command_execution":
8139
+ case "commandExecution":
8100
8140
  return CODEX_TOOL_NAMES.command;
8101
- case "file_change":
8141
+ case "fileChange":
8102
8142
  return CODEX_TOOL_NAMES.fileChange;
8103
- case "web_search":
8143
+ case "webSearch":
8104
8144
  return CODEX_TOOL_NAMES.webSearch;
8105
- case "mcp_tool_call":
8145
+ case "mcpToolCall":
8106
8146
  return `codex.mcp.${(item.server ?? "server").replace(/[^A-Za-z0-9_.-]/g, "")}.${(item.tool ?? "tool").replace(/[^A-Za-z0-9_.-]/g, "")}`.slice(
8107
8147
  0,
8108
8148
  64
@@ -8113,21 +8153,25 @@ var init_normalize2 = __esm({
8113
8153
  };
8114
8154
  toolInput = (item) => {
8115
8155
  switch (item.type) {
8116
- case "command_execution":
8156
+ case "commandExecution":
8117
8157
  return { command: item.command ?? "", ...item.cwd ? { cwd: item.cwd } : {} };
8118
- case "file_change":
8119
- return { changes: item.changes ?? [] };
8120
- case "web_search":
8158
+ case "fileChange":
8159
+ return { changes: fileChanges(item) };
8160
+ case "webSearch":
8121
8161
  return { query: item.query ?? "" };
8122
8162
  default:
8123
- return item.arguments ?? {};
8163
+ if (isRecord(item.arguments)) return item.arguments;
8164
+ return item.arguments === void 0 ? {} : { arguments: item.arguments };
8124
8165
  }
8125
8166
  };
8126
8167
  resultText = (item) => {
8127
- if (item.type === "command_execution") return item.aggregated_output ?? "";
8128
- if (item.type === "file_change") return JSON.stringify(item.changes ?? []);
8168
+ if (item.type === "commandExecution") return item.aggregatedOutput ?? "";
8169
+ if (item.type === "fileChange") return JSON.stringify(fileChanges(item));
8170
+ if (item.type === "webSearch") return JSON.stringify(item.action ?? null);
8171
+ if (item.error?.message) return item.error.message;
8129
8172
  return typeof item.result === "string" ? item.result : JSON.stringify(item.result ?? null);
8130
8173
  };
8174
+ failed4 = (item) => item.status === "failed" || item.status === "declined" || Boolean(item.error) || typeof item.exitCode === "number" && item.exitCode !== 0;
8131
8175
  }
8132
8176
  });
8133
8177
 
@@ -8142,6 +8186,9 @@ function sandboxFor(mode, cwd) {
8142
8186
  return { type: "workspaceWrite", writableRoots: [cwd], networkAccess: false };
8143
8187
  }
8144
8188
  }
8189
+ function sandboxMode(policy) {
8190
+ return SANDBOX_MODES[policy.type];
8191
+ }
8145
8192
  function autoDecide(mode, kind) {
8146
8193
  if (mode === "bypassPermissions") return "accept";
8147
8194
  if (mode === "dontAsk") return "decline";
@@ -8149,9 +8196,15 @@ function autoDecide(mode, kind) {
8149
8196
  if (mode === "acceptEdits" && kind === "file_change") return "accept";
8150
8197
  return "ask";
8151
8198
  }
8199
+ var SANDBOX_MODES;
8152
8200
  var init_policy = __esm({
8153
8201
  "src/codex/policy.ts"() {
8154
8202
  "use strict";
8203
+ SANDBOX_MODES = {
8204
+ readOnly: "read-only",
8205
+ workspaceWrite: "workspace-write",
8206
+ dangerFullAccess: "danger-full-access"
8207
+ };
8155
8208
  }
8156
8209
  });
8157
8210
 
@@ -8161,6 +8214,7 @@ import { randomUUID as randomUUID4 } from "crypto";
8161
8214
  import { EventEmitter as EventEmitter2 } from "events";
8162
8215
  import { chmodSync as chmodSync5, lstatSync as lstatSync7, mkdirSync as mkdirSync8, rmdirSync, rmSync as rmSync8, writeFileSync as writeFileSync6 } from "fs";
8163
8216
  import { dirname as dirname8, join as join22 } from "path";
8217
+ import { z as z18 } from "zod";
8164
8218
  function newStop() {
8165
8219
  let resolve6;
8166
8220
  let reject;
@@ -8174,10 +8228,35 @@ function newStop() {
8174
8228
  function removeFiles(files) {
8175
8229
  for (const file of files) rmSync8(file, { force: true });
8176
8230
  }
8231
+ function readQuestions(params) {
8232
+ const asked = AskedQuestions.safeParse(params);
8233
+ if (!asked.success) return null;
8234
+ const { questions } = asked.data;
8235
+ const ids = questions.map((q) => q.id);
8236
+ if (new Set(ids).size !== ids.length || questions.some((q) => q.isSecret)) return null;
8237
+ const input = CodexQuestions.safeParse({
8238
+ questions: questions.map((q) => ({
8239
+ text: q.question,
8240
+ ...q.options?.length ? { options: q.options.map((o) => o.label) } : {}
8241
+ }))
8242
+ });
8243
+ return input.success ? { ids, input: input.data } : null;
8244
+ }
8177
8245
  function scrubSecrets(text) {
8178
8246
  return SECRET_PATTERNS.reduce((acc, [pattern, to]) => acc.replace(pattern, to), text);
8179
8247
  }
8180
- var STDERR_TAIL_MAX, STOP_WAIT_MS, SIGKILL_AFTER_MS, SECRET_PATTERNS, APPROVAL_METHODS, CodexSession;
8248
+ function failureText(error) {
8249
+ const message = error?.message;
8250
+ if (typeof message !== "string" || !message.trim()) return null;
8251
+ let text = message;
8252
+ try {
8253
+ const inner = JSON.parse(message)?.error?.message;
8254
+ if (typeof inner === "string" && inner.trim()) text = inner;
8255
+ } catch {
8256
+ }
8257
+ return scrubSecrets(text).trim().slice(0, TURN_ERROR_MAX);
8258
+ }
8259
+ var decided, NO_ANSWERS, REFUSALS, METHOD_NOT_FOUND, AskedQuestions, STDERR_TAIL_MAX, STOP_WAIT_MS, TURN_ERROR_MAX, SIGKILL_AFTER_MS, SECRET_PATTERNS, APPROVAL_METHODS, CodexSession;
8181
8260
  var init_session3 = __esm({
8182
8261
  "src/codex/session.ts"() {
8183
8262
  "use strict";
@@ -8187,8 +8266,27 @@ var init_session3 = __esm({
8187
8266
  init_normalize2();
8188
8267
  init_policy();
8189
8268
  init_rpc();
8269
+ decided = (decision) => ({ decision });
8270
+ NO_ANSWERS = { answers: {} };
8271
+ REFUSALS = {
8272
+ "item/permissions/requestApproval": { permissions: {} },
8273
+ "mcpServer/elicitation/request": { action: "decline" },
8274
+ "item/tool/call": { contentItems: [], success: false }
8275
+ };
8276
+ METHOD_NOT_FOUND = -32601;
8277
+ AskedQuestions = z18.object({
8278
+ questions: z18.array(
8279
+ z18.object({
8280
+ id: z18.string().min(1),
8281
+ question: z18.string(),
8282
+ isSecret: z18.boolean().nullish(),
8283
+ options: z18.array(z18.object({ label: z18.string() })).nullish()
8284
+ })
8285
+ )
8286
+ });
8190
8287
  STDERR_TAIL_MAX = 2 * 1024;
8191
8288
  STOP_WAIT_MS = 2e3;
8289
+ TURN_ERROR_MAX = 500;
8192
8290
  SIGKILL_AFTER_MS = 5e3;
8193
8291
  SECRET_PATTERNS = [
8194
8292
  [
@@ -8234,8 +8332,17 @@ var init_session3 = __esm({
8234
8332
  resolvers = /* @__PURE__ */ new Map();
8235
8333
  /** Monotonic, so no two held requests can ever share a local id. */
8236
8334
  approvalSeq = 0;
8237
- /** How many questions each pending `codex.user_input` asked, so answers can be counted. */
8238
- questionCounts = /* @__PURE__ */ new Map();
8335
+ /**
8336
+ * The app-server's id of each question a pending `codex.user_input` asked, in the order the
8337
+ * card shows them: an answer goes back under its question's id.
8338
+ */
8339
+ questionIds = /* @__PURE__ */ new Map();
8340
+ /**
8341
+ * The files of each `fileChange` item in progress, by item id. `item/fileChange/requestApproval`
8342
+ * names the item and not its files: those came with `item/started`, which the app-server sends
8343
+ * first (live 2026-10-13), and with any `item/fileChange/patchUpdated` after.
8344
+ */
8345
+ fileChanges = /* @__PURE__ */ new Map();
8239
8346
  start() {
8240
8347
  if (this.child) throw new Error("VALIDATION: session already started");
8241
8348
  const spawnFn = this.opts.spawnFn ?? ((p, a, env) => spawn2(p, a, { stdio: "pipe", env }));
@@ -8255,13 +8362,19 @@ var init_session3 = __esm({
8255
8362
  ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8256
8363
  const error = this.stopping || code === 0 ? null : `PROVIDER_ERROR: codex exited with ${code}${tail}`;
8257
8364
  this.removeAllFiles();
8365
+ this.withdrawAll();
8258
8366
  this.emit("exit", { error });
8259
8367
  });
8260
8368
  const rpc = new JsonRpc(child.stdin, child.stdout);
8261
8369
  this.rpc = rpc;
8262
- rpc.onNotification((n) => this.onNotification(n.method, n.params));
8263
- rpc.onRequest((r) => this.onRequest(r.method, r.params));
8370
+ rpc.onNotification(
8371
+ (n) => this.onNotification(n.method, n.params ?? {})
8372
+ );
8373
+ rpc.onRequest(
8374
+ (r) => this.onRequest(r.id, r.method, r.params ?? {})
8375
+ );
8264
8376
  void this.handshake().catch((err) => {
8377
+ this.withdrawAll();
8265
8378
  this.emit("exit", {
8266
8379
  error: `PROVIDER_ERROR: ${err instanceof Error ? err.message : String(err)}`
8267
8380
  });
@@ -8277,17 +8390,24 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8277
8390
  const params = {
8278
8391
  cwd: this.opts.cwd,
8279
8392
  approvalPolicy: "on-request",
8280
- sandbox: this.sandbox,
8393
+ // The mode word: opening a thread refuses a policy object (`unknown variant `type``, live
8394
+ // 2026-10-13). The policy itself, with its writable root, goes with every `turn/start`.
8395
+ sandbox: sandboxMode(this.sandbox),
8281
8396
  ...this.pendingModel ? { model: this.pendingModel } : {}
8282
8397
  };
8283
8398
  const res = this.opts.resume ? await rpc.request("thread/resume", {
8284
8399
  threadId: this.opts.resume,
8285
8400
  ...params
8286
- }) : await rpc.request("thread/start", params);
8287
- this.providerSessionId = res.threadId ?? this.opts.resume ?? null;
8401
+ }) : await rpc.request("thread/start", {
8402
+ ...params,
8403
+ ...this.opts.ephemeral ? { ephemeral: true } : {}
8404
+ });
8405
+ const threadId = res?.thread?.id;
8406
+ if (!threadId) throw new Error("codex app-server opened no thread");
8407
+ this.providerSessionId = threadId;
8288
8408
  this.emit("init", {
8289
- providerSessionId: this.providerSessionId ?? "",
8290
- model: res.model ?? this.pendingModel ?? "",
8409
+ providerSessionId: threadId,
8410
+ model: res?.model ?? this.pendingModel ?? "",
8291
8411
  tools: [],
8292
8412
  slashCommands: [],
8293
8413
  permissionMode: this.mode
@@ -8353,6 +8473,7 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8353
8473
  if (!turn) return;
8354
8474
  this.turn = null;
8355
8475
  removeFiles(turn.files);
8476
+ this.fileChanges.clear();
8356
8477
  turn.stop?.resolve();
8357
8478
  }
8358
8479
  removeAllFiles() {
@@ -8399,7 +8520,11 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8399
8520
  isError: true,
8400
8521
  durationMs: 0,
8401
8522
  numTurns: 1,
8402
- stopReason: err instanceof Error ? err.message : String(err)
8523
+ // The app-server's own words, broadcast like a failed turn's: scrubbed and kept short.
8524
+ stopReason: scrubSecrets(err instanceof Error ? err.message : String(err)).slice(
8525
+ 0,
8526
+ TURN_ERROR_MAX
8527
+ )
8403
8528
  });
8404
8529
  });
8405
8530
  }
@@ -8469,6 +8594,10 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8469
8594
  const item = params.item;
8470
8595
  if (!item) return;
8471
8596
  const phase = method === "item/started" ? "started" : "completed";
8597
+ if (item.type === "fileChange") {
8598
+ if (phase === "started") this.setFiles(item.id, fileChanges(item));
8599
+ else this.fileChanges.delete(item.id);
8600
+ }
8472
8601
  if (phase === "completed" && this.streaming.delete(item.id)) {
8473
8602
  for (const event of stopEvents())
8474
8603
  this.emit("stream", { event, parentToolUseId: null, uuid: item.id });
@@ -8477,15 +8606,32 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8477
8606
  if (row) this.emit("message", row);
8478
8607
  return;
8479
8608
  }
8480
- case "turn/completed":
8481
- case "turn/failed": {
8609
+ case "item/fileChange/patchUpdated":
8610
+ if (typeof params.itemId === "string" && this.fileChanges.has(params.itemId)) {
8611
+ this.setFiles(params.itemId, fileChanges(params));
8612
+ }
8613
+ return;
8614
+ case "serverRequest/resolved":
8615
+ for (const [id, entry] of [...this.approvals]) {
8616
+ if (entry.rpcId === params.requestId) this.withdraw(id);
8617
+ }
8618
+ return;
8619
+ case "turn/completed": {
8482
8620
  this.endTurn();
8483
- const status = turn?.status ?? params.status ?? (method === "turn/failed" ? "failed" : "completed");
8621
+ const ended = turn?.id ?? params.turnId;
8622
+ for (const [id, entry] of [...this.approvals]) {
8623
+ if (typeof ended !== "string" || entry.turnId === null || entry.turnId === ended) {
8624
+ this.withdraw(id);
8625
+ }
8626
+ }
8627
+ const status = turn?.status ?? params.status ?? "completed";
8628
+ const isError2 = status === "failed";
8484
8629
  this.emit("result", {
8485
- isError: status === "failed",
8630
+ isError: isError2,
8486
8631
  durationMs: turn?.durationMs ?? params.durationMs ?? 0,
8487
8632
  numTurns: 1,
8488
- stopReason: status
8633
+ // A failed turn says why, when the app-server did.
8634
+ stopReason: isError2 && failureText(turn?.error) || status
8489
8635
  });
8490
8636
  this.flush();
8491
8637
  return;
@@ -8494,6 +8640,45 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8494
8640
  return;
8495
8641
  }
8496
8642
  }
8643
+ /**
8644
+ * The files of a `fileChange` item, as last told. A card already up for that item shows the
8645
+ * files as they were, and the roots check passed those: it is withdrawn and the approval
8646
+ * declined, since the patch an answer would now allow is not the one the owner is reading.
8647
+ */
8648
+ setFiles(itemId, changes) {
8649
+ const before = this.fileChanges.get(itemId);
8650
+ this.fileChanges.set(itemId, changes);
8651
+ if (before === void 0 || JSON.stringify(before) === JSON.stringify(changes)) return;
8652
+ for (const [id, entry] of [...this.approvals]) {
8653
+ if (entry.kind === "file_change" && entry.itemId === itemId) this.withdraw(id);
8654
+ }
8655
+ }
8656
+ /**
8657
+ * Take back a held request no answer can reach any more: nobody answered in time, its files
8658
+ * changed under the card, the app-server settled it itself, its turn ended, the session is
8659
+ * stopping, or the app-server is gone. The request is answered with its own refusal (an approval
8660
+ * declines; a question's response is its answers, of which there are none), and
8661
+ * `permissionTimeout` has the manager expire the card's row, after which an answer to it is
8662
+ * refused. One place, so no way of withdrawing can leave behind what another clears.
8663
+ */
8664
+ withdraw(id) {
8665
+ const entry = this.approvals.get(id);
8666
+ if (!entry) return;
8667
+ clearTimeout(entry.timer);
8668
+ this.approvals.delete(id);
8669
+ this.questionIds.delete(id);
8670
+ this.resolvers.get(id)?.(entry.refusal);
8671
+ this.resolvers.delete(id);
8672
+ this.emit("permissionTimeout", { id });
8673
+ }
8674
+ /**
8675
+ * Every held request, when nothing is left to answer: the session is stopping, the app-server
8676
+ * has exited, or it never opened a thread. The refusals are written if there is still a pipe to
8677
+ * write them to; into one that is gone they are dropped, without a throw (`rpc.test.ts`).
8678
+ */
8679
+ withdrawAll() {
8680
+ for (const id of [...this.approvals.keys()]) this.withdraw(id);
8681
+ }
8497
8682
  delta(params, kind, field) {
8498
8683
  const itemId = params.itemId ?? "";
8499
8684
  const text = params[field] ?? "";
@@ -8509,27 +8694,43 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8509
8694
  * The reply is held until a human answers or the timer fires, which is what makes a Codex chat
8510
8695
  * behave like a Claude one — the SDK's `canUseTool` blocks the same way.
8511
8696
  */
8512
- onRequest(method, params) {
8513
- if (method === "tool/requestUserInput") return this.askQuestion(params);
8514
- const kind = APPROVAL_METHODS[method];
8515
- if (!kind) return "decline";
8516
- const toolName2 = kind === "command" ? CODEX_TOOL_NAMES.command : CODEX_TOOL_NAMES.fileChange;
8517
- const input = kind === "command" ? {
8518
- command: params.command ?? "",
8519
- cwd: params.cwd ?? this.opts.cwd,
8520
- ...params.reason ? { reason: params.reason } : {}
8521
- } : { changes: params.changes ?? [], ...params.reason ? { reason: params.reason } : {} };
8697
+ onRequest(rpcId, method, params) {
8698
+ const from = { rpcId, turnId: typeof params.turnId === "string" ? params.turnId : null };
8699
+ if (method === "item/tool/requestUserInput") return this.askQuestion(from, params);
8700
+ const kind = Object.hasOwn(APPROVAL_METHODS, method) ? APPROVAL_METHODS[method] : void 0;
8701
+ if (!kind) {
8702
+ if (Object.hasOwn(REFUSALS, method)) return REFUSALS[method];
8703
+ throw new RpcError(METHOD_NOT_FOUND, `cayenne does not handle ${method}`);
8704
+ }
8705
+ const itemId = typeof params.itemId === "string" ? params.itemId : null;
8706
+ const reason = params.reason ? { reason: params.reason } : {};
8707
+ let toolName2;
8708
+ let input;
8709
+ if (kind === "command") {
8710
+ toolName2 = CODEX_TOOL_NAMES.command;
8711
+ const { command, cwd } = params;
8712
+ if ((params.kind ?? "command") !== "command" || params.networkApprovalContext != null || typeof command !== "string" || command.trim() === "" || cwd != null && typeof cwd !== "string") {
8713
+ return decided("decline");
8714
+ }
8715
+ input = { command, cwd: cwd ?? this.opts.cwd, ...reason };
8716
+ } else {
8717
+ toolName2 = CODEX_TOOL_NAMES.fileChange;
8718
+ if (params.grantRoot != null) return decided("decline");
8719
+ const changes = (itemId ? this.fileChanges.get(itemId) : void 0) ?? [];
8720
+ if (changes.length === 0) return decided("decline");
8721
+ input = { changes, ...reason };
8722
+ }
8522
8723
  try {
8523
- this.opts.checkInput?.(toolName2, input);
8724
+ this.opts.checkInput(toolName2, input);
8524
8725
  } catch {
8525
- return "decline";
8726
+ return decided("decline");
8526
8727
  }
8527
8728
  const decision = autoDecide(this.mode, kind);
8528
- if (decision !== "ask") return decision;
8529
- return this.hold(this.nextApprovalId(kind), kind, "decline", {
8729
+ if (decision !== "ask") return decided(decision);
8730
+ return this.hold(this.nextApprovalId(kind), kind, decided("decline"), from, {
8530
8731
  toolName: toolName2,
8531
8732
  input,
8532
- toolUseId: params.itemId ?? null
8733
+ toolUseId: itemId
8533
8734
  });
8534
8735
  }
8535
8736
  /**
@@ -8539,15 +8740,20 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8539
8740
  * same answer RPC. The shape is validated here, before a row exists — a malformed or oversized
8540
8741
  * ask is cancelled rather than rendered, because the questions are text the model produced and
8541
8742
  * the card is the one surface where a human is asked to trust what they read.
8542
- */
8543
- askQuestion(params) {
8544
- const parsed = CodexQuestions.safeParse(params);
8545
- if (!parsed.success) return Promise.resolve("cancel");
8743
+ *
8744
+ * The app-server's question (`{ id, header, question, options: { label, description }[] }`) is
8745
+ * not the one Cayenne stores (`CodexQuestions`: `{ text, options: string[] }`), and its answer is
8746
+ * keyed by question id where `CodexAnswers` is a list in order. The adapter translates both
8747
+ * ways, so the row, the card and the answer RPC are what they were.
8748
+ */
8749
+ askQuestion(from, params) {
8750
+ const asked = readQuestions(params);
8751
+ if (!asked) return Promise.resolve(NO_ANSWERS);
8546
8752
  const id = this.nextApprovalId("question");
8547
- this.questionCounts.set(id, parsed.data.questions.length);
8548
- return this.hold(id, "question", "cancel", {
8753
+ this.questionIds.set(id, asked.ids);
8754
+ return this.hold(id, "question", NO_ANSWERS, from, {
8549
8755
  toolName: CODEX_TOOL_NAMES.userInput,
8550
- input: parsed.data,
8756
+ input: asked.input,
8551
8757
  toolUseId: null
8552
8758
  });
8553
8759
  }
@@ -8564,20 +8770,21 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8564
8770
  this.approvalSeq += 1;
8565
8771
  return `${kind}-${this.approvalSeq}`;
8566
8772
  }
8567
- /** Park a server request until a human answers it or the timer gives up on them. */
8568
- hold(id, kind, onTimeout, req) {
8773
+ /**
8774
+ * Park a server request until a human answers it, the timer gives up on them, or it is
8775
+ * withdrawn (`withdraw`).
8776
+ */
8777
+ hold(id, kind, onTimeout, from, req) {
8569
8778
  if (this.approvals.has(id) || this.resolvers.has(id)) {
8570
8779
  throw new Error(`VALIDATION: approval id ${id} is already held`);
8571
8780
  }
8572
8781
  return new Promise((resolve6) => {
8573
- const timer = setTimeout(() => {
8574
- this.approvals.delete(id);
8575
- this.resolvers.delete(id);
8576
- this.questionCounts.delete(id);
8577
- this.emit("permissionTimeout", { id });
8578
- resolve6(onTimeout);
8579
- }, this.opts.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS);
8580
- this.approvals.set(id, { rpcId: 0, kind, refusal: onTimeout, timer });
8782
+ const timer = setTimeout(
8783
+ () => this.withdraw(id),
8784
+ this.opts.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS
8785
+ );
8786
+ const itemId = kind === "question" ? null : req.toolUseId;
8787
+ this.approvals.set(id, { ...from, kind, refusal: onTimeout, itemId, timer });
8581
8788
  this.resolvers.set(id, resolve6);
8582
8789
  this.emit("permission", { id, ...req, suggestions: null });
8583
8790
  });
@@ -8588,20 +8795,24 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8588
8795
  if (!entry || !resolve6) return false;
8589
8796
  let reply;
8590
8797
  if (entry.kind === "question") {
8591
- if (decision.behavior === "deny") reply = "cancel";
8798
+ if (decision.behavior === "deny") reply = NO_ANSWERS;
8592
8799
  else {
8800
+ const ids = this.questionIds.get(id) ?? [];
8593
8801
  const answers = CodexAnswers.safeParse(decision.updatedInput ?? {});
8594
- if (!answers.success || answers.data.answers.length !== this.questionCounts.get(id))
8595
- return false;
8596
- reply = { answers: answers.data.answers };
8802
+ if (!answers.success || answers.data.answers.length !== ids.length) return false;
8803
+ reply = {
8804
+ answers: Object.fromEntries(
8805
+ ids.map((questionId, n) => [questionId, { answers: [answers.data.answers[n] ?? ""] }])
8806
+ )
8807
+ };
8597
8808
  }
8598
8809
  } else {
8599
- reply = decision.behavior === "allow" ? "accept" : "decline";
8810
+ reply = decided(decision.behavior === "allow" ? "accept" : "decline");
8600
8811
  }
8601
8812
  clearTimeout(entry.timer);
8602
8813
  this.approvals.delete(id);
8603
8814
  this.resolvers.delete(id);
8604
- this.questionCounts.delete(id);
8815
+ this.questionIds.delete(id);
8605
8816
  resolve6(reply);
8606
8817
  return true;
8607
8818
  }
@@ -8657,13 +8868,7 @@ ${scrubSecrets(this.stderrTail.join("")).slice(-STDERR_TAIL_MAX)}` : "";
8657
8868
  }
8658
8869
  async stop() {
8659
8870
  this.stopping = true;
8660
- for (const [id, entry] of [...this.approvals]) {
8661
- clearTimeout(entry.timer);
8662
- this.approvals.delete(id);
8663
- this.resolvers.get(id)?.(entry.refusal);
8664
- this.resolvers.delete(id);
8665
- this.emit("permissionTimeout", { id });
8666
- }
8871
+ this.withdrawAll();
8667
8872
  const stoppable = this.turn?.started && this.turn.id;
8668
8873
  await (stoppable ? this.interrupt().catch(() => void 0) : void 0);
8669
8874
  this.removeAllFiles();
@@ -10071,7 +10276,7 @@ function assertRepoOwner(ownerId, createdBy) {
10071
10276
  }
10072
10277
  function narrowToSession(suggestions) {
10073
10278
  if (!suggestions) return suggestions;
10074
- return suggestions.map((s) => isRecord(s) ? { ...s, destination: "session" } : s);
10279
+ return suggestions.map((s) => isRecord2(s) ? { ...s, destination: "session" } : s);
10075
10280
  }
10076
10281
  function shrink(event) {
10077
10282
  switch (event.type) {
@@ -10089,7 +10294,7 @@ function shrink(event) {
10089
10294
  return null;
10090
10295
  }
10091
10296
  }
10092
- var READ_ONLY_TEXT, REFRESH_WITHIN_MS, betweenTurns, UNREADABLE_MARK_CREDS, DRAINED_AFTER_MS, roundUsd, UNIQUE_VIOLATION, ROW_WRITE_TIMEOUT_MS, ORPHANED_STATUSES, RESTARTABLE_STATUSES, isAtOrBelow3, DENY_MESSAGE_MAX, EXIT_ERROR_MAX, INITIAL_PROMPT_MAX, TASK_SKILL_LOOKUP_MS, TASK_REFUSED_MODES, OWNER_DRIVEN_KINDS, REPO_OWNER_ONLY, REPO_NO_DUPLICATE, PROVIDER_SESSION_ID, REPO_WORKTREE_GONE, TASK_ASK_TOOLS, MEMORY_PREAMBLE, MEMORY_SOURCE, wrapMemory, PATH_INPUT_TOOLS, PATH_INPUT_KEYS, isRecord, errorCode, SessionManager;
10297
+ var READ_ONLY_TEXT, REFRESH_WITHIN_MS, betweenTurns, UNREADABLE_MARK_CREDS, DRAINED_AFTER_MS, roundUsd, UNIQUE_VIOLATION, ROW_WRITE_TIMEOUT_MS, ORPHANED_STATUSES, RESTARTABLE_STATUSES, isAtOrBelow3, DENY_MESSAGE_MAX, EXIT_ERROR_MAX, INITIAL_PROMPT_MAX, TASK_SKILL_LOOKUP_MS, TASK_REFUSED_MODES, OWNER_DRIVEN_KINDS, REPO_OWNER_ONLY, REPO_NO_DUPLICATE, PROVIDER_SESSION_ID, REPO_WORKTREE_GONE, TASK_ASK_TOOLS, MEMORY_PREAMBLE, MEMORY_SOURCE, wrapMemory, PATH_INPUT_TOOLS, PATH_INPUT_KEYS, isRecord2, errorCode, SessionManager;
10093
10298
  var init_manager = __esm({
10094
10299
  "src/sessions/manager.ts"() {
10095
10300
  "use strict";
@@ -10176,7 +10381,7 @@ var init_manager = __esm({
10176
10381
  "LS"
10177
10382
  ]);
10178
10383
  PATH_INPUT_KEYS = ["file_path", "path", "cwd", "notebook_path"];
10179
- isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10384
+ isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10180
10385
  errorCode = (message) => message.startsWith("ROOT_NOT_ALLOWED") ? "ROOT_NOT_ALLOWED" : "VALIDATION";
10181
10386
  SessionManager = class {
10182
10387
  constructor(deps) {
@@ -11173,6 +11378,7 @@ var init_manager = __esm({
11173
11378
  turns.drainedAt = Date.now();
11174
11379
  turns.early = true;
11175
11380
  turns.chased = false;
11381
+ turns.ended += 1;
11176
11382
  }
11177
11383
  /**
11178
11384
  * Put rows on this runner that claim a turn in progress back to `idle`, for the chats that have
@@ -11690,15 +11896,31 @@ var init_manager = __esm({
11690
11896
  /**
11691
11897
  * A tool's path as the provider opens it: relative means relative to the session's cwd. Every
11692
11898
  * judge of a tool's path goes through here (R28's `checkUpdatedInput`, a suggested directory,
11693
- * Codex's changes and command cwd). A value the Claude CLI would rewrite before it opens it
11694
- * (surrounding whitespace, a leading `~`, a non-NFC spelling) is refused, not judged: judged as
11695
- * written, `~/.ssh/id_rsa` was `<cwd>/~/.ssh/id_rsa`, inside any root (security re-review
11696
- * 2026-09-29).
11899
+ * and Codex's paths by way of `codexPath`, which admits no relative one). A value the Claude CLI
11900
+ * would rewrite before it opens it (surrounding whitespace, a leading `~`, a non-NFC spelling)
11901
+ * is refused, not judged: judged as written, `~/.ssh/id_rsa` was `<cwd>/~/.ssh/id_rsa`, inside
11902
+ * any root (security re-review 2026-09-29).
11697
11903
  */
11698
11904
  fromCwd(cwd, value) {
11699
11905
  if (!isPlainToolPath(value)) throw new Error(notPlainPathRefusal(value));
11700
11906
  return isAbsolute21(value) ? value : `${cwd}${sep17}${value}`;
11701
11907
  }
11908
+ /**
11909
+ * A path Codex named, for the roots check: absolute, or refused. The runner cannot prove what
11910
+ * directory the app-server would resolve a relative one against: a turn's cwd is the
11911
+ * app-server's, and a command can move it. Every live run against codex-cli 0.162 named
11912
+ * absolute paths only, so a relative one is an unknown shape, refused exactly as an
11913
+ * out-of-roots path is. An absolute value still passes through `fromCwd`, which refuses one
11914
+ * that is not a plain path.
11915
+ */
11916
+ codexPath(cwd, value) {
11917
+ if (!isAbsolute21(value)) {
11918
+ throw new Error(
11919
+ `ROOT_NOT_ALLOWED: ${JSON.stringify(value)} is not an absolute path; a Codex change or command names every path by its absolute path`
11920
+ );
11921
+ }
11922
+ return this.fromCwd(cwd, value);
11923
+ }
11702
11924
  /**
11703
11925
  * §2.9: a path in `updatedInput` passes the same boundary the session's `cwd` did, and the
11704
11926
  * SDK receives the canonical path the guard proved rather than the spelling typed (S2). The
@@ -11721,6 +11943,11 @@ var init_manager = __esm({
11721
11943
  * not admit is denied before a row exists: no answer the owner could give would make it
11722
11944
  * acceptable, exactly as R28 reasons for the Claude path.
11723
11945
  *
11946
+ * A change writes its `path` and, when it moves the file, its `movePath`: both are read here,
11947
+ * each as a path of its own, so the card and the auto-accept gate judge the same set. A command
11948
+ * must name its `cwd`: absent, blank or not a string, the command is refused, not passed.
11949
+ * Every path, and that `cwd`, must be absolute (`codexPath`).
11950
+ *
11724
11951
  * When the roots reach the managed root (D50: an allowed root such as `~`), a file change is
11725
11952
  * also held to the managed `.git` guard Claude sessions get: a change under a managed clone's
11726
11953
  * git dir, or to a worktree's `.git` file, is refused with the same text. In `acceptEdits` or
@@ -11733,19 +11960,30 @@ var init_manager = __esm({
11733
11960
  throw new Error("VALIDATION: codex file change carries no changes to check");
11734
11961
  }
11735
11962
  const guarded = this.reachesManagedRoot(roots);
11963
+ const check = (path) => {
11964
+ assertInsideRootsOrAncestor(this.codexPath(cwd, path), roots);
11965
+ if (guarded && writesManagedGit(path, this.deps.managedRoot, cwd)) {
11966
+ throw new Error(MANAGED_GIT_REFUSAL);
11967
+ }
11968
+ };
11736
11969
  for (const change of changes) {
11737
- if (!isRecord(change) || typeof change.path !== "string" || change.path.trim() === "") {
11970
+ if (!isRecord2(change) || typeof change.path !== "string" || change.path.trim() === "") {
11738
11971
  throw new Error("VALIDATION: codex file change entry names no path to check");
11739
11972
  }
11740
- assertInsideRootsOrAncestor(this.fromCwd(cwd, change.path), roots);
11741
- if (guarded && writesManagedGit(change.path, this.deps.managedRoot, cwd)) {
11742
- throw new Error(MANAGED_GIT_REFUSAL);
11973
+ check(change.path);
11974
+ if (change.movePath === void 0) continue;
11975
+ if (typeof change.movePath !== "string" || change.movePath.trim() === "") {
11976
+ throw new Error("VALIDATION: codex file change entry names no move target to check");
11743
11977
  }
11978
+ check(change.movePath);
11744
11979
  }
11745
11980
  return;
11746
11981
  }
11747
- if (toolName2 === CODEX_TOOL_NAMES.command && typeof input.cwd === "string") {
11748
- assertInsideRoots(this.fromCwd(cwd, input.cwd), roots);
11982
+ if (toolName2 === CODEX_TOOL_NAMES.command) {
11983
+ if (typeof input.cwd !== "string" || input.cwd.trim() === "") {
11984
+ throw new Error("VALIDATION: codex command names no cwd to check");
11985
+ }
11986
+ assertInsideRoots(this.codexPath(cwd, input.cwd), roots);
11749
11987
  }
11750
11988
  }
11751
11989
  /**
@@ -11818,7 +12056,7 @@ var init_manager = __esm({
11818
12056
  checkUpdatedPermissions(live, entry, updates) {
11819
12057
  if (!updates) return void 0;
11820
12058
  return updates.map((update) => {
11821
- if (!isRecord(update))
12059
+ if (!isRecord2(update))
11822
12060
  throw new Error("VALIDATION: updatedPermissions entry is not an object");
11823
12061
  if (update.destination !== "session") {
11824
12062
  throw new Error(
@@ -12004,9 +12242,19 @@ var init_manager = __esm({
12004
12242
  status: "starting",
12005
12243
  echoed: /* @__PURE__ */ new Set(),
12006
12244
  permissions: /* @__PURE__ */ new Map(),
12245
+ permissionsInFlight: /* @__PURE__ */ new Set(),
12246
+ permissionsWithdrawn: /* @__PURE__ */ new Set(),
12007
12247
  cwd: opts.cwd,
12008
12248
  stopping: false,
12009
- turns: { owed: 0, begun: false, drainedAt: null, stops: 0, early: false, chased: false },
12249
+ turns: {
12250
+ owed: 0,
12251
+ begun: false,
12252
+ drainedAt: null,
12253
+ stops: 0,
12254
+ early: false,
12255
+ chased: false,
12256
+ ended: 0
12257
+ },
12010
12258
  dropping: null,
12011
12259
  inflight: /* @__PURE__ */ new Set(),
12012
12260
  writes: Promise.resolve(),
@@ -12089,7 +12337,7 @@ var init_manager = __esm({
12089
12337
  */
12090
12338
  watchWorkingCopy(sessionId, live) {
12091
12339
  const factory = this.deps.watch ?? ((cwd, onChange2, onError2) => watchChanges(cwd, onChange2, CHANGES_DEBOUNCE_MS, onError2));
12092
- let failed4 = false;
12340
+ let failed5 = false;
12093
12341
  let built = null;
12094
12342
  const closeFailed = (watcher) => {
12095
12343
  if (live.watcher === watcher) live.watcher = null;
@@ -12098,8 +12346,8 @@ var init_manager = __esm({
12098
12346
  );
12099
12347
  };
12100
12348
  const onError = (err) => {
12101
- if (failed4) return;
12102
- failed4 = true;
12349
+ if (failed5) return;
12350
+ failed5 = true;
12103
12351
  this.deps.log.warn(
12104
12352
  { err, sessionId },
12105
12353
  "working-copy watcher error; live change refresh stopped"
@@ -12107,7 +12355,7 @@ var init_manager = __esm({
12107
12355
  if (built) closeFailed(built);
12108
12356
  };
12109
12357
  const onChange = () => {
12110
- if (failed4 || this.live.get(sessionId) !== live) return;
12358
+ if (failed5 || this.live.get(sessionId) !== live) return;
12111
12359
  this.bumpChanges(sessionId, live);
12112
12360
  };
12113
12361
  try {
@@ -12116,7 +12364,7 @@ var init_manager = __esm({
12116
12364
  this.deps.log.warn({ err, sessionId }, "working-copy watcher failed to start");
12117
12365
  return null;
12118
12366
  }
12119
- if (failed4) {
12367
+ if (failed5) {
12120
12368
  closeFailed(built);
12121
12369
  return null;
12122
12370
  }
@@ -12218,89 +12466,95 @@ var init_manager = __esm({
12218
12466
  );
12219
12467
  })();
12220
12468
  });
12469
+ const raise = async (rawReq) => {
12470
+ const req = { ...rawReq, suggestions: narrowToSession(rawReq.suggestions) };
12471
+ try {
12472
+ this.checkUpdatedInput(live, req.toolName, req.input);
12473
+ this.checkCodexInput(live.cwd, live.roots, req.toolName, req.input);
12474
+ } catch (err) {
12475
+ const message = this.cap(err instanceof Error ? err.message : String(err));
12476
+ session.respondPermission(req.id, { behavior: "deny", message, interrupt: false });
12477
+ this.deps.log.warn(
12478
+ { sessionId, tool: req.toolName, err: message },
12479
+ "permission request refused before a row existed"
12480
+ );
12481
+ await this.broadcast(sessionId, { type: "error", code: errorCode(message), message });
12482
+ return;
12483
+ }
12484
+ const input = this.secrets.redact(req.input);
12485
+ const suggestions = this.secrets.redact(req.suggestions);
12486
+ const { data, error } = await this.deps.db.from("permission_requests").insert({
12487
+ session_id: sessionId,
12488
+ workspace_id: this.deps.workspaceId,
12489
+ tool_use_id: req.toolUseId,
12490
+ tool_name: req.toolName,
12491
+ input,
12492
+ suggestions
12493
+ }).select("*").single();
12494
+ if (error || !data) {
12495
+ const message = this.cap(
12496
+ `VALIDATION: could not persist the permission request for ${req.toolName}: ${error?.message ?? "no row"}`
12497
+ );
12498
+ session.respondPermission(req.id, { behavior: "deny", message, interrupt: false });
12499
+ await this.broadcast(sessionId, { type: "error", code: "VALIDATION", message });
12500
+ throw new Error(message);
12501
+ }
12502
+ if (live.permissionsWithdrawn.has(req.id)) {
12503
+ await this.expirePermission(sessionId, data.id);
12504
+ return;
12505
+ }
12506
+ live.permissions.set(data.id, {
12507
+ localId: req.id,
12508
+ toolName: req.toolName,
12509
+ input,
12510
+ suggestions
12511
+ });
12512
+ await this.setStatus(sessionId, "awaiting_permission");
12513
+ if (!live.permissions.has(data.id)) return;
12514
+ try {
12515
+ await this.broadcast(sessionId, {
12516
+ type: "permission.request",
12517
+ request: data
12518
+ });
12519
+ } catch (err) {
12520
+ if (!(err instanceof Error) || !err.message.startsWith("VALIDATION:")) throw err;
12521
+ this.deps.log.warn(
12522
+ { sessionId, requestId: data.id, err: err.message },
12523
+ "permission request too large to broadcast; clients reconcile from the row"
12524
+ );
12525
+ await this.broadcast(sessionId, {
12526
+ type: "error",
12527
+ code: "VALIDATION",
12528
+ message: err.message
12529
+ });
12530
+ }
12531
+ };
12221
12532
  session.on(
12222
12533
  "permission",
12223
12534
  (rawReq) => guard(async () => {
12224
- const req = { ...rawReq, suggestions: narrowToSession(rawReq.suggestions) };
12535
+ live.permissionsInFlight.add(rawReq.id);
12225
12536
  try {
12226
- this.checkUpdatedInput(live, req.toolName, req.input);
12227
- this.checkCodexInput(live.cwd, live.roots, req.toolName, req.input);
12228
- } catch (err) {
12229
- const message = this.cap(err instanceof Error ? err.message : String(err));
12230
- session.respondPermission(req.id, { behavior: "deny", message, interrupt: false });
12231
- this.deps.log.warn(
12232
- { sessionId, tool: req.toolName, err: message },
12233
- "permission request refused before a row existed"
12234
- );
12235
- await this.broadcast(sessionId, { type: "error", code: errorCode(message), message });
12236
- return;
12237
- }
12238
- const input = this.secrets.redact(req.input);
12239
- const suggestions = this.secrets.redact(req.suggestions);
12240
- const { data, error } = await this.deps.db.from("permission_requests").insert({
12241
- session_id: sessionId,
12242
- workspace_id: this.deps.workspaceId,
12243
- tool_use_id: req.toolUseId,
12244
- tool_name: req.toolName,
12245
- input,
12246
- suggestions
12247
- }).select("*").single();
12248
- if (error || !data) {
12249
- const message = this.cap(
12250
- `VALIDATION: could not persist the permission request for ${req.toolName}: ${error?.message ?? "no row"}`
12251
- );
12252
- session.respondPermission(req.id, { behavior: "deny", message, interrupt: false });
12253
- await this.broadcast(sessionId, { type: "error", code: "VALIDATION", message });
12254
- throw new Error(message);
12255
- }
12256
- live.permissions.set(data.id, {
12257
- localId: req.id,
12258
- toolName: req.toolName,
12259
- input,
12260
- suggestions
12261
- });
12262
- await this.setStatus(sessionId, "awaiting_permission");
12263
- try {
12264
- await this.broadcast(sessionId, {
12265
- type: "permission.request",
12266
- request: data
12267
- });
12268
- } catch (err) {
12269
- if (!(err instanceof Error) || !err.message.startsWith("VALIDATION:")) throw err;
12270
- this.deps.log.warn(
12271
- { sessionId, requestId: data.id, err: err.message },
12272
- "permission request too large to broadcast; clients reconcile from the row"
12273
- );
12274
- await this.broadcast(sessionId, {
12275
- type: "error",
12276
- code: "VALIDATION",
12277
- message: err.message
12278
- });
12537
+ await raise(rawReq);
12538
+ } finally {
12539
+ live.permissionsInFlight.delete(rawReq.id);
12540
+ live.permissionsWithdrawn.delete(rawReq.id);
12279
12541
  }
12280
12542
  })()
12281
12543
  );
12282
12544
  session.on(
12283
12545
  "permissionTimeout",
12284
12546
  ({ id }) => guard(async () => {
12547
+ const ended = live.turns.ended;
12285
12548
  const requestId = [...live.permissions.entries()].find(
12286
12549
  ([, entry]) => entry.localId === id
12287
12550
  )?.[0];
12288
- if (!requestId) return;
12289
- live.permissions.delete(requestId);
12290
- const { data, error } = await this.deps.db.from("permission_requests").update({ status: "expired" }).eq("id", requestId).eq("status", "pending").select("id");
12291
- if (error) {
12292
- this.deps.log.warn(
12293
- { requestId, err: error.message },
12294
- "could not expire permission request"
12295
- );
12296
- } else if (data && data.length > 0) {
12297
- await this.broadcast(sessionId, {
12298
- type: "permission.resolved",
12299
- requestId,
12300
- status: "expired"
12301
- });
12551
+ if (!requestId) {
12552
+ if (live.permissionsInFlight.has(id)) live.permissionsWithdrawn.add(id);
12553
+ return;
12302
12554
  }
12303
- if (live.permissions.size === 0 && !live.stopping) {
12555
+ live.permissions.delete(requestId);
12556
+ await this.expirePermission(sessionId, requestId);
12557
+ if (live.permissions.size === 0 && !live.stopping && live.status === "awaiting_permission" && live.turns.ended === ended) {
12304
12558
  await this.setStatus(sessionId, "running");
12305
12559
  }
12306
12560
  })()
@@ -12343,6 +12597,26 @@ var init_manager = __esm({
12343
12597
  })();
12344
12598
  });
12345
12599
  }
12600
+ /**
12601
+ * Expire a request the session settled itself, and say so when this write is what settled the
12602
+ * row. `permissions_expire_by_runner` is the one transition the runner may write. Filtered on
12603
+ * status = 'pending' so an owner answer in the same instant wins: the row is then terminal and
12604
+ * this matches nothing rather than failing. A write that fails is logged, never thrown.
12605
+ */
12606
+ async expirePermission(sessionId, requestId) {
12607
+ const { data, error } = await this.deps.db.from("permission_requests").update({ status: "expired" }).eq("id", requestId).eq("status", "pending").select("id");
12608
+ if (error) {
12609
+ this.deps.log.warn({ requestId, err: error.message }, "could not expire permission request");
12610
+ return;
12611
+ }
12612
+ if (data && data.length > 0) {
12613
+ await this.broadcast(sessionId, {
12614
+ type: "permission.resolved",
12615
+ requestId,
12616
+ status: "expired"
12617
+ });
12618
+ }
12619
+ }
12346
12620
  /**
12347
12621
  * Forget `live` and release its channel — once, and only if it is still the current entry.
12348
12622
  *
@@ -15418,7 +15692,7 @@ var configForWrite = () => {
15418
15692
  });
15419
15693
  return cfg;
15420
15694
  };
15421
- var WEB_URL = "https://cayenne-pink.vercel.app";
15695
+ var WEB_URL = "https://app.cayenne.sh";
15422
15696
  var PKG = "@jpcasabiancai/cayenne";
15423
15697
  var service = () => {
15424
15698
  const exec4 = nodeExec();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpcasabiancai/cayenne",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "files": [
5
5
  "dist/cli.js"
6
6
  ],