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.
package/README.md CHANGED
@@ -115,7 +115,9 @@ What each part does:
115
115
  | `--branch-prefix <prefix>` | `feature` / `rafi` / another valid Git prefix | `feature` | Prefix for generated ticket branches. Explicit prefixes are preserved verbatim. |
116
116
  | `--max-branch-depth <n>` | positive integer | `5` | Maximum selected branch/PR stack depth (root PR is depth 1). |
117
117
  | `--show-session-cost` / `--hide-session-cost` | flags | saved role preferences | Show authoritative provider cost or trustworthy cumulative tokens for Builder and QA; does not change context occupancy display. |
118
- | `--auto-compact-threshold <percent>` | integer `1`–`99` | saved Builder value, then `50` | Initial run threshold override. A newer persistent settings revision wins at the next safe boundary. |
118
+ | `--auto-compact-threshold <percent>` | integer `1`–`99` | saved Builder value, then `50` | Initial Builder threshold override. A later explicit Builder-threshold revision wins for the active run. |
119
+
120
+ Builder and QA context ceilings are configured independently (default `50%`, maximum `10` successful compactions per scoped provider session). Claude and Codex install the ceiling as a native in-turn auto-compaction limit before real work begins; Rafi verifies the post-compaction occupancy and counts provider-native and host-requested compactions together. An ineffective or failed compact receives one bounded retry, followed by a validated cumulative handoff. Status output reports the configured and installed ceilings, lifecycle state, scoped count/maximum, and settings revision without substituting cumulative session tokens for active context occupancy.
119
121
 
120
122
  Auto-detected tracker files:
121
123
 
@@ -607,6 +609,14 @@ pnpm build
607
609
  pnpm dev -- start ../../examples/dummy-project --steps 2
