@ccpocket/bridge 1.86.1 → 1.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -211,6 +211,17 @@ export declare class CodexProcess extends EventEmitter<CodexProcessEvents> {
211
211
  * Sends thread/name/set which persists to ~/.codex/session_index.jsonl.
212
212
  */
213
213
  renameThread(name: string): Promise<void>;
214
+ private manualInputVersion;
215
+ private recoveryFailure;
216
+ private upstreamWillRetry;
217
+ private inputTurnStarted;
218
+ private recoveryNotice;
219
+ private readonly recovery;
220
+ getRecoveryState(): import("./codex-recovery.js").CodexRecoveryState;
221
+ setRecoveryEnabled(enabled: boolean): void;
222
+ cancelRecovery(): void;
223
+ noteManualInput(): void;
224
+ private canDispatchRecovery;
214
225
  private goalLookup;
215
226
  /** Read the persisted goal attached to this Codex thread. */
216
227
  getGoal(timeoutMs?: number): Promise<CodexGoal | null>;
@@ -1,3 +1,4 @@
1
+ import { CodexRecovery } from "./codex-recovery.js";
1
2
  import { EventEmitter } from "node:events";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { realpathSync, statSync } from "node:fs";
@@ -240,6 +241,57 @@ export class CodexProcess extends EventEmitter {
240
241
  name,
241
242
  });
242
243
  }
244
+ manualInputVersion = 0;
245
+ recoveryFailure = null;
246
+ upstreamWillRetry = false;
247
+ inputTurnStarted = false;
248
+ recoveryNotice = "";
249
+ recovery = new CodexRecovery({
250
+ changed: (recovery) => {
251
+ this.emitMessage({ type: "codex_recovery_state", recovery });
252
+ const notice = `${recovery.phase}:${recovery.reason}`;
253
+ if (notice !== this.recoveryNotice && ["waiting", "blocked", "exhausted"].includes(recovery.phase)) {
254
+ this.emitMessage({ type: "error", errorCode: "codex_recovery_notice", message: recovery.phase === "waiting"
255
+ ? `Automatic recovery is waiting for the usage limit (${recovery.attempts}/${recovery.maxAttempts} attempts used). You can cancel or send a message. ${recovery.reason ?? ""}`
256
+ : `Automatic recovery ${recovery.phase}. ${recovery.reason ?? ""}` });
257
+ }
258
+ this.recoveryNotice = notice;
259
+ },
260
+ canResume: async () => {
261
+ if (!this.canDispatchRecovery())
262
+ return false;
263
+ const goal = await this.getGoal();
264
+ return this.canDispatchRecovery() && (goal === null ||
265
+ ((goal.status === "active" || goal.status === "usageLimited") &&
266
+ !(typeof goal.tokenBudget === "number" && goal.tokensUsed >= goal.tokenBudget)));
267
+ },
268
+ dispatch: (input) => {
269
+ if (!this.canDispatchRecovery())
270
+ return false;
271
+ const resolve = this.inputResolve;
272
+ this.inputResolve = null;
273
+ resolve({ ...input, recoveryGeneration: this.recovery.version });
274
+ return true;
275
+ },
276
+ resetAt: async () => {
277
+ const response = await this.readRateLimits(3_000);
278
+ const limits = response.rateLimitsByLimitId?.codex ?? response.rateLimits;
279
+ const resets = [limits?.primary, limits?.secondary]
280
+ .filter((window) => window && window.usedPercent >= 100 && typeof window.resetsAt === "number" && Number.isFinite(window.resetsAt))
281
+ .map((window) => window.resetsAt * 1000);
282
+ return resets.length ? Math.max(...resets) : undefined;
283
+ },
284
+ });
285
+ getRecoveryState() { return this.recovery.state; }
286
+ setRecoveryEnabled(enabled) { this.recovery.setEnabled(enabled); }
287
+ cancelRecovery() { this.manualInputVersion++; this.recovery.cancel(); }
288
+ noteManualInput() { this.manualInputVersion++; this.recovery.manualInput(); }
289
+ canDispatchRecovery() {
290
+ return !this.stopped && this._threadId !== null && this.inputResolve !== null &&
291
+ this.pendingTurnId === null && this.pendingTurnCompletion === null &&
292
+ this.pendingApprovals.size === 0 && !this.hasBlockingUserInput() &&
293
+ this.pendingPlanCompletion === null && this._pendingPlanInput === null;
294
+ }
243
295
  goalLookup;
