ai-foreman 1.7.6 → 1.7.8

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 (69) hide show
  1. package/README.md +26 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +31 -6
  4. package/dist/adapters/claude.d.ts +61 -6
  5. package/dist/adapters/claude.js +544 -36
  6. package/dist/adapters/codex.d.ts +28 -1
  7. package/dist/adapters/codex.js +398 -27
  8. package/dist/adapters/recovering.d.ts +13 -2
  9. package/dist/adapters/recovering.js +32 -4
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +117 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +38 -0
  16. package/dist/branch/currentGuard.js +71 -0
  17. package/dist/branch/planner.d.ts +1 -1
  18. package/dist/branch/planner.js +2 -7
  19. package/dist/branch/prefix.d.ts +8 -0
  20. package/dist/branch/prefix.js +26 -0
  21. package/dist/branch/resume.d.ts +2 -0
  22. package/dist/branch/resume.js +8 -0
  23. package/dist/branch/runner.d.ts +14 -2
  24. package/dist/branch/runner.js +77 -17
  25. package/dist/buildRuns.d.ts +13 -4
  26. package/dist/buildRuns.js +85 -19
  27. package/dist/cli/handoffs.d.ts +2 -0
  28. package/dist/cli/handoffs.js +74 -0
  29. package/dist/cli/start.js +1079 -43
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +92 -0
  32. package/dist/continuity.js +415 -0
  33. package/dist/foreman.d.ts +5 -1
  34. package/dist/foreman.js +41 -6
  35. package/dist/handoffs.d.ts +69 -0
  36. package/dist/handoffs.js +546 -0
  37. package/dist/log.d.ts +1 -1
  38. package/dist/permissions/policy.d.ts +5 -1
  39. package/dist/permissions/policy.js +48 -1
  40. package/dist/qaReview.d.ts +6 -6
  41. package/dist/qaReview.js +24 -14
  42. package/dist/sessionAvailability.d.ts +15 -0
  43. package/dist/sessionAvailability.js +57 -0
  44. package/dist/sessionIdentity.d.ts +33 -0
  45. package/dist/sessionIdentity.js +132 -0
  46. package/dist/sessionLifecycle.d.ts +198 -5
  47. package/dist/sessionLifecycle.js +1373 -20
  48. package/dist/statusReporter.d.ts +84 -8
  49. package/dist/statusReporter.js +270 -14
  50. package/dist/ticketPlanning.js +8 -0
  51. package/dist/ticketPopulation.js +88 -12
  52. package/dist/tickets/commands.js +20 -12
  53. package/dist/tickets/groupReset.d.ts +25 -0
  54. package/dist/tickets/groupReset.js +279 -0
  55. package/dist/tickets/groups.d.ts +36 -0
  56. package/dist/tickets/groups.js +68 -0
  57. package/dist/tickets/importer.js +27 -3
  58. package/dist/tickets/recommendations.js +31 -7
  59. package/dist/tickets/reset.d.ts +1 -0
  60. package/dist/tickets/reset.js +1 -0
  61. package/dist/tickets/setupConfig.d.ts +1 -0
  62. package/dist/tickets/setupConfig.js +10 -2
  63. package/dist/tickets/stateDb.d.ts +34 -0
  64. package/dist/tickets/stateDb.js +207 -0
  65. package/dist/tickets/ticketLoader.js +5 -2
  66. package/dist/tickets/validate.js +4 -0
  67. package/dist/workflowDb.d.ts +145 -3
  68. package/dist/workflowDb.js +571 -10
  69. package/package.json +32 -4