608
610
  ```
609
611
 
612
+ Changes to provider adapters or context lifecycle enforcement also require the opt-in authenticated gate:
613
+
614
+ ```bash
615
+ pnpm test:live-providers
616
+ ```
617
+
618
+ It exercises Claude and Codex as both Builder and QA with a low native ceiling, verifies in-turn compaction and same-session continuation, then verifies a maximum-one boundary interruption and validated fresh handoff. Set `RAFI_LIVE_CONTEXT_THRESHOLD_PERCENT` to an integer from `1` through `10` to override its default `10%` ceiling. Very low Claude ceilings can intentionally fail the capability check when they fall below the SDK's minimum native window. The gate uses locally authenticated provider CLIs and is intentionally skipped by the ordinary test suite.
619
+
610
620
  Package output:
611
621
 
612
622
  - The package publishes an `ai-foreman` binary from `dist/index.js`.
package/dist/activity.js CHANGED
@@ -236,8 +236,8 @@ export function reportBuilderEvent(event) {
236
236
  reporter.update("processing agent response");
237
237
  else if (event.kind === "session-transition")
238
238
  reporter.update(`session ${event.transition}`, event.detail);
239
- else if (event.kind === "context-usage")
240
- reporter.pulse(event.percentage === undefined ? undefined : `context ${event.percentage.toFixed(0)}%`);
239
+ else if (event.kind === "context-compaction")
240
+ reporter.update(event.phase === "started" ? "compacting context" : event.phase === "succeeded" ? "context compacted" : "context compaction failed", `${event.origin} ${event.providerEventId}`, { provider: event.provider });
241
241
  else if (event.kind === "turn-complete")
242
242
  reporter.update(event.result.isError ? "agent turn failed" : "agent turn complete");
243
243
  else if (event.kind === "error")
@@ -1,7 +1,7 @@
1
1
  import type { SDKSessionInfo, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
2
2
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
3
3
  export declare function requireClaudeSDK(): Promise<typeof import("@anthropic-ai/claude-agent-sdk")>;
4
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, PermissionDecision, ProviderSettingSwitch, ProviderSessionUsage, TurnResult } from "./types.js";
4
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextManagementPolicy, ContextUsage, InterruptResult, PermissionDecision, PreparedContextManagement, ProviderSettingSwitch, ProviderSessionUsage, TurnResult } from "./types.js";
5
5
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
6
6
  /**
7
7
  * Pure function: build the `options` object passed to `query()`.
@@ -43,11 +43,14 @@ export declare function claudeApiRetryEvent(message: {
43
43
  export declare class ClaudeAdapter implements BuilderAdapter {
44
44
  private readonly opts;
45
45
  readonly agent: "claude";
46
- private readonly inbox;
46
+ private inbox;
47
47
  private readonly eventQueue;
48
- private readonly query;
49
- private readonly abort;
50
- private readonly pumpDone;
48
+ private query;
49
+ private abort;
50
+ private pumpDone;
51
+ private readonly queryFactory;
52
+ private transportGeneration;
53
+ private transportObservedSessionId?;
51
54
  private _sessionId?;
52
55
  private _sessionRef?;
53
56
  private readonly stderrChunks;
@@ -55,6 +58,19 @@ export declare class ClaudeAdapter implements BuilderAdapter {
55
58
  private structuredError?;
56
59
  private apiErrorStatus?;
57
60
  private compactResult?;
61
+ private contextPolicy?;
62
+ private contextSequence;
63
+ private compactionSequence;
64
+ private activeCompaction?;
65
+ private manualCompactionPending;
66
+ private boundaryInterrupt?;
67
+ private compactionTerminalObserved;
68
+ private lastContextUsage?;
69
+ /** Full provider context capacity, before Rafi narrows Claude's auto-compact window. */
70
+ private modelContextWindow?;
71
+ /** SDK-owned space between autoCompactWindow and the actual used-token trigger. */
72
+ private nativeAutoCompactReserve?;
73
+ private contextPrepared;
58
74
  private cumulativeUsage;
59
75
  private pending?;
60
76
  private terminalResult?;
@@ -62,6 +78,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
62
78
  private closed;
63
79
  static create(opts: BuilderAdapterOptions): Promise<ClaudeAdapter>;
64
80
  private constructor();
81
+ private openQuery;
65
82
  /** Background loop: consume the SDK message stream until it ends. */
66
83
  private pump;
67
84
  private handle;
@@ -74,11 +91,18 @@ export declare class ClaudeAdapter implements BuilderAdapter {
74
91
  validateSession(): Promise<SessionAvailabilityV1>;
75
92
  compact(): Promise<CompactResult>;
76
93
  contextUsage(): Promise<ContextUsage | undefined>;
94
+ prepareContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
95
+ updateContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
96
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<InterruptResult>;
77
97
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
78
98
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
79
99
  events(): AsyncIterable<BuilderEvent>;
80
100
  close(): Promise<void>;
81
101
  private observeSession;
102
+ private makeContextUsage;
103
+ private configureContextManagement;
104
+ private restartTransport;
105
+ private completeCompaction;
82
106
  private settlePending;
83
107
  private streamFailureResult;
84
108
  }
@@ -145,6 +145,10 @@ export class ClaudeAdapter {
145
145
  query;
146
146
  abort = new AbortController();
147
147
  pumpDone;
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ queryFactory;
150
+ transportGeneration = 0;
151
+ transportObservedSessionId;
148
152
  _sessionId;
149
153
  _sessionRef;
150
154
  stderrChunks = [];
@@ -152,6 +156,19 @@ export class ClaudeAdapter {
152
156
  structuredError;
153
157
  apiErrorStatus;
154
158
  compactResult;
159
+ contextPolicy;
160
+ contextSequence = 0;
161
+ compactionSequence = 0;
162
+ activeCompaction;
163
+ manualCompactionPending = false;
164
+ boundaryInterrupt;
165
+ compactionTerminalObserved = false;
166
+ lastContextUsage;
167
+ /** Full provider context capacity, before Rafi narrows Claude's auto-compact window. */
168
+ modelContextWindow;
169
+ /** SDK-owned space between autoCompactWindow and the actual used-token trigger. */
170
+ nativeAutoCompactReserve;
171
+ contextPrepared = false;
155
172
  cumulativeUsage = { observedAt: new Date(0).toISOString(), source: "provider" };
156
173
  pending;
157
174
  terminalResult;
@@ -195,18 +212,30 @@ export class ClaudeAdapter {
195
212
  }
196
213
  }
197
214
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
198
- constructor(opts, query) {
215
+ constructor(opts, queryFactory) {
199
216
  this.opts = opts;
200
217
  this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
201
218
  this._sessionRef = opts.resumeSessionRef;
202
- this.query = query({
219
+ this.queryFactory = queryFactory;
220
+ this.query = this.openQuery();
221
+ const generation = ++this.transportGeneration;
222
+ this.pumpDone = this.pump(this.query, generation);
223
+ }
224
+ openQuery(nativeAutoCompactWindow) {
225
+ const scopedOpts = this._sessionRef
226
+ ? { ...this.opts, resumeSessionId: this._sessionRef.sessionId, resumeSessionRef: this._sessionRef }
227
+ : this.opts;
228
+ return this.queryFactory({
203
229
  prompt: this.inbox,
204
230
  options: {
205
- ...buildClaudeQueryOptions(opts),
231
+ ...buildClaudeQueryOptions(scopedOpts),
232
+ ...(nativeAutoCompactWindow === undefined ? {} : {
233
+ settings: { autoCompactEnabled: true, autoCompactWindow: nativeAutoCompactWindow },
234
+ }),
206
235
  abortController: this.abort,
207
236
  stderr: (data) => this.captureStderr(data),
208
237
  canUseTool: async (toolName, input, requestOptions = {}) => {
209
- const decision = await opts.permission({
238
+ const decision = await this.opts.permission({
210
239
  toolName,
211
240
  input,
212
241
  signal: requestOptions.signal,
@@ -221,16 +250,22 @@ export class ClaudeAdapter {
221
250
  },
222
251
  },
223
252
  });
224
- this.pumpDone = this.pump();
225
253
  }
226
254
  /** Background loop: consume the SDK message stream until it ends. */
227
- async pump() {
255
+ async pump(query, generation) {
228
256
  try {
229
- for await (const msg of this.query) {
230
- this.handle(msg);
257
+ for await (const msg of query) {
258
+ if (generation !== this.transportGeneration)
259
+ return;
260
+ // Keep provider messages ordered through post-compaction measurement. In
261
+ // particular, a result message must not overtake the fresh context sample
262
+ // produced for the preceding compact_result/compact_boundary message.
263
+ await this.handle(msg);
231
264
  }
232
265
  }
233
266
  catch (err) {
267
+ if (generation !== this.transportGeneration)
268
+ return;
234
269
  // Suppress the AbortError that fires when close() aborts the stream.
235
270
  const isShutdownAbort = this.closed &&
236
271
  (err instanceof Error &&
@@ -245,6 +280,8 @@ export class ClaudeAdapter {
245
280
  }
246
281
  }
247
282
  finally {
283
+ if (generation !== this.transportGeneration)
284
+ return;
248
285
  this.streamEnded = true;
249
286
  if (!this.closed && this.pending) {
250
287
  const result = this.streamFailureResult("Claude stream ended without a result", Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId));
@@ -257,8 +294,9 @@ export class ClaudeAdapter {
257
294
  this.eventQueue.close();
258
295
  }
259
296
  }
260
- handle(msg) {
297
+ async handle(msg) {
261
298
  if ("session_id" in msg && typeof msg.session_id === "string") {
299
+ this.transportObservedSessionId = msg.session_id;
262
300
  this._sessionId = msg.session_id;
263
301
  this.observeSession(msg.session_id);
264
302
  }
@@ -287,14 +325,43 @@ export class ClaudeAdapter {
287
325
  this.turnSignals.push(...msg.output.map((line) => `auth: ${line}`));
288
326
  }
289
327
  else if (msg.type === "system" && msg.subtype === "status") {
290
- if (msg.status === "compacting")
328
+ if (msg.status === "compacting") {
329
+ this.compactionTerminalObserved = false;
291
330
  this.eventQueue.push({ kind: "session-transition", transition: "compacting" });
331
+ const providerSequence = ++this.compactionSequence;
332
+ const origin = this.manualCompactionPending ? "rafi-manual" : "provider-auto";
333
+ this.activeCompaction = { providerEventId: msg.uuid, providerSequence, origin };
334
+ this.eventQueue.push({
335
+ kind: "context-compaction", phase: "started", origin, providerEventId: msg.uuid,
336
+ providerSequence, provider: "claude", sessionId: this._sessionId, sessionRef: this._sessionRef,
337
+ observedAt: new Date().toISOString(),
338
+ });
339
+ }
292
340
  if (msg.compact_result === "success") {
293
341
  this.compactResult = { ok: true };
294
342
  this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
343
+ if (!this.compactionTerminalObserved)
344
+ await this.completeCompaction(true);
295
345
  }
296
346
  else if (msg.compact_result === "failed") {
297
347
  this.compactResult = { ok: false, error: sanitizeDiagnostics(msg.compact_error ?? "Claude native compaction failed") };
348
+ if (!this.compactionTerminalObserved)
349
+ await this.completeCompaction(false, this.compactResult.error);
350
+ }
351
+ }
352
+ else if (msg.type === "system" && msg.subtype === "compact_boundary") {
353
+ if (!this.compactionTerminalObserved) {
354
+ // The boundary is itself a successful native compact signal. Query the
355
+ // SDK immediately and use its post_tokens only as a fallback if that
356
+ // authoritative query is unavailable.
357
+ if (!this.activeCompaction) {
358
+ this.activeCompaction = {
359
+ providerEventId: msg.uuid,
360
+ providerSequence: ++this.compactionSequence,
361
+ origin: msg.compact_metadata.trigger === "manual" ? "rafi-manual" : "provider-auto",
362
+ };
363
+ }
364
+ await this.completeCompaction(true, undefined, msg.compact_metadata.post_tokens);
298
365
  }
299
366
  }
300
367
  else if (msg.type === "system" && msg.subtype === "api_retry") {
@@ -356,7 +423,9 @@ export class ClaudeAdapter {
356
423
  costUsd: msg.total_cost_usd,
357
424
  costAuthoritative: Number.isFinite(msg.total_cost_usd),
358
425
  failure,
426
+ ...(this.boundaryInterrupt ? { interrupted: { reason: "compaction-boundary", ...(this.boundaryInterrupt.providerEventId ? { providerEventId: this.boundaryInterrupt.providerEventId } : {}) } } : {}),
359
427
  };
428
+ this.boundaryInterrupt = undefined;
360
429
  const rawResult = msg;
361
430
  const mergedUsage = mergeClaudeProviderSessionUsage(this.cumulativeUsage, rawResult);
362
431
  result.inputTokens = mergedUsage.inputTokens;
@@ -371,7 +440,11 @@ export class ClaudeAdapter {
371
440
  }
372
441
  }
373
442
  sendTurn(text) {
374
- return withActivityPhase(`Claude ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(text));
443
+ return withActivityPhase(`Claude ${activityPhase(this.opts.runtimePhase)}`, async () => {
444
+ if (this.opts.contextManagementPolicy && !this.contextPrepared)
445
+ await this.prepareContextManagement(this.opts.contextManagementPolicy);
446
+ return this.sendTurnInternal(text);
447
+ });
375
448
  }
