@ccpocket/bridge 1.86.2 → 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
  }
@@ -1600,10 +1661,21 @@ export class CodexProcess extends EventEmitter {
1600
1661
  });
1601
1662
  continue;
1602
1663
  }
1664
+ const manualVersion = this.manualInputVersion;
1665
+ this.recoveryFailure = null;
1666
+ this.upstreamWillRetry = false;
1667
+ this.inputTurnStarted = false;
1668
+ let startFailure = null;
1603
1669
  const { input, tempPaths } = await this.toRpcInput(pendingInput);
1604
- 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(() => { })));
1605
1673
  continue;
1606
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
+ }
1607
1679
  this.setStatus("running");
1608
1680
  this.lastTokenUsage = null;
1609
1681
  const completion = await new Promise((resolve, reject) => {
@@ -1657,6 +1729,9 @@ export class CodexProcess extends EventEmitter {
1657
1729
  reject(err instanceof Error ? err : new Error(String(err)));
1658
1730
  });
1659
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;
1660
1735
  if (!this.stopped) {
1661
1736
  const message = err instanceof Error ? err.message : String(err);
1662
1737
  this.emitMessage({ type: "error", message });
@@ -1671,6 +1746,14 @@ export class CodexProcess extends EventEmitter {
1671
1746
  });
1672
1747
  await Promise.all(tempPaths.map((path) => rm(path, { force: true }).catch(() => { })));
1673
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
+ }
1674
1757
  }
1675
1758
  }
1676
1759
  handleStdoutChunk(chunk) {
@@ -1955,6 +2038,8 @@ export class CodexProcess extends EventEmitter {
1955
2038
  break;
1956
2039
  }
1957
2040
  case "turn/started": {
2041
+ this.inputTurnStarted = true;
2042
+ this.recovery.cancel();
1958
2043
  const turn = params.turn;
1959
2044
  if (typeof turn?.id === "string") {
1960
2045
  this.pendingTurnId = turn.id;
@@ -1976,6 +2061,10 @@ export class CodexProcess extends EventEmitter {
1976
2061
  }
1977
2062
  case "thread/goal/updated": {
1978
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();
1979
2068
  this.emitMessage({
1980
2069
  type: "goal_state",
1981
2070
  goal: parseCodexGoal(params.goal),
@@ -1987,6 +2076,7 @@ export class CodexProcess extends EventEmitter {
1987
2076
  break;
1988
2077
  }
1989
2078
  case "thread/goal/cleared": {
2079
+ this.cancelRecovery();
1990
2080
  this.emitMessage({ type: "goal_state", goal: null });
1991
2081
  break;
1992
2082
  }
@@ -2135,6 +2225,7 @@ export class CodexProcess extends EventEmitter {
2135
2225
  const error = asRecord(params.error);
2136
2226
  const message = stringValue(error?.message) ?? "Codex runtime error";
2137
2227
  if (params.willRetry === true) {
2228
+ this.upstreamWillRetry = true;
2138
2229
  console.warn(`[codex-process] Codex will retry: ${message}`);
2139
2230
  break;
2140
2231
  }
@@ -2174,6 +2265,13 @@ export class CodexProcess extends EventEmitter {
2174
2265
  this.lastTokenUsage = null;
2175
2266
  if (status === "failed") {
2176
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
+ }
2177
2275
  const message = typeof errorObj?.message === "string"
2178
2276
  ? errorObj.message
2179
2277
  : "Turn failed";