ai-foreman 1.7.7 → 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.
@@ -23,6 +23,7 @@ export function parseCodexLine(raw) {
23
23
  return { events: [{ kind: "error", message: String(raw.error?.message ?? JSON.stringify(raw)) }] };
24
24
  return { events: [] };
25
25
  }
26
+ const CODEX_COMPACTION_TIMEOUT_MS = 120_000;
26
27
  /** Persistent JSON-RPC controller for one live Codex thread. */
27
28
  export class CodexAdapter {
28
29
  opts;
@@ -40,9 +41,21 @@ export class CodexAdapter {
40
41
  activeText = [];
41
42
  usage;
42
43
  usageRevision = 0;
44
+ contextSequence = 0;
43
45
  providerSessionUsage;
44
46
  lastTurnTokens;
45
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;
46
59
  constructor(opts) {
47
60
  this.opts = opts;
48
61
  this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
@@ -52,7 +65,13 @@ export class CodexAdapter {
52
65
  return [this.opts.systemPromptAppend, this.buildSkillsAppendix(), instruction]
53
66
  .filter((part) => Boolean(part)).join("\n\n");
54
67
  }
55
- 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
+ }
56
75
  /** Deprecated fixture helper. Runtime execution uses only app-server. */
57
76
  buildArgs(instruction) {
58
77
  const args = ["exec", "--json", "--skip-git-repo-check", "--sandbox", this.opts.sandboxMode ?? "workspace-write", "-C", this.opts.cwd];
@@ -68,9 +87,11 @@ export class CodexAdapter {
68
87
  return args;
69
88
  }
70
89
  async sendTurn(instruction) {
90
+ if (this.opts.contextManagementPolicy && !this.contextPrepared)
91
+ await this.prepareContextManagement(this.opts.contextManagementPolicy);
71
92
  return withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction));
72
93
  }
73
- async sendTurnInternal(instruction) {
94
+ async sendTurnInternal(instruction, setup = false) {
74
95
  if (this.closed)
75
96
  throw new Error("builder is closed");
76
97
  this.eventQueue.push({ kind: "activity", state: "starting Codex turn", provider: "codex", model: this.opts.model });
@@ -79,29 +100,49 @@ export class CodexAdapter {
79
100
  await this.ensureThread();
80
101
  this.activeText = [];
81
102
  this.lastTurnTokens = undefined;
103
+ this.setupTurnActive = setup;
104
+ this.setupToolAttempted = false;
82
105
  const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId);
83
106
  turnStartDispatched = true;
84
- await this.request("turn/start", {
107
+ const started = await this.request("turn/start", {
85
108
  threadId: this._sessionId,
86
- input: [{ type: "text", text: this.buildInstruction(instruction), text_elements: [] }],
109
+ input: [{ type: "text", text: setup ? instruction : this.buildInstruction(instruction), text_elements: [] }],
87
110
  cwd: this.opts.cwd,
88
111
  model: this.opts.model ?? null,
89
112
  effort: this.opts.effort ?? (this.opts.fast ? "low" : null),
90
113
  });
114
+ const startedTurn = started.turn;
115
+ this.activeTurnId = typeof startedTurn?.id === "string" ? startedTurn.id : undefined;
91
116
  const params = await completion;
92
117
  const turn = params.turn;
93
118
  const failed = turn?.status === "failed";
119
+ const interrupted = turn?.status === "interrupted" || Boolean(this.boundaryInterrupt);
94
120
  const error = turn?.error;
95
121
  const text = this.activeText.join("\n");
96
122
  const result = {
97
123
  text: failed ? normalizeRuntimeErrorText("codex", String(error?.message ?? text), null, "app-server turn") : text,
98
124
  isError: failed, numTurns: 1, costUsd: 0, costAuthoritative: false,
99
125
  ...(this.lastTurnTokens ?? {}),
126
+ ...(interrupted ? { interrupted: { reason: "compaction-boundary", ...(this.boundaryInterrupt?.providerEventId ? { providerEventId: this.boundaryInterrupt.providerEventId } : {}) } } : {}),
100
127
  };
128
+ if (setup && this.setupToolAttempted)
129
+ throw new Error("Codex context setup attempted to invoke a tool; native context capability was not established");
101
130
  this.eventQueue.push({ kind: "turn-complete", result });
102
131
  return result;
103
132
  }
104
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
+ }
105
146
  if (error instanceof SessionUnavailableError) {
106
147
  const result = sessionUnavailableResult(error);
107
148
  this.eventQueue.push({ kind: "error", message: result.text });
@@ -123,10 +164,87 @@ export class CodexAdapter {
123
164
  this.eventQueue.push({ kind: "turn-complete", result });
124
165
  return result;
125
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
+ }
126
243
  }
127
244
  async compact() {
128
245
  try {
129
246
  await this.ensureThread();
247
+ this.manualCompactionPending = true;
130
248
  this.eventQueue.push({ kind: "session-transition", transition: "compacting" });
131
249
  const usageRevision = this.usageRevision;
132
250
  // A pre-compaction observation must never be mistaken for proof that the
@@ -137,12 +255,12 @@ export class CodexAdapter {
137
255
  const done = this.waitFor("item/completed", (params) => {
138
256
  const item = params.item;
139
257
  return params.threadId === this._sessionId && item?.type === "contextCompaction";
140
- }, 30_000);
258
+ }, CODEX_COMPACTION_TIMEOUT_MS);
141
259
  const postCompactUsage = this.waitFor("thread/tokenUsage/updated", (params) => {
142
260
  return (params.threadId === undefined || params.threadId === this._sessionId)
143
261
  && this.usageRevision > usageRevision
144
262
  && this.usage !== undefined;
145
- }, 30_000);
263
+ }, CODEX_COMPACTION_TIMEOUT_MS);
146
264
  await Promise.all([
147
265
  this.request("thread/compact/start", { threadId: this._sessionId }),
148
266
  done,
@@ -156,6 +274,9 @@ export class CodexAdapter {
156
274
  return { ok: false, error: error.message, failure: error.failure };
157
275
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
158
276
  }
277
+ finally {
278
+ this.manualCompactionPending = false;
279
+ }
159
280
  }
160
281
  async contextUsage() { return this.usage; }
161
282
  async sessionUsage() { return this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined; }
@@ -292,6 +413,7 @@ export class CodexAdapter {
292
413
  this.stderr = "";
293
414
  const executable = this.opts.runtimeExecutable ?? "codex";
294
415
  const child = spawn(executable, this.buildAppServerArgs(), { cwd: this.opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
416
+ this.spawnedNativeTokenLimit = this.nativeTokenLimit;
295
417
  this.process = child;
296
418
  // A short-lived server may close stdin before its stderr/exit event. The
297
419
  // close handler owns rejection so diagnostics include the complete stderr.
@@ -303,8 +425,10 @@ export class CodexAdapter {
303
425
  }
304
426
  catch { /* ignore non-protocol stdout */ } });
305
427
  child.stderr.on("data", (chunk) => { this.stderr = `${this.stderr}${chunk.toString()}`.slice(-8192); });
306
- child.on("error", (error) => this.disconnect(error));
307
- 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"}`)); });
308
432
  await this.request("initialize", { clientInfo: { name: "rafi", title: "Rafi", version: "1" }, capabilities: { experimentalApi: true, requestAttestation: false } });
309
433
  this.write({ method: "initialized", params: {} });
310
434
  this.initialized = true;
@@ -339,8 +463,29 @@ export class CodexAdapter {
339
463
  if (!message.method)
340
464
  return;
341
465
  const params = message.params ?? {};
342
- 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") {
343
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
+ }
344
489
  const activity = codexItemActivity(item);
345
490
  if (activity)
346
491
  this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
@@ -354,6 +499,21 @@ export class CodexAdapter {
354
499
  else if (item?.type === "commandExecution")
355
500
  this.eventQueue.push({ kind: "tool", name: "command_execution", input: { command: item.command } });
356
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
+ }
357
517
  const activity = codexItemActivity(item, true);
358
518
  if (activity)
359
519
  this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
@@ -371,7 +531,8 @@ export class CodexAdapter {
371
531
  const tokenUsage = params.tokenUsage;
372
532
  const total = tokenUsage?.total;
373
533
  const last = tokenUsage?.last;
374
- const used = Number(total?.totalTokens);
534
+ // `last` is active context occupancy. `total` is lifetime session usage.
535
+ const used = Number(last?.totalTokens);
375
536
  const maximum = Number(tokenUsage?.modelContextWindow);
376
537
  const observedAt = new Date().toISOString();
377
538
  const totalInput = optionalNonNegative(total?.inputTokens);
@@ -385,7 +546,11 @@ export class CodexAdapter {
385
546
  outputTokens: optionalNonNegative(last?.outputTokens),
386
547
  };
387
548
  if (Number.isFinite(used) && used >= 0 && Number.isFinite(maximum) && maximum > 0) {
388
- this.usage = { used, maximum, percentage: used / maximum * 100, observedAt, source: "provider-event" };
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
+ };
389
554
  this.usageRevision += 1;
390
555
  this.eventQueue.push({ kind: "context-usage", ...this.usage });
391
556
  }
@@ -441,6 +606,24 @@ export class CodexAdapter {
441
606
  waiter.reject(error);
442
607
  this.notificationWaiters.clear();
443
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
+ }
444
627
  buildSkillsAppendix() {
445
628
  if (!this.opts.skills?.length)
446
629
  return undefined;
@@ -452,6 +635,19 @@ function optionalNonNegative(value) {
452
635
  const number = Number(value);
453
636
  return Number.isFinite(number) && number >= 0 ? number : undefined;
454
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
+ }
455
651
  function activityPhase(phase) {
456
652
  if (phase === "planning")
457
653
  return "planning";
@@ -1,5 +1,5 @@
1
1
  import type { AgentRuntime } from "../runtimeAuth.js";
2
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, TurnResult } from "./types.js";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextManagementPolicy, ContextUsage, InterruptResult, ManagedTurnDispatcher, PreparedContextManagement, ProviderSessionUsage, TurnResult } from "./types.js";
3
3
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
4
4
  export type TurnRecoveryChoice = "retry" | "switch" | "cancel";
5
5
  export interface RecoveringAdapterOptions {
@@ -21,6 +21,7 @@ export declare class RecoveringAdapter implements BuilderAdapter {
21
21
  private readonly eventQueue;
22
22
  private eventPump;
23
23
  private closed;
24
+ private turnDispatcher?;
24
25
  constructor(opts: RecoveringAdapterOptions);
25
26
  get agent(): "claude" | "codex";
26
27
  sendTurn(text: string): Promise<TurnResult>;
@@ -29,6 +30,10 @@ export declare class RecoveringAdapter implements BuilderAdapter {
29
30
  adoptSessionRef(ref: ProviderSessionRefV1): void;
30
31
  validateSession(): Promise<SessionAvailabilityV1>;
31
32
  compact(): Promise<CompactResult>;
33
+ prepareContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
34
+ updateContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
35
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<InterruptResult>;
36
+ installManagedTurnDispatcher(dispatcher: ManagedTurnDispatcher): void;
32
37
  contextUsage(): Promise<ContextUsage | undefined>;
33
38
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
34
39
  events(): AsyncIterable<BuilderEvent>;
@@ -9,6 +9,7 @@ export class RecoveringAdapter {
9
9
  eventQueue = new AsyncQueue();
10
10
  eventPump;
11
11
  closed = false;
12
+ turnDispatcher;
12
13
  constructor(opts) {
13
14
  this.opts = opts;
14
15
  this.adapter = opts.initial;
@@ -20,7 +21,8 @@ export class RecoveringAdapter {
20
21
  }
21
22
  async sendTurn(text) {
22
23
  while (true) {
23
- const result = await this.adapter.sendTurn(text);
24
+ const invoke = () => this.adapter.sendTurn(text);
25
+ const result = this.turnDispatcher ? await this.turnDispatcher(text, invoke) : await invoke();
24
26
  const sessionRef = this.adapter.sessionRef?.();
25
27
  if (sessionRef && this.opts.onSessionRef) {
26
28
  try {
@@ -67,6 +69,12 @@ export class RecoveringAdapter {
67
69
  return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "wrapped adapter does not expose scoped session validation" });
68
70
  }
69
71
  compact() { return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
72
+ prepareContextManagement(policy) { if (!this.adapter.prepareContextManagement)
73
+ return Promise.reject(new Error("native context management unavailable")); return this.adapter.prepareContextManagement(policy); }
74
+ updateContextManagement(policy) { if (!this.adapter.updateContextManagement)
75
+ return Promise.reject(new Error("native context reconfiguration unavailable")); return this.adapter.updateContextManagement(policy); }
76
+ interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "compaction-boundary interruption unavailable", providerEventId }); }
77
+ installManagedTurnDispatcher(dispatcher) { this.turnDispatcher = dispatcher; }
70
78
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
71
79
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
72
80
  events() {
@@ -59,7 +59,55 @@ export interface TurnResult {
59
59
  inputTokens?: number;
60
60
  outputTokens?: number;
61
61
  failure?: RuntimeFailure;
62
+ /** A provider turn stopped at a context-compaction boundary, not a failure. */
63
+ interrupted?: {
64
+ reason: "compaction-boundary";
65
+ providerEventId?: string;
66
+ };
62
67
  }
68
+ export type ContextCompactionOrigin = "provider-auto" | "rafi-manual" | "recovery" | "boundary";
69
+ export type ContextCompactionPhase = "started" | "succeeded" | "failed";
70
+ /** Native context policy installed before a Builder/QA role may do real work. */
71
+ export interface ContextManagementPolicy {
72
+ role: "builder" | "qa";
73
+ configuredThresholdPercent: number;
74
+ compactMaximum: number;
75
+ settingsRevision: number;
76
+ model: string;
77
+ /** Continue a provider-monotonic sequence after process recovery. */
78
+ providerSequenceStart?: number;
79
+ /** A persisted, model-matched maximum may avoid a discovery turn. */
80
+ knownModelContextWindow?: number;
81
+ /** Native compaction stays enabled until the host vetoes a boundary. */
82
+ nativeCompactionEnabled?: boolean;
83
+ }
84
+ export interface PreparedContextManagement {
85
+ modelContextWindow: number;
86
+ configuredTokenLimit: number;
87
+ installedNativeTokenLimit: number;
88
+ installedNativePercent: number;
89
+ sample?: ContextUsage;
90
+ }
91
+ export interface ContextCompactionEvent {
92
+ kind: "context-compaction";
93
+ phase: ContextCompactionPhase;
94
+ origin: ContextCompactionOrigin;
95
+ providerEventId: string;
96
+ providerSequence: number;
97
+ provider: "claude" | "codex";
98
+ sessionId?: string;
99
+ sessionRef?: ProviderSessionRefV1;
100
+ observedAt: string;
101
+ postCompactSample?: ContextUsage;
102
+ reason?: string;
103
+ }
104
+ export interface InterruptResult {
105
+ ok: boolean;
106
+ error?: string;
107
+ providerEventId?: string;
108
+ }
109
+ /** Host-owned gate used by wrappers that need additional provider turns. */
110
+ export type ManagedTurnDispatcher = (text: string, invoke: () => Promise<TurnResult>) => Promise<TurnResult>;
63
111
  /** Observability events emitted while a builder works. */
64
112
  export type BuilderEvent = {
65
113
  kind: "text";
@@ -97,7 +145,10 @@ export type BuilderEvent = {
97
145
  percentage?: number;
98
146
  observedAt?: string;
99
147
  source?: "provider-event" | "provider-query" | "post-compact";
100
- } | {
148
+ sequence?: number;
149
+ sessionId?: string;
150
+ model?: string;
151
+ } | ContextCompactionEvent | {
101
152
  kind: "error";
102
153
  message: string;
103
154
  };
@@ -107,6 +158,10 @@ export interface ContextUsage {
107
158
  percentage?: number;
108
159
  observedAt?: string;
109
160
  source?: "provider-event" | "provider-query" | "post-compact";
161
+ /** Monotonic within one scoped provider session. */
162
+ sequence?: number;
163
+ sessionId?: string;
164
+ model?: string;
110
165
  }
111
166
  export interface ProviderSessionUsage {
112
167
  inputTokens?: number;
@@ -163,6 +218,8 @@ export interface BuilderAdapterOptions {
163
218
  systemPromptAppend?: string;
164
219
  /** Skill names to preload for this session (Claude: lazy-loaded; Codex: flattened). */
165
220
  skills?: string[];
221
+ /** Builder/QA native policy used even for setup and handoff-acceptance turns. */
222
+ contextManagementPolicy?: ContextManagementPolicy;
166
223
  }
167
224
  export interface BuilderAdapter {
168
225
  readonly agent: "claude" | "codex";
@@ -178,6 +235,14 @@ export interface BuilderAdapter {
178
235
  validateSession?(): Promise<SessionAvailabilityV1>;
179
236
  /** Provider-native compaction on the exact live conversation. */
180
237
  compact?(): Promise<CompactResult>;
238
+ /** Initialize the session and install native in-turn context controls. */
239
+ prepareContextManagement?(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
240
+ /** Safely reconfigure native in-turn controls on the same session. */
241
+ updateContextManagement?(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
242
+ /** Stop only at a provider-observed compaction boundary. */
243
+ interruptTurnAtCompactionBoundary?(providerEventId?: string): Promise<InterruptResult>;
244
+ /** Route wrapper-owned repair/retry/follow-up turns through the role gate. */
245
+ installManagedTurnDispatcher?(dispatcher: ManagedTurnDispatcher): void;
181
246
  /** Truthful provider context occupancy, when exposed by the provider. */
182
247
  contextUsage?(): Promise<ContextUsage | undefined>;
183
248
  /** Provider/session cumulative totals; distinct from live context occupancy. */
@@ -1,4 +1,4 @@
1
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "../adapters/types.js";
1
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextManagementPolicy, ContextUsage, InterruptResult, ManagedTurnDispatcher, PreparedContextManagement, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "../adapters/types.js";
2
2
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
3
  export interface CurrentWorkflowIdentity {
4
4
  worktree: string;
@@ -26,6 +26,10 @@ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
26
26
  adoptSessionRef(ref: ProviderSessionRefV1): void;
27
27
  validateSession(): Promise<SessionAvailabilityV1>;
28
28
  compact(): Promise<CompactResult>;
29
+ prepareContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
30
+ updateContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
31
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<InterruptResult>;
32
+ installManagedTurnDispatcher(dispatcher: ManagedTurnDispatcher): void;
29
33
  contextUsage(): Promise<ContextUsage | undefined>;
30
34
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
31
35
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
@@ -48,6 +48,12 @@ export class CurrentWorkflowGuardAdapter {
48
48
  adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
49
49
  validateSession() { assertCurrentWorkflowIdentity(this.cwd, this.expected); return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
50
50
  async compact() { assertCurrentWorkflowIdentity(this.cwd, this.expected); const result = await (this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" })); assertCurrentWorkflowIdentity(this.cwd, this.expected); return result; }
51
+ async prepareContextManagement(policy) { assertCurrentWorkflowIdentity(this.cwd, this.expected); if (!this.adapter.prepareContextManagement)
52
+ throw new Error("native context management unavailable"); const result = await this.adapter.prepareContextManagement(policy); assertCurrentWorkflowIdentity(this.cwd, this.expected); return result; }
53
+ async updateContextManagement(policy) { assertCurrentWorkflowIdentity(this.cwd, this.expected); if (!this.adapter.updateContextManagement)
54
+ throw new Error("native context reconfiguration unavailable"); const result = await this.adapter.updateContextManagement(policy); assertCurrentWorkflowIdentity(this.cwd, this.expected); return result; }
55
+ interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "compaction-boundary interruption unavailable", providerEventId }); }
56
+ installManagedTurnDispatcher(dispatcher) { this.adapter.installManagedTurnDispatcher?.(dispatcher); }
51
57
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
52
58
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
53
59
  switchSettings(settings) { return this.adapter.switchSettings?.(settings) ?? Promise.resolve({ ok: false, error: "settings switch unavailable" }); }