376
449
  sendTurnInternal(text) {
377
450
  if (this.closed)
@@ -423,18 +496,25 @@ export class ClaudeAdapter {
423
496
  }
424
497
  async compact() {
425
498
  this.compactResult = undefined;
426
- const result = await this.sendTurn("/compact");
427
- if (result.failure?.category === "session-unavailable") {
428
- return { ok: false, error: result.text || result.failure.diagnostics, failure: result.failure };
499
+ this.manualCompactionPending = true;
500
+ try {
501
+ const result = await this.sendTurn("/compact");
502
+ if (result.failure?.category === "session-unavailable") {
503
+ return { ok: false, error: result.text || result.failure.diagnostics, failure: result.failure };
504
+ }
505
+ if (this.compactResult)
506
+ return this.compactResult;
507
+ return { ok: false, error: sanitizeDiagnostics(result.text || "Claude did not emit an explicit compact status") };
508
+ }
509
+ finally {
510
+ this.manualCompactionPending = false;
429
511
  }
430
- if (this.compactResult)
431
- return this.compactResult;
432
- return { ok: false, error: sanitizeDiagnostics(result.text || "Claude did not emit an explicit compact status") };
433
512
  }
434
513
  async contextUsage() {
435
514
  try {
436
515
  const usage = await this.query.getContextUsage();
437
- const result = { used: usage.totalTokens, maximum: usage.maxTokens, percentage: usage.percentage, observedAt: new Date().toISOString(), source: "provider-query" };
516
+ const result = this.makeContextUsage(usage);
517
+ this.lastContextUsage = result;
438
518
  this.eventQueue.push({ kind: "context-usage", ...result });
439
519
  return result;
440
520
  }
@@ -442,6 +522,32 @@ export class ClaudeAdapter {
442
522
  return undefined;
443
523
  }
444
524
  }
525
+ async prepareContextManagement(policy) {
526
+ validateContextPolicy(policy);
527
+ if (this.contextPrepared)
528
+ return this.configureContextManagement(policy, false);
529
+ await this.query.initializationResult();
530
+ const prepared = await this.configureContextManagement(policy, true);
531
+ this.contextPrepared = true;
532
+ return prepared;
533
+ }
534
+ async updateContextManagement(policy) {
535
+ validateContextPolicy(policy);
536
+ return this.configureContextManagement(policy, false);
537
+ }
538
+ async interruptTurnAtCompactionBoundary(providerEventId) {
539
+ if (!this.pending)
540
+ return { ok: false, error: "no active Claude turn exists at the compaction boundary", providerEventId };
541
+ try {
542
+ this.boundaryInterrupt = { providerEventId };
543
+ await this.query.interrupt();
544
+ return { ok: true, providerEventId };
545
+ }
546
+ catch (error) {
547
+ this.boundaryInterrupt = undefined;
548
+ return { ok: false, error: error instanceof Error ? error.message : String(error), providerEventId };
549
+ }
550
+ }
445
551
  async sessionUsage() {
446
552
  return this.cumulativeUsage.observedAt === new Date(0).toISOString() ? undefined : { ...this.cumulativeUsage };
447
553
  }
@@ -450,11 +556,14 @@ export class ClaudeAdapter {
450
556
  return { ok: false, error: "Claude SDK cannot change reasoning/fast controls on an existing transport" };
451
557
  if (settings.model === this.opts.model)
452
558
  return { ok: true };
453
- const result = await this.sendTurn(`/model ${settings.model ?? "default"}`);
454
- if (result.isError)
455
- return { ok: false, error: result.text, ...(result.failure ? { failure: result.failure } : {}) };
456
- this.opts.model = settings.model;
457
- return { ok: true };
559
+ try {
560
+ await this.query.setModel(settings.model);
561
+ this.opts.model = settings.model;
562
+ return { ok: true };
563
+ }
564
+ catch (error) {
565
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
566
+ }
458
567
  }
459
568
  events() {
460
569
  return this.eventQueue;
@@ -483,6 +592,200 @@ export class ClaudeAdapter {
483
592
  source: "observed",
484
593
  });
485
594
  }
595
+ makeContextUsage(usage) {
596
+ const maximum = this.modelContextWindow
597
+ ?? (Number.isFinite(usage.maxTokens) && usage.maxTokens > 0 ? usage.maxTokens : this.lastContextUsage?.maximum);
598
+ // Once Rafi installs a smaller autoCompactWindow, the SDK's percentage and
599
+ // maxTokens describe that artificial window. Enforcement and display stay
600
+ // relative to the full model capacity discovered before reconfiguration.
601
+ const percentage = maximum ? usage.totalTokens / maximum * 100
602
+ : Number.isFinite(usage.percentage) ? usage.percentage : undefined;
603
+ return {
604
+ used: usage.totalTokens,
605
+ ...(maximum ? { maximum } : {}),
606
+ ...(percentage !== undefined ? { percentage } : {}),
607
+ observedAt: new Date().toISOString(),
608
+ source: "provider-query",
609
+ sequence: ++this.contextSequence,
610
+ ...(this._sessionId ? { sessionId: this._sessionId } : {}),
611
+ ...(this.opts.model ?? usage.model ? { model: this.opts.model ?? usage.model } : {}),
612
+ };
613
+ }
614
+ async configureContextManagement(policy, initial) {
615
+ const modelChanged = this.contextPolicy !== undefined && this.contextPolicy.model !== policy.model;
616
+ if (modelChanged) {
617
+ this.modelContextWindow = undefined;
618
+ this.nativeAutoCompactReserve = undefined;
619
+ await this.restartTransport();
620
+ }
621
+ this.contextPolicy = { ...policy };
622
+ this.compactionSequence = Math.max(this.compactionSequence, policy.providerSequenceStart ?? 0);
623
+ this.contextSequence = Math.max(this.contextSequence, policy.providerSequenceStart ?? 0);
624
+ if (initial || modelChanged || !this.modelContextWindow || !this.nativeAutoCompactReserve) {
625
+ // Enable first so getContextUsage exposes the provider's real trigger.
626
+ await this.query.applyFlagSettings({ autoCompactEnabled: true });
627
+ const baseline = await this.query.getContextUsage();
628
+ const baselineWindow = positiveNumber(baseline.maxTokens);
629
+ const baselineTrigger = positiveNumber(baseline.autoCompactThreshold);
630
+ if (!baselineWindow || !baselineTrigger || baselineTrigger >= baselineWindow) {
631
+ throw new Error("Claude did not expose a usable maxTokens/autoCompactThreshold pair after enabling native auto-compaction");
632
+ }
633
+ this.modelContextWindow = policy.knownModelContextWindow ?? baselineWindow;
634
+ this.nativeAutoCompactReserve = baselineWindow - baselineTrigger;
635
+ }
636
+ const maximum = this.modelContextWindow;
637
+ const reserve = this.nativeAutoCompactReserve;
638
+ const configuredTokenLimit = tokenCeiling(maximum, policy.configuredThresholdPercent);
639
+ // Claude's setting is a total window, not the used-token trigger. Preserve
640
+ // the SDK-reported output/compaction reserve so its resulting trigger lands
641
+ // on (or earlier than) Rafi's configured absolute ceiling.
642
+ const requestedNativeWindow = configuredTokenLimit + reserve;
643
+ await this.query.applyFlagSettings({ autoCompactEnabled: true, autoCompactWindow: requestedNativeWindow });
644
+ let verified = await this.query.getContextUsage();
645
+ // The SDK applies flag-layer changes through an internal settings watcher;
646
+ // give that watcher a short bounded interval before declaring the native
647
+ // control unavailable.
648
+ for (let attempt = 0; attempt < 4
649
+ && positiveNumber(verified.autoCompactThreshold) !== undefined
650
+ && positiveNumber(verified.autoCompactThreshold) > configuredTokenLimit; attempt += 1) {
651
+ await new Promise((resolve) => setTimeout(resolve, 100));
652
+ verified = await this.query.getContextUsage();
653
+ }
654
+ if (initial && positiveNumber(verified.autoCompactThreshold) > configuredTokenLimit) {
655
+ // Some SDK builds commit flag-layer changes to the active query state at
656
+ // the next streaming-input boundary. Use the same approved, tool-free
657
+ // setup exchange that the handoff monitor already recognizes, then
658
+ // verify again before any real role work is dispatched.
659
+ const setup = await this.sendTurnInternal("Rafi context setup only. Do not call tools or change files. Reply with exactly CONTEXT_READY.");
660
+ if (setup.isError || setup.text.trim() !== "CONTEXT_READY") {
661
+ throw new Error(`Claude context setup did not complete safely: ${setup.text.slice(0, 240)}`);
662
+ }
663
+ verified = await this.query.getContextUsage();
664
+ }
665
+ if (positiveNumber(verified.autoCompactThreshold) > configuredTokenLimit) {
666
+ // Current SDK releases accept applyFlagSettings but some noninteractive
667
+ // transports do not project autoCompactWindow into their already-created
668
+ // query state. Restart only that transport with equivalent inline flag
669
+ // settings and resume the exact scoped conversation.
670
+ await this.restartTransport(requestedNativeWindow);
671
+ verified = await this.query.getContextUsage();
672
+ }
673
+ if (!verified.isAutoCompactEnabled)
674
+ throw new Error("Claude disabled native auto-compaction during context configuration");
675
+ const installed = positiveNumber(verified.autoCompactThreshold);
676
+ if (!installed)
677
+ throw new Error("Claude did not report the installed native auto-compaction trigger");
678
+ if (installed > configuredTokenLimit) {
679
+ throw new Error(`Claude installed native compaction at ${installed} tokens, later than the configured ${configuredTokenLimit}-token ceiling `
680
+ + `(requested SDK window ${requestedNativeWindow}; this ceiling may be below the provider's supported minimum)`);
681
+ }
682
+ const sample = this.makeContextUsage(verified);
683
+ this.lastContextUsage = sample;
684
+ this.eventQueue.push({ kind: "context-usage", ...sample });
685
+ return {
686
+ modelContextWindow: maximum,
687
+ configuredTokenLimit,
688
+ installedNativeTokenLimit: installed,
689
+ installedNativePercent: installed / maximum * 100,
690
+ sample,
691
+ };
692
+ }
693
+ async restartTransport(nativeAutoCompactWindow) {
694
+ if (this.pending)
695
+ throw new Error("Claude context transport cannot restart while a provider turn is active");
696
+ const expectedSessionId = this._sessionId;
697
+ const expectedSessionRef = this._sessionRef;
698
+ if (!expectedSessionId || !expectedSessionRef) {
699
+ throw new Error("Claude context transport restart requires an established location-scoped session");
700
+ }
701
+ const priorQuery = this.query;
702
+ const priorInbox = this.inbox;
703
+ const priorPump = this.pumpDone;
704
+ const generation = ++this.transportGeneration;
705
+ priorInbox.close();
706
+ priorQuery.close();
707
+ await priorPump.catch(() => { });
708
+ this.inbox = new AsyncQueue();
709
+ this.abort = new AbortController();
710
+ this.streamEnded = false;
711
+ this.terminalResult = undefined;
712
+ this.transportObservedSessionId = undefined;
713
+ this.query = this.openQuery(nativeAutoCompactWindow);
714
+ this.pumpDone = this.pump(this.query, generation);
715
+ await this.query.initializationResult();
716
+ for (let attempt = 0; attempt < 10 && !this.transportObservedSessionId; attempt += 1) {
717
+ await new Promise((resolve) => setTimeout(resolve, 50));
718
+ }
719
+ if (this.transportObservedSessionId && this.transportObservedSessionId !== expectedSessionId) {
720
+ this.query.close();
721
+ throw new Error(`Claude context transport did not resume the exact scoped session ${expectedSessionId} `
722
+ + `(observed ${this.transportObservedSessionId})`);
723
+ }
724
+ if (!this.transportObservedSessionId) {
725
+ // Claude does not always repeat the session-start frame until the next
726
+ // user message. Validate the exact persisted, location-scoped target;
727
+ // the immediately following context query then proves this transport is
728
+ // usable before any real role action is dispatched.
729
+ const availability = await probeClaudeSession(expectedSessionRef, {
730
+ cwd: this.opts.cwd,
731
+ configRoot: this.opts.configRoot,
732
+ workspaceIdentity: this.opts.workspaceIdentity,
733
+ role: this.opts.sessionRole,
734
+ stream: this.opts.sessionStream,
735
+ ticketId: this.opts.ticketId,
736
+ deliveryUnitId: this.opts.deliveryUnitId,
737
+ });
738
+ if (availability.status !== "available") {
739
+ this.query.close();
740
+ throw new Error(`Claude context transport could not validate scoped session ${expectedSessionId}: `
741
+ + `${availability.reason ?? availability.status} (${availability.detail ?? "no provider detail"})`);
742
+ }
743
+ }
744
+ }
745
+ async completeCompaction(ok, reason, fallbackPostTokens) {
746
+ this.compactionTerminalObserved = true;
747
+ const active = this.activeCompaction;
748
+ const providerSequence = active?.providerSequence ?? ++this.compactionSequence;
749
+ const providerEventId = active?.providerEventId ?? `claude:${this._sessionId ?? "pending"}:${providerSequence}`;
750
+ const origin = active?.origin ?? (this.manualCompactionPending ? "rafi-manual" : "provider-auto");
751
+ this.activeCompaction = undefined;
752
+ if (!ok) {
753
+ this.eventQueue.push({
754
+ kind: "context-compaction", phase: "failed", origin, providerEventId, providerSequence,
755
+ provider: "claude", sessionId: this._sessionId, sessionRef: this._sessionRef,
756
+ observedAt: new Date().toISOString(), reason,
757
+ });
758
+ return;
759
+ }
760
+ let postCompactSample;
761
+ try {
762
+ const usage = await this.query.getContextUsage();
763
+ postCompactSample = { ...this.makeContextUsage(usage), source: "post-compact" };
764
+ this.lastContextUsage = postCompactSample;
765
+ this.eventQueue.push({ kind: "context-usage", ...postCompactSample });
766
+ }
767
+ catch {
768
+ if (fallbackPostTokens !== undefined && this.lastContextUsage?.maximum) {
769
+ postCompactSample = {
770
+ ...this.makeContextUsage({
771
+ totalTokens: fallbackPostTokens,
772
+ maxTokens: this.lastContextUsage.maximum,
773
+ model: this.opts.model,
774
+ }),
775
+ source: "post-compact",
776
+ };
777
+ this.lastContextUsage = postCompactSample;
778
+ this.eventQueue.push({ kind: "context-usage", ...postCompactSample });
779
+ }
780
+ // Otherwise the shared coordinator records this success as unverified
781
+ // and hands off after its own bounded query attempts.
782
+ }
783
+ this.eventQueue.push({
784
+ kind: "context-compaction", phase: "succeeded", origin, providerEventId, providerSequence,
785
+ provider: "claude", sessionId: this._sessionId, sessionRef: this._sessionRef,
786
+ observedAt: new Date().toISOString(), ...(postCompactSample ? { postCompactSample } : {}),
787
+ });
788
+ }
486
789
  settlePending(result, emit = true) {
487
790
  const pending = this.pending;
488
791
  if (!pending)
@@ -510,6 +813,20 @@ export class ClaudeAdapter {
510
813
  }
511
814
  }
512
815
  function finiteNumber(value) { const parsed = Number(value); return value !== null && value !== undefined && value !== "" && Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; }
816
+ function positiveNumber(value) {
817
+ const parsed = Number(value);
818
+ return value !== null && value !== undefined && value !== "" && Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
819
+ }
820
+ function tokenCeiling(maximum, percentage) {
821
+ return Math.max(1, Math.floor(maximum * percentage / 100));
822
+ }
823
+ function validateContextPolicy(policy) {
824
+ if (!Number.isInteger(policy.configuredThresholdPercent) || policy.configuredThresholdPercent < 1 || policy.configuredThresholdPercent > 99) {
825
+ throw new Error("configured context threshold must be an integer from 1 to 99");
826
+ }
827
+ if (!Number.isSafeInteger(policy.compactMaximum) || policy.compactMaximum < 1)
828
+ throw new Error("compact maximum must be a positive safe integer");
829
+ }
513
830
  function isMissingClaudeSession(message) {
514
831
  return /no conversation found with session id|session .* not found|conversation .* not found/i.test(message);
515
832
  }
@@ -1,4 +1,4 @@
1
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
1
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextManagementPolicy, ContextUsage, InterruptResult, PreparedContextManagement, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
2
2
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
3
  export interface CodexLineResult {
4
4
  events: BuilderEvent[];
@@ -24,9 +24,21 @@ export declare class CodexAdapter implements BuilderAdapter {
24
24
  private activeText;
25
25
  private usage?;
26
26
  private usageRevision;
27
+ private contextSequence;
27
28
  private providerSessionUsage?;
28
29
  private lastTurnTokens?;
29
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;
30
42
  constructor(opts: BuilderAdapterOptions);
31
43
  buildInstruction(instruction: string): string;
32
44
  buildAppServerArgs(): string[];
@@ -34,6 +46,9 @@ export declare class CodexAdapter implements BuilderAdapter {
34
46
  buildArgs(instruction: string): string[];
35
47
  sendTurn(instruction: string): Promise<TurnResult>;
36
48
  private sendTurnInternal;
49
+ prepareContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
50
+ updateContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
51
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<InterruptResult>;
37
52
  compact(): Promise<CompactResult>;
38
53
  contextUsage(): Promise<ContextUsage | undefined>;
39
54
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
@@ -52,5 +67,7 @@ export declare class CodexAdapter implements BuilderAdapter {
52
67
  private handle;
53
68
  private waitFor;
54
69
  private disconnect;
70
+ /** Restart only app-server; the scoped thread remains the continuity boundary. */
71
+ private restartTransport;
55
72
  private buildSkillsAppendix;
56
73
  }