@@ -1,4 +1,5 @@
1
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
1
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextManagementPolicy, ContextUsage, InterruptResult, PreparedContextManagement, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
2
+ import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
2
3
  export interface CodexLineResult {
3
4
  events: BuilderEvent[];
4
5
  sessionId?: string;
@@ -16,11 +17,28 @@ export declare class CodexAdapter implements BuilderAdapter {
16
17
  private pending;
17
18
  private notificationWaiters;
18
19
  private _sessionId?;
20
+ private _sessionRef?;
21
+ private sessionAvailability?;
19
22
  private initialized;
20
23
  private closed;
21
24
  private activeText;
22
25
  private usage?;
26
+ private usageRevision;
27
+ private contextSequence;
28
+ private providerSessionUsage?;
29
+ private lastTurnTokens?;
23
30
  private stderr;
31
+ private contextPolicy?;
32
+ private nativeTokenLimit?;
33
+ private spawnedNativeTokenLimit?;
34
+ private contextPrepared;
35
+ private setupTurnActive;
36
+ private setupToolAttempted;
37
+ private activeTurnId?;
38
+ private boundaryInterrupt?;
39
+ private compactionSequence;
40
+ private activeCompaction?;
41
+ private manualCompactionPending;
24
42
  constructor(opts: BuilderAdapterOptions);
25
43
  buildInstruction(instruction: string): string;
26
44
  buildAppServerArgs(): string[];
@@ -28,10 +46,17 @@ export declare class CodexAdapter implements BuilderAdapter {
28
46
  buildArgs(instruction: string): string[];
29
47
  sendTurn(instruction: string): Promise<TurnResult>;
30
48
  private sendTurnInternal;
49
+ prepareContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
50
+ updateContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
51
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<InterruptResult>;
31
52
  compact(): Promise<CompactResult>;
32
53
  contextUsage(): Promise<ContextUsage | undefined>;
54
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
33
55
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
34
56
  sessionId(): string | undefined;
57
+ sessionRef(): ProviderSessionRefV1 | undefined;
58
+ adoptSessionRef(ref: ProviderSessionRefV1): void;
59
+ validateSession(): Promise<SessionAvailabilityV1>;
35
60
  events(): AsyncIterable<BuilderEvent>;
36
61
  close(): Promise<void>;
37
62
  private ensureThread;
@@ -42,5 +67,7 @@ export declare class CodexAdapter implements BuilderAdapter {
42
67
  private handle;
43
68
  private waitFor;
44
69
  private disconnect;
70
+ /** Restart only app-server; the scoped thread remains the continuity boundary. */
71
+ private restartTransport;
45
72
  private buildSkillsAppendix;
46
73
  }
@@ -5,6 +5,8 @@ import { createInterface } from "node:readline";
5
5
  import { loadSkill } from "special-agents";
6
6
  import { BuilderEventQueue, withActivityPhase } from "../activity.js";
7
7
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
8
+ import { canonicalSessionPath, createProviderSessionRef, validateProviderSessionScope } from "../sessionIdentity.js";
9
+ import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFailure.js";
8
10
  /** Compatibility parser for recorded pre-app-server JSONL fixtures. */
9
11
  export function parseCodexLine(raw) {
10
12
  const type = raw.type;
@@ -21,6 +23,7 @@ export function parseCodexLine(raw) {
21
23
  return { events: [{ kind: "error", message: String(raw.error?.message ?? JSON.stringify(raw)) }] };
22
24
  return { events: [] };
23
25
  }
26
+ const CODEX_COMPACTION_TIMEOUT_MS = 120_000;
24
27
  /** Persistent JSON-RPC controller for one live Codex thread. */
25
28
  export class CodexAdapter {
26
29
  opts;
@@ -31,20 +34,44 @@ export class CodexAdapter {
31
34
  pending = new Map();
32
35
  notificationWaiters = new Map();
33
36
  _sessionId;
37
+ _sessionRef;
38
+ sessionAvailability;
34
39
  initialized = false;
35
40
  closed = false;
36
41
  activeText = [];
37
42
  usage;
43
+ usageRevision = 0;
44
+ contextSequence = 0;
45
+ providerSessionUsage;
46
+ lastTurnTokens;
38
47
  stderr = "";
48
+ contextPolicy;
49
+ nativeTokenLimit;
50
+ spawnedNativeTokenLimit;
51
+ contextPrepared = false;
52
+ setupTurnActive = false;
53
+ setupToolAttempted = false;
54
+ activeTurnId;
55
+ boundaryInterrupt;
56
+ compactionSequence = 0;
57
+ activeCompaction;
58
+ manualCompactionPending = false;
39
59
  constructor(opts) {
40
60
  this.opts = opts;
41
- this._sessionId = opts.resumeSessionId;
61
+ this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
62
+ this._sessionRef = opts.resumeSessionRef;
42
63
  }
43
64
  buildInstruction(instruction) {
44
65
  return [this.opts.systemPromptAppend, this.buildSkillsAppendix(), instruction]
45
66
  .filter((part) => Boolean(part)).join("\n\n");
46
67
  }
47
- buildAppServerArgs() { return ["app-server", "--listen", "stdio://"]; }
68
+ buildAppServerArgs() {
69
+ const args = ["app-server", "--listen", "stdio://"];
70
+ if (this.nativeTokenLimit !== undefined) {
71
+ args.push("-c", `model_auto_compact_token_limit=${this.nativeTokenLimit}`, "-c", 'model_auto_compact_token_limit_scope="total"');
72
+ }
73
+ return args;
74
+ }
48
75
  /** Deprecated fixture helper. Runtime execution uses only app-server. */
49
76
  buildArgs(instruction) {
50
77
  const args = ["exec", "--json", "--skip-git-repo-check", "--sandbox", this.opts.sandboxMode ?? "workspace-write", "-C", this.opts.cwd];
@@ -60,61 +87,199 @@ export class CodexAdapter {
60
87
  return args;
61
88
  }
62
89
  async sendTurn(instruction) {
90
+ if (this.opts.contextManagementPolicy && !this.contextPrepared)
91
+ await this.prepareContextManagement(this.opts.contextManagementPolicy);
63
92
  return withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction));
64
93
  }
65
- async sendTurnInternal(instruction) {
94
+ async sendTurnInternal(instruction, setup = false) {
66
95
  if (this.closed)
67
96
  throw new Error("builder is closed");
68
97
  this.eventQueue.push({ kind: "activity", state: "starting Codex turn", provider: "codex", model: this.opts.model });
98
+ let turnStartDispatched = false;
69
99
  try {
70
100
  await this.ensureThread();
71
101
  this.activeText = [];
102
+ this.lastTurnTokens = undefined;
103
+ this.setupTurnActive = setup;
104
+ this.setupToolAttempted = false;
72
105
  const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId);
73
- await this.request("turn/start", {
106
+ turnStartDispatched = true;
107
+ const started = await this.request("turn/start", {
74
108
  threadId: this._sessionId,
75
- input: [{ type: "text", text: this.buildInstruction(instruction), text_elements: [] }],
109
+ input: [{ type: "text", text: setup ? instruction : this.buildInstruction(instruction), text_elements: [] }],
76
110
  cwd: this.opts.cwd,
77
111
  model: this.opts.model ?? null,
78
112
  effort: this.opts.effort ?? (this.opts.fast ? "low" : null),
79
113
  });
114
+ const startedTurn = started.turn;
115
+ this.activeTurnId = typeof startedTurn?.id === "string" ? startedTurn.id : undefined;
80
116
  const params = await completion;
81
117
  const turn = params.turn;
82
118
  const failed = turn?.status === "failed";
119
+ const interrupted = turn?.status === "interrupted" || Boolean(this.boundaryInterrupt);
83
120
  const error = turn?.error;
84
121
  const text = this.activeText.join("\n");
85
- const result = { text: failed ? normalizeRuntimeErrorText("codex", String(error?.message ?? text), null, "app-server turn") : text, isError: failed, numTurns: 1, costUsd: 0 };
122
+ const result = {
123
+ text: failed ? normalizeRuntimeErrorText("codex", String(error?.message ?? text), null, "app-server turn") : text,
124
+ isError: failed, numTurns: 1, costUsd: 0, costAuthoritative: false,
125
+ ...(this.lastTurnTokens ?? {}),
126
+ ...(interrupted ? { interrupted: { reason: "compaction-boundary", ...(this.boundaryInterrupt?.providerEventId ? { providerEventId: this.boundaryInterrupt.providerEventId } : {}) } } : {}),
127
+ };
128
+ if (setup && this.setupToolAttempted)
129
+ throw new Error("Codex context setup attempted to invoke a tool; native context capability was not established");
86
130
  this.eventQueue.push({ kind: "turn-complete", result });
87
131
  return result;
88
132
  }
89
133
  catch (error) {
134
+ if (this.boundaryInterrupt) {
135
+ const result = {
136
+ text: this.activeText.join("\n"), isError: false, numTurns: 1, costUsd: 0,
137
+ costAuthoritative: false, ...(this.lastTurnTokens ?? {}),
138
+ interrupted: {
139
+ reason: "compaction-boundary",
140
+ ...(this.boundaryInterrupt.providerEventId ? { providerEventId: this.boundaryInterrupt.providerEventId } : {}),
141
+ },
142
+ };
143
+ this.eventQueue.push({ kind: "turn-complete", result });
144
+ return result;
145
+ }
146
+ if (error instanceof SessionUnavailableError) {
147
+ const result = sessionUnavailableResult(error);
148
+ this.eventQueue.push({ kind: "error", message: result.text });
149
+ this.eventQueue.push({ kind: "turn-complete", result });
150
+ return result;
151
+ }
90
152
  const detail = [error instanceof Error ? error.message : String(error), this.stderr].filter(Boolean).join("\n");
91
153
  const text = normalizeRuntimeErrorText("codex", detail, this.process?.exitCode ?? null, "builder turn");
92
154
  this.disconnect(new Error(text));
93
- const result = { text, isError: true, numTurns: 1, costUsd: 0 };
155
+ const resumed = Boolean(this.opts.resumeSessionRef);
156
+ const result = resumed
157
+ ? sessionUnavailableResult(new SessionUnavailableError({
158
+ runtime: "codex", phase: turnStartDispatched ? "turn" : "attach", dispatchState: turnStartDispatched ? "unknown" : "not-sent",
159
+ executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: text,
160
+ availability: { version: 1, status: turnStartDispatched ? "unknown" : "unavailable", checkedAt: new Date().toISOString(), reason: turnStartDispatched ? "probe-failed" : "attach-failed", detail: text, sessionRef: this.opts.resumeSessionRef },
161
+ }))
162
+ : { text, isError: true, numTurns: 1, costUsd: 0, costAuthoritative: false };
94
163
  this.eventQueue.push({ kind: "error", message: text });
95
164
  this.eventQueue.push({ kind: "turn-complete", result });
96
165
  return result;
97
166
  }
167
+ finally {
168
+ this.activeTurnId = undefined;
169
+ this.setupTurnActive = false;
170
+ this.boundaryInterrupt = undefined;
171
+ }
172
+ }
173
+ async prepareContextManagement(policy) {
174
+ validateContextPolicy(policy);
175
+ this.contextPolicy = { ...policy };
176
+ this.compactionSequence = Math.max(this.compactionSequence, policy.providerSequenceStart ?? 0);
177
+ this.contextSequence = Math.max(this.contextSequence, policy.providerSequenceStart ?? 0);
178
+ if (policy.knownModelContextWindow && policy.knownModelContextWindow > 0) {
179
+ this.nativeTokenLimit = tokenCeiling(policy.knownModelContextWindow, policy.configuredThresholdPercent);
180
+ }
181
+ await this.ensureThread();
182
+ if (!this.usage) {
183
+ const setup = await this.sendTurnInternal("Rafi context setup only. Do not call tools or change files. Reply with exactly CONTEXT_READY.", true);
184
+ if (setup.isError || setup.text.trim() !== "CONTEXT_READY") {
185
+ throw new Error(`Codex context setup did not complete safely: ${setup.text.slice(0, 240)}`);
186
+ }
187
+ }
188
+ const maximum = this.usage?.maximum ?? policy.knownModelContextWindow;
189
+ if (!maximum || !Number.isFinite(maximum) || maximum <= 0) {
190
+ throw new Error("Codex did not expose modelContextWindow during context setup");
191
+ }
192
+ const configuredTokenLimit = tokenCeiling(maximum, policy.configuredThresholdPercent);
193
+ this.nativeTokenLimit = configuredTokenLimit;
194
+ if (this.spawnedNativeTokenLimit !== configuredTokenLimit) {
195
+ await this.restartTransport();
196
+ await this.ensureThread();
197
+ }
198
+ this.contextPrepared = true;
199
+ return {
200
+ modelContextWindow: maximum,
201
+ configuredTokenLimit,
202
+ installedNativeTokenLimit: configuredTokenLimit,
203
+ installedNativePercent: configuredTokenLimit / maximum * 100,
204
+ ...(this.usage ? { sample: { ...this.usage } } : {}),
205
+ };
206
+ }
207
+ async updateContextManagement(policy) {
208
+ validateContextPolicy(policy);
209
+ if (this.activeTurnId)
210
+ throw new Error("Codex native context control can be reconfigured only after the active turn settles");
211
+ const maximum = this.usage?.maximum ?? policy.knownModelContextWindow;
212
+ if (!maximum || maximum <= 0)
213
+ return this.prepareContextManagement(policy);
214
+ this.contextPolicy = { ...policy };
215
+ this.compactionSequence = Math.max(this.compactionSequence, policy.providerSequenceStart ?? 0);
216
+ this.contextSequence = Math.max(this.contextSequence, policy.providerSequenceStart ?? 0);
217
+ const configuredTokenLimit = tokenCeiling(maximum, policy.configuredThresholdPercent);
218
+ this.nativeTokenLimit = configuredTokenLimit;
219
+ if (this.spawnedNativeTokenLimit !== configuredTokenLimit) {
220
+ await this.restartTransport();
221
+ await this.ensureThread();
222
+ }
223
+ return {
224
+ modelContextWindow: maximum,
225
+ configuredTokenLimit,
226
+ installedNativeTokenLimit: configuredTokenLimit,
227
+ installedNativePercent: configuredTokenLimit / maximum * 100,
228
+ ...(this.usage ? { sample: { ...this.usage } } : {}),
229
+ };
230
+ }
231
+ async interruptTurnAtCompactionBoundary(providerEventId) {
232
+ if (!this.activeTurnId || !this._sessionId)
233
+ return { ok: false, error: "no active Codex turn exists at the compaction boundary", providerEventId };
234
+ try {
235
+ this.boundaryInterrupt = { providerEventId };
236
+ await this.request("turn/interrupt", { threadId: this._sessionId, turnId: this.activeTurnId });
237
+ return { ok: true, providerEventId };
238
+ }
239
+ catch (error) {
240
+ this.boundaryInterrupt = undefined;
241
+ return { ok: false, error: error instanceof Error ? error.message : String(error), providerEventId };
242
+ }
98
243
  }
99
244
  async compact() {
100
245
  try {
101
246
  await this.ensureThread();
247
+ this.manualCompactionPending = true;
102
248
  this.eventQueue.push({ kind: "session-transition", transition: "compacting" });
249
+ const usageRevision = this.usageRevision;
250
+ // A pre-compaction observation must never be mistaken for proof that the
251
+ // provider reduced the live context. The app server emits a fresh token
252
+ // usage notification for the compacted thread; require it alongside the
253
+ // explicit contextCompaction completion item.
254
+ this.usage = undefined;
103
255
  const done = this.waitFor("item/completed", (params) => {
104
256
  const item = params.item;
105
257
  return params.threadId === this._sessionId && item?.type === "contextCompaction";
106
- });
107
- await this.request("thread/compact/start", { threadId: this._sessionId });
108
- await done;
109
- this.usage = undefined;
258
+ }, CODEX_COMPACTION_TIMEOUT_MS);
259
+ const postCompactUsage = this.waitFor("thread/tokenUsage/updated", (params) => {
260
+ return (params.threadId === undefined || params.threadId === this._sessionId)
261
+ && this.usageRevision > usageRevision
262
+ && this.usage !== undefined;
263
+ }, CODEX_COMPACTION_TIMEOUT_MS);
264
+ await Promise.all([
265
+ this.request("thread/compact/start", { threadId: this._sessionId }),
266
+ done,
267
+ postCompactUsage,
268
+ ]);
110
269
  this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
111
270
  return { ok: true };
112
271
  }
113
272
  catch (error) {
273
+ if (error instanceof SessionUnavailableError)
274
+ return { ok: false, error: error.message, failure: error.failure };
114
275
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
115
276
  }
277
+ finally {
278
+ this.manualCompactionPending = false;
279
+ }
116
280
  }
117
281
  async contextUsage() { return this.usage; }
282
+ async sessionUsage() { return this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined; }
118
283
  async switchSettings(settings) {
119
284
  this.opts.model = settings.model;
120
285
  this.opts.effort = settings.effort;
@@ -122,6 +287,26 @@ export class CodexAdapter {
122
287
  return { ok: true };
123
288
  }
124
289
  sessionId() { return this._sessionId; }
290
+ sessionRef() { return this._sessionRef; }
291
+ adoptSessionRef(ref) {
292
+ if (ref.provider !== "codex" || ref.sessionId !== this._sessionId)
293
+ throw new Error("cannot adopt a session reference for a different Codex thread");
294
+ this._sessionRef = ref;
295
+ }
296
+ async validateSession() {
297
+ const checkedAt = new Date().toISOString();
298
+ if (!this._sessionRef)
299
+ return { version: 1, status: "unknown", checkedAt, reason: "legacy-unscoped", detail: "Codex raw thread IDs cannot be declared exact without a stored location scope" };
300
+ try {
301
+ await this.ensureThread();
302
+ return this.sessionAvailability ?? { version: 1, status: "available", checkedAt, observedCwd: canonicalSessionPath(this.opts.cwd), sessionRef: this._sessionRef };
303
+ }
304
+ catch (error) {
305
+ if (error instanceof SessionUnavailableError && error.failure.availability)
306
+ return error.failure.availability;
307
+ return { version: 1, status: "unknown", checkedAt, reason: "probe-failed", detail: error instanceof Error ? error.message : String(error), sessionRef: this._sessionRef };
308
+ }
309
+ }
125
310
  events() { return this.eventQueue; }
126
311
  async close() {
127
312
  if (this.closed)
@@ -131,20 +316,93 @@ export class CodexAdapter {
131
316
  this.eventQueue.close();
132
317
  }
133
318
  async ensureThread() {
319
+ if (this._sessionRef && !this.threadAttached) {
320
+ if (this._sessionRef.source === "legacy-inferred") {
321
+ const availability = { version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "legacy Codex thread IDs cannot be proven exact without an observed scoped binding", sessionRef: this._sessionRef };
322
+ this.sessionAvailability = availability;
323
+ throw new SessionUnavailableError({ runtime: "codex", phase: "preflight", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: availability.detail, availability });
324
+ }
325
+ const scoped = validateProviderSessionScope(this._sessionRef, {
326
+ provider: "codex", cwd: this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
327
+ role: this.opts.sessionRole ?? this._sessionRef.role, stream: this.opts.sessionStream ?? this._sessionRef.stream,
328
+ workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
329
+ });
330
+ if (scoped.status !== "available" || !scoped.sessionRef) {
331
+ this.sessionAvailability = scoped;
332
+ throw new SessionUnavailableError({
333
+ runtime: "codex", phase: "preflight", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex",
334
+ cwd: this.opts.cwd, diagnostics: scoped.detail ?? `Codex session ${this._sessionRef.sessionId} is ${scoped.status}`, availability: scoped,
335
+ });
336
+ }
337
+ this._sessionRef = scoped.sessionRef;
338
+ }
134
339
  await this.ensureConnection();
135
340
  if (this._sessionId && this.initialized === true && this.threadAttached)
136
341
  return;
137
342
  const method = this._sessionId ? "thread/resume" : "thread/start";
138
- const result = await this.request(method, {
139
- ...(this._sessionId ? { threadId: this._sessionId } : {}), cwd: this.opts.cwd,
140
- model: this.opts.model ?? null, approvalPolicy: "never",
141
- sandbox: this.opts.sandboxMode === "read-only" ? "read-only" : "workspace-write",
142
- developerInstructions: this.opts.systemPromptAppend ?? null,
143
- });
343
+ let result;
344
+ try {
345
+ result = await this.request(method, {
346
+ ...(this._sessionId ? { threadId: this._sessionId } : {}), cwd: this.opts.cwd,
347
+ model: this.opts.model ?? null, approvalPolicy: "never",
348
+ sandbox: this.opts.sandboxMode === "read-only" ? "read-only" : "workspace-write",
349
+ developerInstructions: this.opts.systemPromptAppend ?? null,
350
+ });
351
+ }
352
+ catch (error) {
353
+ if (this._sessionRef) {
354
+ const detail = error instanceof Error ? error.message : String(error);
355
+ const availability = {
356
+ version: 1, status: "unavailable", checkedAt: new Date().toISOString(),
357
+ reason: /not found|unknown thread|no thread/i.test(detail) ? "not-found" : "attach-failed", detail, sessionRef: this._sessionRef,
358
+ };
359
+ this.sessionAvailability = availability;
360
+ throw new SessionUnavailableError({ runtime: "codex", phase: "attach", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: detail, availability, cause: error });
361
+ }
362
+ throw error;
363
+ }
144
364
  const thread = result.thread;
145
- this._sessionId = String(thread?.id ?? this._sessionId ?? "") || undefined;
365
+ const returnedSessionId = String(thread?.id ?? this._sessionId ?? "") || undefined;
366
+ if (this._sessionRef && returnedSessionId !== this._sessionRef.sessionId) {
367
+ const availability = {
368
+ version: 1,
369
+ status: "unavailable",
370
+ checkedAt: new Date().toISOString(),
371
+ reason: "attach-failed",
372
+ detail: `Codex resumed thread ${returnedSessionId ?? "without an ID"} instead of requested thread ${this._sessionRef.sessionId}`,
373
+ sessionRef: this._sessionRef,
374
+ };
375
+ this.sessionAvailability = availability;
376
+ throw new SessionUnavailableError({
377
+ runtime: "codex",
378
+ phase: "attach",
379
+ dispatchState: "not-sent",
380
+ executable: this.opts.runtimeExecutable ?? "codex",
381
+ cwd: this.opts.cwd,
382
+ diagnostics: availability.detail,
383
+ availability,
384
+ });
385
+ }
386
+ this._sessionId = returnedSessionId;
146
387
  if (!this._sessionId)
147
388
  throw new Error(`${method} did not return a thread ID`);
389
+ const providerCwd = typeof thread?.cwd === "string" ? canonicalSessionPath(thread.cwd) : undefined;
390
+ if (this._sessionRef && providerCwd && providerCwd !== canonicalSessionPath(this._sessionRef.cwd)) {
391
+ const availability = { version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "cwd-mismatch", observedCwd: providerCwd, detail: `Codex thread cwd ${providerCwd} does not match ${this._sessionRef.cwd}`, sessionRef: this._sessionRef };
392
+ this.sessionAvailability = availability;
393
+ throw new SessionUnavailableError({ runtime: "codex", phase: "attach", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: availability.detail, availability });
394
+ }
395
+ if (!this._sessionRef) {
396
+ this._sessionRef = createProviderSessionRef({
397
+ provider: "codex", sessionId: this._sessionId, cwd: providerCwd ?? this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
398
+ role: this.opts.sessionRole, stream: this.opts.sessionStream, generation: this.opts.sessionGeneration,
399
+ workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
400
+ });
401
+ }
402
+ else {
403
+ this._sessionRef = { ...this._sessionRef, validatedAt: new Date().toISOString() };
404
+ }
405
+ this.sessionAvailability = { version: 1, status: "available", checkedAt: new Date().toISOString(), observedCwd: providerCwd ?? canonicalSessionPath(this.opts.cwd), sessionRef: this._sessionRef };
148
406
  this.threadAttached = true;
149
407
  this.eventQueue.push({ kind: "session-transition", transition: method === "thread/resume" ? "resumed" : "started" });
150
408
  }
@@ -155,6 +413,7 @@ export class CodexAdapter {
155
413
  this.stderr = "";
156
414
  const executable = this.opts.runtimeExecutable ?? "codex";
157
415
  const child = spawn(executable, this.buildAppServerArgs(), { cwd: this.opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
416
+ this.spawnedNativeTokenLimit = this.nativeTokenLimit;
158
417
  this.process = child;
159
418
  // A short-lived server may close stdin before its stderr/exit event. The
160
419
  // close handler owns rejection so diagnostics include the complete stderr.
@@ -166,8 +425,10 @@ export class CodexAdapter {
166
425
  }
167
426
  catch { /* ignore non-protocol stdout */ } });
168
427
  child.stderr.on("data", (chunk) => { this.stderr = `${this.stderr}${chunk.toString()}`.slice(-8192); });
169
- child.on("error", (error) => this.disconnect(error));
170
- child.on("close", (code) => this.disconnect(new Error(`Codex app-server exited with code ${code ?? "unknown"}`)));
428
+ child.on("error", (error) => { if (this.process === child)
429
+ this.disconnect(error); });
430
+ child.on("close", (code) => { if (this.process === child)
431
+ this.disconnect(new Error(`Codex app-server exited with code ${code ?? "unknown"}`)); });
171
432
  await this.request("initialize", { clientInfo: { name: "rafi", title: "Rafi", version: "1" }, capabilities: { experimentalApi: true, requestAttestation: false } });
172
433
  this.write({ method: "initialized", params: {} });
173
434
  this.initialized = true;
@@ -202,8 +463,29 @@ export class CodexAdapter {
202
463
  if (!message.method)
203
464
  return;
204
465
  const params = message.params ?? {};
205
- if (message.method === "item/started") {
466
+ if (message.method === "turn/started") {
467
+ const turn = params.turn;
468
+ if (typeof turn?.id === "string")
469
+ this.activeTurnId = turn.id;
470
+ }
471
+ else if (message.method === "item/started") {
206
472
  const item = params.item;
473
+ if (this.setupTurnActive && item && isToolItem(item.type)) {
474
+ this.setupToolAttempted = true;
475
+ if (this.activeTurnId && this._sessionId)
476
+ void this.request("turn/interrupt", { threadId: this._sessionId, turnId: this.activeTurnId }).catch(() => { });
477
+ }
478
+ if (item?.type === "contextCompaction") {
479
+ const providerSequence = ++this.compactionSequence;
480
+ const providerEventId = String(item.id ?? `codex-compaction-${providerSequence}`);
481
+ const origin = this.manualCompactionPending ? "rafi-manual" : "provider-auto";
482
+ this.activeCompaction = { providerEventId, providerSequence, origin, usageRevision: this.usageRevision };
483
+ this.eventQueue.push({
484
+ kind: "context-compaction", phase: "started", origin, providerEventId, providerSequence,
485
+ provider: "codex", sessionId: this._sessionId, sessionRef: this._sessionRef,
486
+ observedAt: new Date().toISOString(),
487
+ });
488
+ }
207
489
  const activity = codexItemActivity(item);
208
490
  if (activity)
209
491
  this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
@@ -217,6 +499,21 @@ export class CodexAdapter {
217
499
  else if (item?.type === "commandExecution")
218
500
  this.eventQueue.push({ kind: "tool", name: "command_execution", input: { command: item.command } });
219
501
  else {
502
+ if (item?.type === "contextCompaction") {
503
+ const active = this.activeCompaction;
504
+ const providerSequence = active?.providerSequence ?? ++this.compactionSequence;
505
+ const providerEventId = String(item.id ?? active?.providerEventId ?? `codex-compaction-${providerSequence}`);
506
+ const origin = active?.origin ?? (this.manualCompactionPending ? "rafi-manual" : "provider-auto");
507
+ const failed = item.status === "failed" || item.status === "error" || item.error !== undefined;
508
+ this.eventQueue.push({
509
+ kind: "context-compaction", phase: failed ? "failed" : "succeeded", origin, providerEventId, providerSequence,
510
+ provider: "codex", sessionId: this._sessionId, sessionRef: this._sessionRef,
511
+ observedAt: new Date().toISOString(),
512
+ ...(failed ? { reason: String(item.error?.message ?? item.error ?? "Codex context compaction failed") } : {}),
513
+ ...(!failed && this.usage && (!active || this.usageRevision > active.usageRevision) ? { postCompactSample: { ...this.usage, source: "post-compact" } } : {}),
514
+ });
515
+ this.activeCompaction = undefined;
516
+ }
220
517
  const activity = codexItemActivity(item, true);
221
518
  if (activity)
222
519
  this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
@@ -233,10 +530,30 @@ export class CodexAdapter {
233
530
  else if (message.method === "thread/tokenUsage/updated") {
234
531
  const tokenUsage = params.tokenUsage;
235
532
  const total = tokenUsage?.total;
236
- const used = Number(total?.totalTokens ?? 0);
237
- const maximum = Number(tokenUsage?.modelContextWindow ?? 0) || undefined;
238
- this.usage = { used, maximum, ...(maximum ? { percentage: used / maximum * 100 } : {}) };
239
- this.eventQueue.push({ kind: "context-usage", ...this.usage });
533
+ const last = tokenUsage?.last;
534
+ // `last` is active context occupancy. `total` is lifetime session usage.
535
+ const used = Number(last?.totalTokens);
536
+ const maximum = Number(tokenUsage?.modelContextWindow);
537
+ const observedAt = new Date().toISOString();
538
+ const totalInput = optionalNonNegative(total?.inputTokens);
539
+ const totalOutput = optionalNonNegative(total?.outputTokens);
540
+ const sessionTotal = optionalNonNegative(total?.totalTokens);
541
+ if (totalInput !== undefined || totalOutput !== undefined || sessionTotal !== undefined) {
542
+ this.providerSessionUsage = { inputTokens: totalInput, outputTokens: totalOutput, totalTokens: sessionTotal, observedAt, source: "provider" };
543
+ }
544
+ this.lastTurnTokens = {
545
+ inputTokens: optionalNonNegative(last?.inputTokens),
546
+ outputTokens: optionalNonNegative(last?.outputTokens),
547
+ };
548
+ if (Number.isFinite(used) && used >= 0 && Number.isFinite(maximum) && maximum > 0) {
549
+ const sequence = ++this.contextSequence;
550
+ this.usage = {
551
+ used, maximum, percentage: used / maximum * 100, observedAt, source: "provider-event", sequence,
552
+ ...(this._sessionId ? { sessionId: this._sessionId } : {}), ...(this.opts.model ? { model: this.opts.model } : {}),
553
+ };
554
+ this.usageRevision += 1;
555
+ this.eventQueue.push({ kind: "context-usage", ...this.usage });
556
+ }
240
557
  }
241
558
  else if (message.method === "error") {
242
559
  const error = params.error;
@@ -252,8 +569,27 @@ export class CodexAdapter {
252
569
  waiter.predicate(params) ? waiter.resolve(params) : remaining.push(waiter);
253
570
  this.notificationWaiters.set(message.method, remaining);
254
571
  }
255
- waitFor(method, predicate) {
256
- return new Promise((resolve, reject) => { const waiters = this.notificationWaiters.get(method) ?? []; waiters.push({ predicate, resolve, reject }); this.notificationWaiters.set(method, waiters); });
572
+ waitFor(method, predicate, timeoutMs) {
573
+ return new Promise((resolve, reject) => {
574
+ let timeout;
575
+ const waiter = {
576
+ predicate,
577
+ resolve: (params) => { if (timeout)
578
+ clearTimeout(timeout); resolve(params); },
579
+ reject: (error) => { if (timeout)
580
+ clearTimeout(timeout); reject(error); },
581
+ };
582
+ const waiters = this.notificationWaiters.get(method) ?? [];
583
+ waiters.push(waiter);
584
+ this.notificationWaiters.set(method, waiters);
585
+ if (timeoutMs !== undefined) {
586
+ timeout = setTimeout(() => {
587
+ const active = this.notificationWaiters.get(method) ?? [];
588
+ this.notificationWaiters.set(method, active.filter((candidate) => candidate !== waiter));
589
+ reject(new Error(`Timed out waiting for Codex app-server notification ${method}`));
590
+ }, timeoutMs);
591
+ }
592
+ });
257
593
  }
258
594
  disconnect(error) {
259
595
  const child = this.process;
@@ -270,6 +606,24 @@ export class CodexAdapter {
270
606
  waiter.reject(error);
271
607
  this.notificationWaiters.clear();
272
608
  }
609
+ /** Restart only app-server; the scoped thread remains the continuity boundary. */
610
+ async restartTransport() {
611
+ if (this.activeTurnId)
612
+ throw new Error("cannot restart Codex app-server while a turn is active");
613
+ const child = this.process;
614
+ this.process = undefined;
615
+ this.initialized = false;
616
+ this.threadAttached = false;
617
+ if (child && child.exitCode === null)
618
+ child.kill("SIGTERM");
619
+ for (const pending of this.pending.values())
620
+ pending.reject(new Error("Codex app-server restarted for context policy"));
621
+ this.pending.clear();
622
+ for (const waiters of this.notificationWaiters.values())
623
+ for (const waiter of waiters)
624
+ waiter.reject(new Error("Codex app-server restarted for context policy"));
625
+ this.notificationWaiters.clear();
626
+ }
273
627
  buildSkillsAppendix() {
274
628
  if (!this.opts.skills?.length)
275
629
  return undefined;
@@ -277,6 +631,23 @@ export class CodexAdapter {
277
631
  return blocks.length ? ["# Preloaded Skills", "Use the following skills for this run.", ...blocks].join("\n\n") : undefined;
278
632
  }
279
633
  }
634
+ function optionalNonNegative(value) {
635
+ const number = Number(value);
636
+ return Number.isFinite(number) && number >= 0 ? number : undefined;
637
+ }
638
+ function tokenCeiling(maximum, percentage) {
639
+ return Math.max(1, Math.floor(maximum * percentage / 100));
640
+ }
641
+ function validateContextPolicy(policy) {
642
+ if (!Number.isInteger(policy.configuredThresholdPercent) || policy.configuredThresholdPercent < 1 || policy.configuredThresholdPercent > 99) {
643
+ throw new Error("configured context threshold must be an integer from 1 to 99");
644
+ }
645
+ if (!Number.isSafeInteger(policy.compactMaximum) || policy.compactMaximum < 1)
646
+ throw new Error("compact maximum must be a positive safe integer");
647
+ }
648
+ function isToolItem(type) {
649
+ return type === "commandExecution" || type === "fileChange" || type === "mcpToolCall" || type === "dynamicToolCall" || type === "webSearch";
650
+ }
280
651
  function activityPhase(phase) {
281
652
  if (phase === "planning")
282
653
  return "planning";