@adecore/agents 0.27.2 → 0.27.4

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.
@@ -10,8 +10,10 @@ export interface BackendLaunch {
10
10
  runtimeMode: RuntimeMode;
11
11
  resume: string | null;
12
12
  generation: number;
13
+ now?(): number;
13
14
  instructions: string | null;
14
15
  resumeNote: string | null;
16
+ resumeInstructions?: string | null;
15
17
  goal?: ChatGoal;
16
18
  folders?: readonly string[];
17
19
  projectConfig?: boolean;
@@ -196,6 +198,7 @@ export type BackendEvent = {
196
198
  lastUuid?: string;
197
199
  };
198
200
  limit?: ChatTurnLimit;
201
+ replaceProcess?: boolean;
199
202
  } | {
200
203
  type: 'failed';
201
204
  message: string;
@@ -29,6 +29,7 @@ export interface ChatCoreHooks {
29
29
  runtimeModeFor?(chatId: string, mode: RuntimeMode): RuntimeMode;
30
30
  instructionsFor?(chatId: string): string | null;
31
31
  resumeNoteFor?(chatId: string): string | null;
32
+ resumeInstructionsFor?(chatId: string): string | null;
32
33
  foldersFor?(chatId: string): readonly string[];
33
34
  projectConfigFor?(chatId: string): boolean;
34
35
  hiddenFor?(chatId: string): boolean;
@@ -306,6 +306,7 @@ export class ChatCore {
306
306
  saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
307
307
  instructions: () => (this.hooks.instructionsFor ? this.hooks.instructionsFor(payload.chatId) : this.instructions),
308
308
  resumeNote: () => this.hooks.resumeNoteFor?.(payload.chatId) ?? null,
309
+ resumeInstructions: () => this.hooks.resumeInstructionsFor?.(payload.chatId) ?? null,
309
310
  folders: () => this.hooks.foldersFor?.(payload.chatId) ?? [],
310
311
  projectConfig: () => this.hooks.projectConfigFor?.(payload.chatId) ?? true,
311
312
  ...(promptNotes ? { promptNotes } : {}),
@@ -41,6 +41,7 @@ export interface ChatSessionOptions {
41
41
  saveGeneratedImage?: BackendLaunch['saveGeneratedImage'];
42
42
  instructions?(): string | null;
43
43
  resumeNote?(): string | null;
44
+ resumeInstructions?(): string | null;
44
45
  folders?(): readonly string[];
45
46
  projectConfig?(): boolean;
46
47
  promptNotes?: PromptNotes;
@@ -84,6 +85,7 @@ export declare class ChatSession {
84
85
  private readonly options;
85
86
  private readonly projector;
86
87
  private backend;
88
+ private replaceProcess;
87
89
  private starting;
88
90
  private pendingStart;
89
91
  private sendAfterStop;
@@ -43,6 +43,7 @@ export class ChatSession {
43
43
  options;
44
44
  projector;
45
45
  backend = null;
46
+ replaceProcess = false;
46
47
  starting = null;
47
48
  pendingStart = null;
48
49
  sendAfterStop = false;
@@ -1564,7 +1565,8 @@ export class ChatSession {
1564
1565
  }
1565
1566
  requiresReplacement() {
1566
1567
  return (this.backend !== null &&
1567
- (JSON.stringify(this.info.selection) !== JSON.stringify(this.launchedSelection) ||
1568
+ (this.replaceProcess ||
1569
+ JSON.stringify(this.info.selection) !== JSON.stringify(this.launchedSelection) ||
1568
1570
  this.info.runtimeMode !== this.launchedRuntimeMode ||
1569
1571
  this.info.account !== this.launchedAccount ||
1570
1572
  this.info.cwd !== this.launchedCwd ||
@@ -1626,9 +1628,11 @@ export class ChatSession {
1626
1628
  runtimeMode: info.runtimeMode,
1627
1629
  resume: info.agentSessionId,
1628
1630
  generation,
1631
+ now: () => this.options.limitResume?.now() ?? (this.options.clock ?? systemClock).now(),
1629
1632
  saveGeneratedImage: this.options.saveGeneratedImage,
1630
1633
  instructions: this.options.instructions?.() ?? null,
1631
1634
  resumeNote: this.options.resumeNote?.() ?? null,
1635
+ resumeInstructions: this.options.resumeInstructions?.() ?? null,
1632
1636
  ...(info.goal === undefined ? {} : { goal: info.goal }),
1633
1637
  ...(folders.length > 0 ? { folders } : {}),
1634
1638
  ...(projectConfig ? {} : { projectConfig }),
@@ -1649,6 +1653,7 @@ export class ChatSession {
1649
1653
  }
1650
1654
  const backend = made.backend;
1651
1655
  this.backend = backend;
1656
+ this.replaceProcess = false;
1652
1657
  this.emit([this.thread.patchInfo({ running: true, effectiveRuntimeMode: undefined, permissionMode: undefined })]);
1653
1658
  let started;
1654
1659
  try {
@@ -1729,6 +1734,9 @@ export class ChatSession {
1729
1734
  if (event.promptId !== undefined) {
1730
1735
  this.staleResults = 0;
1731
1736
  }
1737
+ if (event.replaceProcess === true) {
1738
+ this.replaceProcess = true;
1739
+ }
1732
1740
  const promptId = event.promptId ?? this.submission?.input.promptId;
1733
1741
  if (promptId !== undefined) {
1734
1742
  this.acceptPrompt(promptId);
@@ -32,7 +32,7 @@ export class ClaudeBackend {
32
32
  this.launch = launch;
33
33
  this.host = host;
34
34
  this.allowedTools = options.allowedTools ?? [];
35
- this.protocol = new ClaudeProtocol(launch.goal ?? null);
35
+ this.protocol = new ClaudeProtocol(launch.goal ?? null, launch.now);
36
36
  }
37
37
  get running() {
38
38
  return this.child !== null;
@@ -3,6 +3,7 @@ import type { ApprovalDecision, BackendEvent } from './backend.ts';
3
3
  export declare function claudeGoalFromCommand(text: string, current: ChatGoal | null): ChatGoal | null | undefined;
4
4
  export declare function claudeGoalCheck(text: string, current: ChatGoal | null): ChatGoal | undefined;
5
5
  export declare class ClaudeProtocol {
6
+ private readonly now;
6
7
  private promptId;
7
8
  private readonly pending;
8
9
  private streamMessageId;
@@ -18,13 +19,14 @@ export declare class ClaudeProtocol {
18
19
  private readonly subagentModels;
19
20
  private apiError;
20
21
  private readonly refusedWindows;
22
+ private readonly refusedResets;
21
23
  private readonly stopRequests;
22
24
  private readonly steerIds;
23
25
  private goal;
24
26
  private goalAnswered;
25
27
  private interrupting;
26
28
  private readonly backgroundAgents;
27
- constructor(goal?: ChatGoal | null);
29
+ constructor(goal?: ChatGoal | null, now?: () => number);
28
30
  noteInterrupt(): void;
29
31
  private setGoal;
30
32
  beginSteer(promptId: string): void;
@@ -50,6 +52,7 @@ export declare class ClaudeProtocol {
50
52
  private handleUser;
51
53
  private handleResult;
52
54
  private noteRefusal;
55
+ private expireRefusedResets;
53
56
  private limit;
54
57
  private handleControlRequest;
55
58
  private textRef;
@@ -153,6 +153,7 @@ export function claudeGoalCheck(text, current) {
153
153
  * belongs to one process, so a resumed CLI that numbers its messages from the start gets its own.
154
154
  */
155
155
  export class ClaudeProtocol {
156
+ now;
156
157
  promptId = null;
157
158
  pending = new Map();
158
159
  streamMessageId = null;
@@ -174,6 +175,8 @@ export class ClaudeProtocol {
174
175
  // Only main-chain errors classify the turn; each plan window can refuse it independently.
175
176
  apiError = null;
176
177
  refusedWindows = new Map();
178
+ // Sparse refusals can omit a reset; cached metadata alone must never classify a later 429.
179
+ refusedResets = new Map();
177
180
  // The task each `stop_task` request named, so an error answer finds it again.
178
181
  stopRequests = new Map();
179
182
  steerIds = new Set();
@@ -184,8 +187,9 @@ export class ClaudeProtocol {
184
187
  interrupting = false;
185
188
  // Background subagents still at work; Claude Code holds a goal's check until they report.
186
189
  backgroundAgents = new Set();
187
- constructor(goal = null) {
190
+ constructor(goal = null, now = () => Date.now()) {
188
191
  this.goal = goal;
192
+ this.now = now;
189
193
  }
190
194
  /* A stop is on its way; the turn it ends leaves the goal set but nobody working on it. */
191
195
  noteInterrupt() {
@@ -204,6 +208,7 @@ export class ClaudeProtocol {
204
208
  this.promptId = promptId;
205
209
  this.apiError = null;
206
210
  this.refusedWindows.clear();
211
+ this.expireRefusedResets();
207
212
  this.lastUuid = null;
208
213
  }
209
214
  handle(frame) {
@@ -713,6 +718,10 @@ export class ClaudeProtocol {
713
718
  const lastUuid = this.lastUuid;
714
719
  this.lastUuid = null;
715
720
  const limit = failed ? this.limit() : null;
721
+ const replaceProcess = failed && this.apiError === 'authentication_failed';
722
+ if (limit?.kind !== 'usage') {
723
+ this.refusedResets.clear();
724
+ }
716
725
  this.apiError = null;
717
726
  this.refusedWindows.clear();
718
727
  // Before the turn ends, so whoever settles work on that end already reads where the goal stands.
@@ -724,21 +733,41 @@ export class ClaudeProtocol {
724
733
  costUsd: num(frame.total_cost_usd),
725
734
  ...(failed ? { error: errors[0] ?? str(frame.result) ?? `The turn ended with ${str(frame.subtype) ?? 'an error'}` } : {}),
726
735
  ...(lastUuid === null ? {} : { native: { lastUuid } }),
727
- ...(limit === null ? {} : { limit })
736
+ ...(limit === null ? {} : { limit }),
737
+ ...(replaceProcess ? { replaceProcess: true } : {})
728
738
  });
729
739
  }
730
740
  noteRefusal(info) {
731
741
  if (!isRecord(info) || typeof info.status !== 'string') {
732
742
  return;
733
743
  }
734
- const window = str(info.rateLimitType) ?? 'unknown';
744
+ this.expireRefusedResets();
745
+ const namedWindow = str(info.rateLimitType);
746
+ const window = namedWindow ?? 'unknown';
735
747
  const overageAllowed = info.overageStatus === 'allowed' || info.overageStatus === 'allowed_warning' || info.isUsingOverage === true || info.overageInUse === true;
736
748
  if (info.status === 'rejected' && !overageAllowed) {
737
749
  const resetsAt = typeof info.resetsAt === 'number' ? info.resetsAt * 1000 : NaN;
738
- this.refusedWindows.set(window, Number.isFinite(resetsAt) && resetsAt > 0 && resetsAt < 8.64e15 ? resetsAt : null);
750
+ const validReset = Number.isFinite(resetsAt) && resetsAt > this.now() && resetsAt < 8.64e15 ? resetsAt : null;
751
+ const reset = validReset ?? (info.resetsAt === undefined && namedWindow ? (this.refusedResets.get(window) ?? null) : null);
752
+ this.refusedWindows.set(window, reset);
753
+ if (namedWindow && reset !== null && reset > this.now()) {
754
+ this.refusedResets.set(window, reset);
755
+ }
756
+ else {
757
+ this.refusedResets.delete(window);
758
+ }
739
759
  }
740
760
  else if (info.status === 'allowed' || info.status === 'allowed_warning' || overageAllowed) {
741
761
  this.refusedWindows.delete(window);
762
+ this.refusedResets.delete(window);
763
+ }
764
+ }
765
+ expireRefusedResets() {
766
+ const now = this.now();
767
+ for (const [window, reset] of this.refusedResets) {
768
+ if (reset <= now) {
769
+ this.refusedResets.delete(window);
770
+ }
742
771
  }
743
772
  }
744
773
  /*
@@ -750,7 +779,7 @@ export class ClaudeProtocol {
750
779
  if (this.refusedWindows.size > 0) {
751
780
  let resetsAt = 0;
752
781
  for (const reset of this.refusedWindows.values()) {
753
- if (reset === null) {
782
+ if (reset === null || reset <= this.now()) {
754
783
  return { kind: 'usage' };
755
784
  }
756
785
  resetsAt = Math.max(resetsAt, reset);
@@ -39,6 +39,7 @@ export declare class CodexBackend implements ChatBackend {
39
39
  get pid(): number | null;
40
40
  start(): Promise<void>;
41
41
  private handshake;
42
+ private resumeContext;
42
43
  private readGoal;
43
44
  private turnInput;
44
45
  sendTurn(input: TurnInput): void;
@@ -68,7 +68,7 @@ export class CodexBackend {
68
68
  imageWrites = null;
69
69
  transport = null;
70
70
  threadId = '';
71
- // Codex 0.154 ignores developer instructions on `thread/resume` (measured), so the first prompt of a resumed process carries them.
71
+ // Codex ignores developer instructions on `thread/resume` (measured on 0.154 and 0.163), so the first prompt of a resumed process carries them.
72
72
  contextPending = null;
73
73
  imageInputSupported = null;
74
74
  /* A turn asked for that Codex has not named yet; `turn/interrupt` needs that name. */
@@ -131,7 +131,7 @@ export class CodexBackend {
131
131
  if (this.launch.resume) {
132
132
  try {
133
133
  result = await transport.request('thread/resume', { threadId: this.launch.resume, excludeTurns: true, ...params });
134
- this.contextPending = this.launch.resumeNote;
134
+ this.contextPending = await this.resumeContext(transport, this.launch.resume);
135
135
  }
136
136
  catch (error) {
137
137
  // A refusal means the thread is gone from Codex's store and a fresh one keeps the chat usable; a silence or an exit says nothing about the thread.
@@ -156,6 +156,26 @@ export class CodexBackend {
156
156
  // An app-server without goals, or with the feature turned off, has none to report.
157
157
  });
158
158
  }
159
+ /*
160
+ * A developer message outranks the thread's first instructions, where a note in front of the prompt loses to
161
+ * them; an app-server without `thread/inject_items` gets the instructions in front of the prompt after all.
162
+ */
163
+ async resumeContext(transport, threadId) {
164
+ const instructions = this.launch.resumeInstructions ?? null;
165
+ if (instructions !== null) {
166
+ try {
167
+ const items = [{ type: 'message', role: 'developer', content: [{ type: 'input_text', text: instructions }] }];
168
+ await transport.request('thread/inject_items', { threadId, items });
169
+ }
170
+ catch (error) {
171
+ if (!(error instanceof Error) || error.name !== 'CodexRpcError') {
172
+ throw error;
173
+ }
174
+ return [instructions, this.launch.resumeNote].filter((part) => part !== null).join('\n\n');
175
+ }
176
+ }
177
+ return this.launch.resumeNote;
178
+ }
159
179
  async readGoal(transport) {
160
180
  const result = await transport.request('thread/goal/get', { threadId: this.threadId });
161
181
  const goal = codexGoal(isRecord(result) ? result.goal : null);
@@ -4,6 +4,7 @@ export declare const FAKE_CHILD_STEPS = 250;
4
4
  export declare const fakeCodexForks: Frame[];
5
5
  export interface FakeCodexOptions {
6
6
  resumeLead?: string;
7
+ injectItems?: boolean;
7
8
  }
8
9
  export declare function fakeCodexWith(options: FakeCodexOptions): FakeCli;
9
10
  export declare const fakeCodex: FakeCli;
@@ -451,6 +451,22 @@ export function fakeCodexWith(options) {
451
451
  notify('thread/started', { thread });
452
452
  return;
453
453
  }
454
+ case 'thread/inject_items': {
455
+ if (options.injectItems === false) {
456
+ out({ id, error: { code: -32601, message: 'unknown method thread/inject_items' } });
457
+ return;
458
+ }
459
+ // The newest developer message is the one the model follows, so `note?` answers with it.
460
+ const items = Array.isArray(params.items) ? params.items : [];
461
+ for (const injected of items) {
462
+ const content = Array.isArray(injected.content) ? injected.content[0] : undefined;
463
+ if (injected.role === 'developer' && typeof content?.text === 'string') {
464
+ threadInstructions.set(String(params.threadId), content.text);
465
+ }
466
+ }
467
+ out({ id, result: {} });
468
+ return;
469
+ }
454
470
  case 'thread/fork': {
455
471
  const source = String(params.threadId);
456
472
  const turns = threadTurns.get(source) ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.27.2",
3
+ "version": "0.27.4",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.27.2",
85
- "@adecore/intelligent-ui": "0.27.2",
84
+ "@adecore/agent-contracts": "0.27.4",
85
+ "@adecore/intelligent-ui": "0.27.4",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -35,11 +35,16 @@ export interface BackendLaunch {
35
35
  // How often this chat started a CLI; a protocol that numbers its own requests from zero
36
36
  // needs it to keep the ids of one process apart from those of the one before.
37
37
  generation: number;
38
+ // Shares the host clock with expiry checks when a test advances time by hand.
39
+ now?(): number;
38
40
  // What the agent is told once, at the start of a process: a system prompt, or a new thread's developer instructions.
39
41
  instructions: string | null;
40
42
  // The part of it a resumed thread has to hear again in front of its next prompt, for a CLI that ignores new
41
43
  // instructions on a resume; null when there is nothing to repeat.
42
44
  resumeNote: string | null;
45
+ // Instructions a resumed thread takes in as a developer message, for a CLI that keeps the ones its thread started
46
+ // with; absent or null when the thread holds them already.
47
+ resumeInstructions?: string | null;
43
48
  // The goal the chat last knew of, which a CLI that takes it up again from its own transcript on a resume still works toward.
44
49
  goal?: ChatGoal;
45
50
  // Folders outside `cwd` the agent may reach without asking. Claude Code takes them as `--add-dir`; Codex's
@@ -179,6 +184,8 @@ export type BackendEvent =
179
184
  error?: string;
180
185
  native?: { turnId?: string; lastUuid?: string };
181
186
  limit?: ChatTurnLimit;
187
+ // The next prompt needs fresh process credentials, once native work and requests have settled.
188
+ replaceProcess?: boolean;
182
189
  }
183
190
  // The CLI could not be reached or refused the request; the turn ends and the chat needs a new one.
184
191
  | { type: 'failed'; message: string; processAlive?: boolean }
@@ -119,6 +119,8 @@ export interface ChatCoreHooks {
119
119
  instructionsFor?(chatId: string): string | null;
120
120
  /* What of the instructions a resumed thread hears again in front of its next prompt, for a CLI that ignores them on a resume. */
121
121
  resumeNoteFor?(chatId: string): string | null;
122
+ /* Instructions a resumed thread takes in again as a developer message, for a CLI that keeps those its thread started with; null when it holds them. */
123
+ resumeInstructionsFor?(chatId: string): string | null;
122
124
  /*
123
125
  * Folders outside its working directory a chat's agent may reach without asking, absolute. Asked
124
126
  * before every turn, so a host can hand one over for as long as it is needed: a change starts the
@@ -495,6 +497,7 @@ export class ChatCore {
495
497
  saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
496
498
  instructions: () => (this.hooks.instructionsFor ? this.hooks.instructionsFor(payload.chatId) : this.instructions),
497
499
  resumeNote: () => this.hooks.resumeNoteFor?.(payload.chatId) ?? null,
500
+ resumeInstructions: () => this.hooks.resumeInstructionsFor?.(payload.chatId) ?? null,
498
501
  folders: () => this.hooks.foldersFor?.(payload.chatId) ?? [],
499
502
  projectConfig: () => this.hooks.projectConfigFor?.(payload.chatId) ?? true,
500
503
  ...(promptNotes ? { promptNotes } : {}),
@@ -76,6 +76,7 @@ export interface ChatSessionOptions {
76
76
  // What the agent is told at the start of every process, and what of it a resumed thread hears again; see `BackendLaunch`.
77
77
  instructions?(): string | null;
78
78
  resumeNote?(): string | null;
79
+ resumeInstructions?(): string | null;
79
80
  // The folders beside the working directory, asked before every turn; a change starts the next turn in a new process.
80
81
  folders?(): readonly string[];
81
82
  // False runs the CLI without the configuration of the working folder; asked before every turn like the folders.
@@ -169,6 +170,7 @@ export class ChatSession {
169
170
  private readonly options: ChatSessionOptions;
170
171
  private readonly projector: ThreadProjector;
171
172
  private backend: ChatBackend | null = null;
173
+ private replaceProcess = false;
172
174
  private starting: Promise<ChatBackend> | null = null;
173
175
  private pendingStart: { turnId: string; warning: unknown } | null = null;
174
176
  private sendAfterStop = false;
@@ -1804,7 +1806,8 @@ export class ChatSession {
1804
1806
  private requiresReplacement(): boolean {
1805
1807
  return (
1806
1808
  this.backend !== null &&
1807
- (JSON.stringify(this.info.selection) !== JSON.stringify(this.launchedSelection) ||
1809
+ (this.replaceProcess ||
1810
+ JSON.stringify(this.info.selection) !== JSON.stringify(this.launchedSelection) ||
1808
1811
  this.info.runtimeMode !== this.launchedRuntimeMode ||
1809
1812
  this.info.account !== this.launchedAccount ||
1810
1813
  this.info.cwd !== this.launchedCwd ||
@@ -1873,9 +1876,11 @@ export class ChatSession {
1873
1876
  runtimeMode: info.runtimeMode,
1874
1877
  resume: info.agentSessionId,
1875
1878
  generation,
1879
+ now: () => this.options.limitResume?.now() ?? (this.options.clock ?? systemClock).now(),
1876
1880
  saveGeneratedImage: this.options.saveGeneratedImage,
1877
1881
  instructions: this.options.instructions?.() ?? null,
1878
1882
  resumeNote: this.options.resumeNote?.() ?? null,
1883
+ resumeInstructions: this.options.resumeInstructions?.() ?? null,
1879
1884
  ...(info.goal === undefined ? {} : { goal: info.goal }),
1880
1885
  ...(folders.length > 0 ? { folders } : {}),
1881
1886
  ...(projectConfig ? {} : { projectConfig }),
@@ -1895,6 +1900,7 @@ export class ChatSession {
1895
1900
  }
1896
1901
  const backend = made.backend;
1897
1902
  this.backend = backend;
1903
+ this.replaceProcess = false;
1898
1904
  this.emit([this.thread.patchInfo({ running: true, effectiveRuntimeMode: undefined, permissionMode: undefined })]);
1899
1905
  let started: Promise<void>;
1900
1906
  try {
@@ -1978,6 +1984,9 @@ export class ChatSession {
1978
1984
  if (event.promptId !== undefined) {
1979
1985
  this.staleResults = 0;
1980
1986
  }
1987
+ if (event.replaceProcess === true) {
1988
+ this.replaceProcess = true;
1989
+ }
1981
1990
  const promptId = event.promptId ?? this.submission?.input.promptId;
1982
1991
  if (promptId !== undefined) {
1983
1992
  this.acceptPrompt(promptId);
@@ -41,7 +41,7 @@ export class ClaudeBackend implements ChatBackend {
41
41
  this.launch = launch;
42
42
  this.host = host;
43
43
  this.allowedTools = options.allowedTools ?? [];
44
- this.protocol = new ClaudeProtocol(launch.goal ?? null);
44
+ this.protocol = new ClaudeProtocol(launch.goal ?? null, launch.now);
45
45
  }
46
46
 
47
47
  get running(): boolean {
@@ -187,6 +187,7 @@ export function claudeGoalCheck(text: string, current: ChatGoal | null): ChatGoa
187
187
  * belongs to one process, so a resumed CLI that numbers its messages from the start gets its own.
188
188
  */
189
189
  export class ClaudeProtocol {
190
+ private readonly now: () => number;
190
191
  private promptId: string | null = null;
191
192
  private readonly pending = new Map<string, Pending>();
192
193
  private streamMessageId: string | null = null;
@@ -208,6 +209,8 @@ export class ClaudeProtocol {
208
209
  // Only main-chain errors classify the turn; each plan window can refuse it independently.
209
210
  private apiError: string | null = null;
210
211
  private readonly refusedWindows = new Map<string, number | null>();
212
+ // Sparse refusals can omit a reset; cached metadata alone must never classify a later 429.
213
+ private readonly refusedResets = new Map<string, number>();
211
214
 
212
215
  // The task each `stop_task` request named, so an error answer finds it again.
213
216
  private readonly stopRequests = new Map<string, string>();
@@ -221,8 +224,9 @@ export class ClaudeProtocol {
221
224
  // Background subagents still at work; Claude Code holds a goal's check until they report.
222
225
  private readonly backgroundAgents = new Set<string>();
223
226
 
224
- constructor(goal: ChatGoal | null = null) {
227
+ constructor(goal: ChatGoal | null = null, now: () => number = () => Date.now()) {
225
228
  this.goal = goal;
229
+ this.now = now;
226
230
  }
227
231
 
228
232
  /* A stop is on its way; the turn it ends leaves the goal set but nobody working on it. */
@@ -245,6 +249,7 @@ export class ClaudeProtocol {
245
249
  this.promptId = promptId;
246
250
  this.apiError = null;
247
251
  this.refusedWindows.clear();
252
+ this.expireRefusedResets();
248
253
  this.lastUuid = null;
249
254
  }
250
255
 
@@ -765,6 +770,10 @@ export class ClaudeProtocol {
765
770
  const lastUuid = this.lastUuid;
766
771
  this.lastUuid = null;
767
772
  const limit = failed ? this.limit() : null;
773
+ const replaceProcess = failed && this.apiError === 'authentication_failed';
774
+ if (limit?.kind !== 'usage') {
775
+ this.refusedResets.clear();
776
+ }
768
777
  this.apiError = null;
769
778
  this.refusedWindows.clear();
770
779
  // Before the turn ends, so whoever settles work on that end already reads where the goal stands.
@@ -776,7 +785,8 @@ export class ClaudeProtocol {
776
785
  costUsd: num(frame.total_cost_usd),
777
786
  ...(failed ? { error: errors[0] ?? str(frame.result) ?? `The turn ended with ${str(frame.subtype) ?? 'an error'}` } : {}),
778
787
  ...(lastUuid === null ? {} : { native: { lastUuid } }),
779
- ...(limit === null ? {} : { limit })
788
+ ...(limit === null ? {} : { limit }),
789
+ ...(replaceProcess ? { replaceProcess: true } : {})
780
790
  });
781
791
  }
782
792
 
@@ -784,14 +794,33 @@ export class ClaudeProtocol {
784
794
  if (!isRecord(info) || typeof info.status !== 'string') {
785
795
  return;
786
796
  }
787
- const window = str(info.rateLimitType) ?? 'unknown';
797
+ this.expireRefusedResets();
798
+ const namedWindow = str(info.rateLimitType);
799
+ const window = namedWindow ?? 'unknown';
788
800
  const overageAllowed =
789
801
  info.overageStatus === 'allowed' || info.overageStatus === 'allowed_warning' || info.isUsingOverage === true || info.overageInUse === true;
790
802
  if (info.status === 'rejected' && !overageAllowed) {
791
803
  const resetsAt = typeof info.resetsAt === 'number' ? info.resetsAt * 1000 : NaN;
792
- this.refusedWindows.set(window, Number.isFinite(resetsAt) && resetsAt > 0 && resetsAt < 8.64e15 ? resetsAt : null);
804
+ const validReset = Number.isFinite(resetsAt) && resetsAt > this.now() && resetsAt < 8.64e15 ? resetsAt : null;
805
+ const reset = validReset ?? (info.resetsAt === undefined && namedWindow ? (this.refusedResets.get(window) ?? null) : null);
806
+ this.refusedWindows.set(window, reset);
807
+ if (namedWindow && reset !== null && reset > this.now()) {
808
+ this.refusedResets.set(window, reset);
809
+ } else {
810
+ this.refusedResets.delete(window);
811
+ }
793
812
  } else if (info.status === 'allowed' || info.status === 'allowed_warning' || overageAllowed) {
794
813
  this.refusedWindows.delete(window);
814
+ this.refusedResets.delete(window);
815
+ }
816
+ }
817
+
818
+ private expireRefusedResets(): void {
819
+ const now = this.now();
820
+ for (const [window, reset] of this.refusedResets) {
821
+ if (reset <= now) {
822
+ this.refusedResets.delete(window);
823
+ }
795
824
  }
796
825
  }
797
826
 
@@ -804,7 +833,7 @@ export class ClaudeProtocol {
804
833
  if (this.refusedWindows.size > 0) {
805
834
  let resetsAt = 0;
806
835
  for (const reset of this.refusedWindows.values()) {
807
- if (reset === null) {
836
+ if (reset === null || reset <= this.now()) {
808
837
  return { kind: 'usage' };
809
838
  }
810
839
  resetsAt = Math.max(resetsAt, reset);
@@ -81,7 +81,7 @@ export class CodexBackend implements ChatBackend {
81
81
  private imageWrites: Promise<void> | null = null;
82
82
  private transport: CodexTransport | null = null;
83
83
  private threadId = '';
84
- // Codex 0.154 ignores developer instructions on `thread/resume` (measured), so the first prompt of a resumed process carries them.
84
+ // Codex ignores developer instructions on `thread/resume` (measured on 0.154 and 0.163), so the first prompt of a resumed process carries them.
85
85
  private contextPending: string | null = null;
86
86
  private imageInputSupported: boolean | null = null;
87
87
  /* A turn asked for that Codex has not named yet; `turn/interrupt` needs that name. */
@@ -148,7 +148,7 @@ export class CodexBackend implements ChatBackend {
148
148
  if (this.launch.resume) {
149
149
  try {
150
150
  result = await transport.request('thread/resume', { threadId: this.launch.resume, excludeTurns: true, ...params });
151
- this.contextPending = this.launch.resumeNote;
151
+ this.contextPending = await this.resumeContext(transport, this.launch.resume);
152
152
  } catch (error) {
153
153
  // A refusal means the thread is gone from Codex's store and a fresh one keeps the chat usable; a silence or an exit says nothing about the thread.
154
154
  if (!(error instanceof Error) || error.name !== 'CodexRpcError') {
@@ -172,6 +172,26 @@ export class CodexBackend implements ChatBackend {
172
172
  });
173
173
  }
174
174
 
175
+ /*
176
+ * A developer message outranks the thread's first instructions, where a note in front of the prompt loses to
177
+ * them; an app-server without `thread/inject_items` gets the instructions in front of the prompt after all.
178
+ */
179
+ private async resumeContext(transport: CodexTransport, threadId: string): Promise<string | null> {
180
+ const instructions = this.launch.resumeInstructions ?? null;
181
+ if (instructions !== null) {
182
+ try {
183
+ const items = [{ type: 'message', role: 'developer', content: [{ type: 'input_text', text: instructions }] }];
184
+ await transport.request('thread/inject_items', { threadId, items });
185
+ } catch (error) {
186
+ if (!(error instanceof Error) || error.name !== 'CodexRpcError') {
187
+ throw error;
188
+ }
189
+ return [instructions, this.launch.resumeNote].filter((part) => part !== null).join('\n\n');
190
+ }
191
+ }
192
+ return this.launch.resumeNote;
193
+ }
194
+
175
195
  private async readGoal(transport: CodexTransport): Promise<ChatGoal | null> {
176
196
  const result = await transport.request('thread/goal/get', { threadId: this.threadId });
177
197
  const goal = codexGoal(isRecord(result) ? result.goal : null);
@@ -26,6 +26,8 @@ export const fakeCodexForks: Frame[] = [];
26
26
  export interface FakeCodexOptions {
27
27
  // How the note a host repeats in front of a resumed thread's first prompt starts; `pasted?` answers it back.
28
28
  resumeLead?: string;
29
+ // False answers `thread/inject_items` the way an app-server without it does.
30
+ injectItems?: boolean;
29
31
  }
30
32
 
31
33
  export function fakeCodexWith(options: FakeCodexOptions): FakeCli {
@@ -490,6 +492,22 @@ export function fakeCodexWith(options: FakeCodexOptions): FakeCli {
490
492
  notify('thread/started', { thread });
491
493
  return;
492
494
  }
495
+ case 'thread/inject_items': {
496
+ if (options.injectItems === false) {
497
+ out({ id, error: { code: -32601, message: 'unknown method thread/inject_items' } });
498
+ return;
499
+ }
500
+ // The newest developer message is the one the model follows, so `note?` answers with it.
501
+ const items = Array.isArray(params.items) ? (params.items as Frame[]) : [];
502
+ for (const injected of items) {
503
+ const content = Array.isArray(injected.content) ? (injected.content[0] as { text?: unknown } | undefined) : undefined;
504
+ if (injected.role === 'developer' && typeof content?.text === 'string') {
505
+ threadInstructions.set(String(params.threadId), content.text);
506
+ }
507
+ }
508
+ out({ id, result: {} });
509
+ return;
510
+ }
493
511
  case 'thread/fork': {
494
512
  const source = String(params.threadId);
495
513
  const turns = threadTurns.get(source) ?? [];