244
296
  /** Read the persisted goal attached to this Codex thread. */
245
297
  async getGoal(timeoutMs = 3_000) {
@@ -258,6 +310,7 @@ export class CodexProcess extends EventEmitter {
258
310
  }
259
311
  /** Create or update the persisted goal attached to this Codex thread. */
260
312
  async setGoal(update) {
313
+ this.cancelRecovery();
261
314
  if (!this._threadId) {
262
315
  throw new Error("No thread ID available for goal update");
263
316
  }
@@ -272,6 +325,7 @@ export class CodexProcess extends EventEmitter {
272
325
  }
273
326
  /** Remove the persisted goal attached to this Codex thread. */
274
327
  async clearGoal() {
328
+ this.cancelRecovery();
275
329
  if (!this._threadId) {
276
330
  throw new Error("No thread ID available for goal clear");
277
331
  }
@@ -601,6 +655,7 @@ export class CodexProcess extends EventEmitter {
601
655
  }
602
656
  stop() {
603
657
  this.stopped = true;
658
+ this.recovery.setEnabled(false);
604
659
  this.rejectReadiness(new Error("codex app-server stopped"));
605
660
  if (this.inputResolve) {
606
661
  this.inputResolve({ text: "" });
@@ -621,6 +676,7 @@ export class CodexProcess extends EventEmitter {
621
676
  }
622
677
  prepareLaunch(projectPath, options) {
623
678
  this.stopped = false;
679
+ this.recovery.setEnabled(false);
624
680
  this._threadId = null;
625
681
  this._agentNickname = null;
626
682
  this._agentRole = null;
@@ -702,6 +758,7 @@ export class CodexProcess extends EventEmitter {
702
758
  transport.start(projectPath);
703
759
  }
704
760
  interrupt() {
761
+ this.cancelRecovery();
705
762
  if (!this._threadId || !this.pendingTurnId)
706
763
  return;
707
764
  void this.request("turn/interrupt", {
@@ -714,6 +771,7 @@ export class CodexProcess extends EventEmitter {
714
771
  });
715
772
  }
716
773
  sendInput(text) {
774
+ this.noteManualInput();
717
775
  if (!this.inputResolve) {
718
776
  console.error("[codex-process] No pending input resolver for sendInput");
719
777
  return;
@@ -723,6 +781,7 @@ export class CodexProcess extends EventEmitter {
723
781
  resolve({ text });
724
782
  }
725
783
  sendInputWithImages(text, images) {
784
+ this.noteManualInput();
726
785
  if (!this.inputResolve) {
727
786
  console.error("[codex-process] No pending input resolver for sendInputWithImages");
728
787
  return;
@@ -735,6 +794,7 @@ export class CodexProcess extends EventEmitter {
735
794
  this.sendInputStructured(text, { skills: [skill] });
736
795
  }
737
796
  sendInputStructured(text, options) {
797
+ this.noteManualInput();
738
798
  if (!this.inputResolve) {
739
799
  console.error("[codex-process] No pending input resolver for sendInputStructured");
740
800
  return;
@@ -749,6 +809,7 @@ export class CodexProcess extends EventEmitter {
749
809
  });
750
810
  }
751
811
  async steerInputStructured(text, options) {
812
+ this.noteManualInput();
752
813
  if (!this._threadId || !this.pendingTurnId) {
753
814
  throw new Error("No active Codex turn to steer");
754
815
  }
@@ -1134,7 +1195,7 @@ export class CodexProcess extends EventEmitter {
1134
1195
  }
1135
1196
  async bootstrap(projectPath, options) {
1136
1197
  try {
1137
- await this.initializeRpcConnection();
1198
+ const supportsPermissionProfiles = await this.initializeRpcConnection();
1138
1199
  const autoReviewDisabled = options?.autoReviewDisabledByPolicy === null
1139
1200
  ? (await this.readConfigRequirements()).autoReviewDisabled
1140
1201
  : options?.autoReviewDisabledByPolicy === true;
@@ -1186,7 +1247,19 @@ export class CodexProcess extends EventEmitter {
1186
1247
  threadParams.approvalsReviewer = requestedApprovalsReviewer;
1187
1248
  }
1188
1249
  if (requestedSandboxMode) {
1189
- threadParams.sandbox = requestedSandboxMode;
1250
+ // A legacy sandbox override does not persist the selected built-in
1251
+ // profile. Desktop can restore its configured sandbox on resume.
1252
+ // Keep legacy requests for older/unknown servers, which may silently
1253
+ // ignore the new field. Never send permissions together with sandbox.
1254
+ if (supportsPermissionProfiles &&
1255
+ effectiveCodexPermissionsMode === "fullAccess" &&
1256
+ !options?.profile &&
1257
+ requestedSandboxMode === "danger-full-access") {
1258
+ threadParams.permissions = ":danger-full-access";
1259
+ }
1260
+ else {
1261
+ threadParams.sandbox = requestedSandboxMode;
1262
+ }
1190
1263
  }
1191
1264
  const threadConfig = {};
1192
1265
  const requestedModel = sanitizeCodexModel(options?.model);
@@ -1201,7 +1274,8 @@ export class CodexProcess extends EventEmitter {
1201
1274
  // not the top-level thread/start payload.
1202
1275
  threadConfig.model_reasoning_effort = requestedReasoningEffort;
1203
1276
  }
1204
- if (options?.networkAccessEnabled !== undefined) {
1277
+ if (options?.networkAccessEnabled !== undefined &&
1278
+ threadParams.permissions === undefined) {
1205
1279
  threadParams.sandboxPolicy = {
1206
1280
  type: normalizeSandboxMode(options?.sandboxMode ?? "workspace-write"),
1207
1281
  networkAccess: options.networkAccessEnabled,
@@ -1353,7 +1427,7 @@ export class CodexProcess extends EventEmitter {
1353
1427
  return extractPermissionsFromConfigRead(response, projectLookupPaths, this.platform, profile);
1354
1428
  }
1355
1429
  async initializeRpcConnection(timeoutMs) {
1356
- await this.request("initialize", {
1430
+ const response = (await this.request("initialize", {
1357
1431
  clientInfo: {
1358
1432
  name: "ccpocket_bridge",
1359
1433
  version: "1.0.0",
@@ -1362,8 +1436,12 @@ export class CodexProcess extends EventEmitter {
1362
1436
  capabilities: {
1363
1437
  experimentalApi: true,
1364
1438
  },
1365
- }, timeoutMs);
1439
+ }, timeoutMs));
1366
1440
  this.notify("initialized", {});
1441
+ // 0.157.0 is the oldest version verified for built-in permission profiles.
1442
+ const version = response.userAgent?.match(/^\S+\/(\d+)\.(\d+)\.(\d+)(?=\s|$)/);
1443
+ return (version != null &&
1444
+ (Number(version[1]) > 0 || Number(version[2]) >= 157));
1367
1445
  }
1368
1446
  async readProfileConfig(cwd) {
1369
1447
  const response = (await this.request("config/read", {
@@ -1583,10 +1661,21 @@ export class CodexProcess extends EventEmitter {
1583
1661
  });
1584
1662
  continue;
1585
1663
  }
1664
+ const manualVersion = this.manualInputVersion;
1665
+ this.recoveryFailure = null;
1666
+ this.upstreamWillRetry = false;
1667
+ this.inputTurnStarted = false;
1668
+ let startFailure = null;
1586
1669
  const { input, tempPaths } = await this.toRpcInput(pendingInput);
1587
- if (!input) {
1670
+ if (!input || (pendingInput.recoveryGeneration !== undefined &&
1671
+ (pendingInput.recoveryGeneration !== this.recovery.version || !this.recovery.state.enabled))) {
1672
+ await Promise.all(tempPaths.map((path) => rm(path, { force: true }).catch(() => { })));
1588
1673
  continue;
1589
1674
  }
1675
+ if (pendingInput.recoveryContinuation && !pendingInput.recoveryEchoed) {
1676
+ pendingInput.recoveryEchoed = true;
1677
+ this.emitMessage({ type: "user_input", text: pendingInput.text, timestamp: new Date().toISOString() });
1678
+ }
1590
1679
  this.setStatus("running");
1591
1680
  this.lastTokenUsage = null;
1592
1681
  const completion = await new Promise((resolve, reject) => {
@@ -1640,6 +1729,9 @@ export class CodexProcess extends EventEmitter {
1640
1729
  reject(err instanceof Error ? err : new Error(String(err)));
1641
1730
  });
1642
1731
  }).catch((err) => {
1732
+ // Only a confirmed turn/start rejection can replay original attachments.
1733
+ if (err instanceof CodexRpcError && !this.inputTurnStarted && !this.pendingTurnId)
1734
+ startFailure = err;
1643
1735
  if (!this.stopped) {
1644
1736
  const message = err instanceof Error ? err.message : String(err);
1645
1737
  this.emitMessage({ type: "error", message });
@@ -1654,6 +1746,14 @@ export class CodexProcess extends EventEmitter {
1654
1746
  });
1655
1747
  await Promise.all(tempPaths.map((path) => rm(path, { force: true }).catch(() => { })));
1656
1748
  void completion;
1749
+ if (!this.stopped && manualVersion === this.manualInputVersion && !this.upstreamWillRetry) {
1750
+ const failure = startFailure ?? this.recoveryFailure;
1751
+ if (failure)
1752
+ this.recovery.schedule(failure, startFailure ? pendingInput : {
1753
+ text: "Automatic recovery: Continue exactly where you left off. Do not repeat completed work. If the task is already complete, stop.",
1754
+ recoveryContinuation: true,
1755
+ });
1756
+ }
1657
1757
  }
1658
1758
  }
1659
1759
  handleStdoutChunk(chunk) {
@@ -1938,6 +2038,8 @@ export class CodexProcess extends EventEmitter {
1938
2038
  break;
1939
2039
  }
1940
2040
  case "turn/started": {
2041
+ this.inputTurnStarted = true;
2042
+ this.recovery.cancel();
1941
2043
  const turn = params.turn;
1942
2044
  if (typeof turn?.id === "string") {
1943
2045
  this.pendingTurnId = turn.id;
@@ -1959,6 +2061,10 @@ export class CodexProcess extends EventEmitter {
1959
2061
  }
1960
2062
  case "thread/goal/updated": {
1961
2063
  try {
2064
+ const goal = parseCodexGoal(params.goal);
2065
+ if (!["active", "usageLimited"].includes(goal.status) ||
2066
+ (typeof goal.tokenBudget === "number" && goal.tokensUsed >= goal.tokenBudget))
2067
+ this.cancelRecovery();
1962
2068
  this.emitMessage({
1963
2069
  type: "goal_state",
1964
2070
  goal: parseCodexGoal(params.goal),
@@ -1970,6 +2076,7 @@ export class CodexProcess extends EventEmitter {
1970
2076
  break;
1971
2077
  }
1972
2078
  case "thread/goal/cleared": {
2079
+ this.cancelRecovery();
1973
2080
  this.emitMessage({ type: "goal_state", goal: null });
1974
2081
  break;
1975
2082
  }
@@ -2118,6 +2225,7 @@ export class CodexProcess extends EventEmitter {
2118
2225
  const error = asRecord(params.error);
2119
2226
  const message = stringValue(error?.message) ?? "Codex runtime error";
2120
2227
  if (params.willRetry === true) {
2228
+ this.upstreamWillRetry = true;
2121
2229
  console.warn(`[codex-process] Codex will retry: ${message}`);
2122
2230
  break;
2123
2231
  }
@@ -2157,6 +2265,13 @@ export class CodexProcess extends EventEmitter {
2157
2265
  this.lastTokenUsage = null;
2158
2266
  if (status === "failed") {
2159
2267
  const errorObj = turn?.error;
2268
+ // A terminal failure ends upstream retry ownership. Warnings alone never
2269
+ // schedule work, and an explicitly retrying terminal payload is excluded.
2270
+ if (this.pendingTurnCompletion && errorObj?.willRetry !== true &&
2271
+ (!this.pendingTurnId || !turn?.id || turn.id === this.pendingTurnId)) {
2272
+ this.upstreamWillRetry = false;
2273
+ this.recoveryFailure = errorObj;
2274
+ }
2160
2275
  const message = typeof errorObj?.message === "string"
2161
2276
  ? errorObj.message
2162
2277
  : "Turn failed";