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
@@ -13,6 +13,8 @@ import { AsyncQueue } from "../util/asyncQueue.js";
13
13
  import { BuilderEventQueue, withActivityPhase } from "../activity.js";
14
14
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
15
15
  import { classifyClaudeSdkFailure, resolveExecutablePath, sanitizeDiagnostics, } from "../runtimeReadiness.js";
16
+ import { createProviderSessionRef, validateProviderSessionScope, canonicalSessionPath } from "../sessionIdentity.js";
17
+ import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFailure.js";
16
18
  /**
17
19
  * Pure function: build the `options` object passed to `query()`.
18
20
  * Extracted so tests can assert on the shape without making a live SDK call.
@@ -24,7 +26,7 @@ export function buildClaudeQueryOptions(opts) {
24
26
  pathToClaudeCodeExecutable: opts.runtimeExecutable,
25
27
  env: { ...process.env },
26
28
  model: opts.model,
27
- resume: opts.resumeSessionId,
29
+ resume: opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId,
28
30
  permissionMode: "acceptEdits",
29
31
  effort: opts.effort,
30
32
  extraArgs: opts.fast ? { fast: null } : undefined,
@@ -38,6 +40,38 @@ export function buildClaudeQueryOptions(opts) {
38
40
  }
39
41
  return base;
40
42
  }
43
+ export async function probeClaudeSession(ref, input) {
44
+ const now = input.now ?? new Date();
45
+ const local = validateProviderSessionScope(ref, {
46
+ provider: "claude",
47
+ cwd: input.cwd,
48
+ configRoot: input.configRoot ?? input.cwd,
49
+ role: input.role ?? ref.role,
50
+ stream: input.stream ?? ref.stream,
51
+ workspaceIdentity: input.workspaceIdentity,
52
+ ticketId: input.ticketId ?? ref.ticketId,
53
+ deliveryUnitId: input.deliveryUnitId ?? ref.deliveryUnitId,
54
+ }, now);
55
+ if (local.status !== "available")
56
+ return local;
57
+ try {
58
+ const getSessionInfo = input.getSessionInfo ?? (await requireClaudeSDK()).getSessionInfo;
59
+ const info = await getSessionInfo(ref.sessionId, { dir: ref.cwd });
60
+ if (!info)
61
+ return { version: 1, status: "unavailable", checkedAt: now.toISOString(), reason: "not-found", detail: `Claude has no conversation ${ref.sessionId} in ${ref.cwd}`, sessionRef: ref };
62
+ if (info.sessionId !== ref.sessionId)
63
+ return { version: 1, status: "unavailable", checkedAt: now.toISOString(), reason: "not-found", detail: "Claude returned metadata for a different session", sessionRef: ref };
64
+ if (!info.cwd)
65
+ return { version: 1, status: "unknown", checkedAt: now.toISOString(), reason: "probe-failed", detail: "Claude session metadata did not include cwd", sessionRef: ref };
66
+ const observedCwd = canonicalSessionPath(info.cwd);
67
+ if (observedCwd !== canonicalSessionPath(ref.cwd))
68
+ return { version: 1, status: "unavailable", checkedAt: now.toISOString(), reason: "cwd-mismatch", detail: `Claude metadata cwd ${observedCwd} does not match ${ref.cwd}`, observedCwd, sessionRef: ref };
69
+ return { ...local, checkedAt: now.toISOString(), observedCwd, sessionRef: { ...local.sessionRef, validatedAt: now.toISOString() } };
70
+ }
71
+ catch (error) {
72
+ return { version: 1, status: "unknown", checkedAt: now.toISOString(), reason: "probe-failed", detail: sanitizeDiagnostics(error instanceof Error ? error.message : String(error)), sessionRef: ref };
73
+ }
74
+ }
41
75
  export function permissionDecisionToClaudeResult(decision, toolUseID) {
42
76
  if (decision.behavior === "allow") {
43
77
  return {
@@ -54,6 +88,39 @@ export function permissionDecisionToClaudeResult(decision, toolUseID) {
54
88
  toolUseID,
55
89
  };
56
90
  }
91
+ /** Project one cumulative SDK result without inventing or double-counting counters. */
92
+ export function mergeClaudeProviderSessionUsage(prior, rawResult, observedAt = new Date().toISOString()) {
93
+ const rawUsage = rawResult.usage && typeof rawResult.usage === "object" ? rawResult.usage : {};
94
+ const directInputTokens = finiteNumber(rawUsage.input_tokens);
95
+ const cacheCreationInputTokens = finiteNumber(rawUsage.cache_creation_input_tokens);
96
+ const cacheReadInputTokens = finiteNumber(rawUsage.cache_read_input_tokens);
97
+ const inputTokens = [directInputTokens, cacheCreationInputTokens, cacheReadInputTokens].some((value) => value !== undefined)
98
+ ? (directInputTokens ?? 0) + (cacheCreationInputTokens ?? 0) + (cacheReadInputTokens ?? 0)
99
+ : undefined;
100
+ const outputTokens = finiteNumber(rawUsage.output_tokens);
101
+ const authoritativeCostUsd = finiteNumber(rawResult.total_cost_usd);
102
+ // SDK result usage and total_cost_usd are cumulative for this query
103
+ // conversation. Preserve the latest authoritative absolute counters;
104
+ // summing successive result messages would double-count prior turns.
105
+ const cumulativeInput = inputTokens ?? prior.inputTokens;
106
+ const cumulativeOutput = outputTokens ?? prior.outputTokens;
107
+ const cumulativeTotal = inputTokens === undefined && outputTokens === undefined
108
+ ? prior.totalTokens
109
+ : (inputTokens ?? 0) + (outputTokens ?? 0);
110
+ const cumulativeCost = authoritativeCostUsd ?? prior.authoritativeCostUsd;
111
+ return {
112
+ sample: {
113
+ ...(cumulativeInput !== undefined ? { inputTokens: cumulativeInput } : {}),
114
+ ...(cumulativeOutput !== undefined ? { outputTokens: cumulativeOutput } : {}),
115
+ ...(cumulativeTotal !== undefined ? { totalTokens: cumulativeTotal } : {}),
116
+ ...(cumulativeCost !== undefined ? { authoritativeCostUsd: cumulativeCost } : {}),
117
+ observedAt,
118
+ source: "provider",
119
+ },
120
+ inputTokens,
121
+ outputTokens,
122
+ };
123
+ }
57
124
  export function claudeApiRetryEvent(message) {
58
125
  return {
59
126
  kind: "retry",
@@ -78,13 +145,34 @@ export class ClaudeAdapter {
78
145
  query;
79
146
  abort = new AbortController();
80
147
  pumpDone;
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ queryFactory;
150
+ transportGeneration = 0;
151
+ transportObservedSessionId;
81
152
  _sessionId;
153
+ _sessionRef;
82
154
  stderrChunks = [];
83
155
  turnSignals = [];
84
156
  structuredError;
85
157
  apiErrorStatus;
86
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;
172
+ cumulativeUsage = { observedAt: new Date(0).toISOString(), source: "provider" };
87
173
  pending;
174
+ terminalResult;
175
+ streamEnded = false;
88
176
  closed = false;
89
177
  static async create(opts) {
90
178
  try {
@@ -92,25 +180,62 @@ export class ClaudeAdapter {
92
180
  if (!runtimeExecutable) {
93
181
  throw new Error("Claude Code executable not found on PATH. Install your organization-approved Claude Code CLI, then retry.");
94
182
  }
95
- const { query } = await requireClaudeSDK();
96
- return new ClaudeAdapter({ ...opts, runtimeExecutable }, query);
183
+ const sdk = await requireClaudeSDK();
184
+ let validatedOpts = { ...opts, runtimeExecutable };
185
+ if (opts.resumeSessionRef) {
186
+ const availability = await probeClaudeSession(opts.resumeSessionRef, {
187
+ cwd: opts.cwd,
188
+ configRoot: opts.configRoot,
189
+ workspaceIdentity: opts.workspaceIdentity,
190
+ role: opts.sessionRole,
191
+ stream: opts.sessionStream,
192
+ ticketId: opts.ticketId,
193
+ deliveryUnitId: opts.deliveryUnitId,
194
+ getSessionInfo: sdk.getSessionInfo,
195
+ });
196
+ if (availability.status !== "available" || !availability.sessionRef) {
197
+ throw new SessionUnavailableError({
198
+ runtime: "claude", phase: "preflight", dispatchState: "not-sent", executable: runtimeExecutable,
199
+ cwd: opts.cwd, diagnostics: availability.detail ?? `Claude session ${opts.resumeSessionRef.sessionId} is ${availability.status}`,
200
+ availability,
201
+ });
202
+ }
203
+ validatedOpts = { ...validatedOpts, resumeSessionId: availability.sessionRef.sessionId, resumeSessionRef: availability.sessionRef };
204
+ }
205
+ return new ClaudeAdapter(validatedOpts, sdk.query);
97
206
  }
98
207
  catch (err) {
208
+ if (err instanceof SessionUnavailableError)
209
+ throw err;
99
210
  const message = err instanceof Error ? err.message : String(err);
100
211
  throw new Error(normalizeRuntimeErrorText("claude", message, null, "adapter startup"), { cause: err });
101
212
  }
102
213
  }
103
214
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
- constructor(opts, query) {
215
+ constructor(opts, queryFactory) {
105
216
  this.opts = opts;
106
- this.query = query({
217
+ this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
218
+ this._sessionRef = opts.resumeSessionRef;
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({
107
229
  prompt: this.inbox,
108
230
  options: {
109
- ...buildClaudeQueryOptions(opts),
231
+ ...buildClaudeQueryOptions(scopedOpts),
232
+ ...(nativeAutoCompactWindow === undefined ? {} : {
233
+ settings: { autoCompactEnabled: true, autoCompactWindow: nativeAutoCompactWindow },
234
+ }),
110
235
  abortController: this.abort,
111
236
  stderr: (data) => this.captureStderr(data),
112
237
  canUseTool: async (toolName, input, requestOptions = {}) => {
113
- const decision = await opts.permission({
238
+ const decision = await this.opts.permission({
114
239
  toolName,
115
240
  input,
116
241
  signal: requestOptions.signal,
@@ -125,16 +250,22 @@ export class ClaudeAdapter {
125
250
  },
126
251
  },
127
252
  });
128
- this.pumpDone = this.pump();
129
253
  }
130
254
  /** Background loop: consume the SDK message stream until it ends. */
131
- async pump() {
255
+ async pump(query, generation) {
132
256
  try {
133
- for await (const msg of this.query) {
134
- 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);
135
264
  }
136
265
  }
137
266
  catch (err) {
267
+ if (generation !== this.transportGeneration)
268
+ return;
138
269
  // Suppress the AbortError that fires when close() aborts the stream.
139
270
  const isShutdownAbort = this.closed &&
140
271
  (err instanceof Error &&
@@ -143,17 +274,31 @@ export class ClaudeAdapter {
143
274
  const rawMessage = err instanceof Error ? err.message : String(err);
144
275
  const message = normalizeRuntimeErrorText("claude", rawMessage, null, "builder stream");
145
276
  this.eventQueue.push({ kind: "error", message });
146
- this.pending?.reject(new Error(message));
147
- this.pending = undefined;
277
+ const result = this.streamFailureResult(message, isMissingClaudeSession(rawMessage));
278
+ this.terminalResult = result;
279
+ this.settlePending(result);
148
280
  }
149
281
  }
150
282
  finally {
283
+ if (generation !== this.transportGeneration)
284
+ return;
285
+ this.streamEnded = true;
286
+ if (!this.closed && this.pending) {
287
+ const result = this.streamFailureResult("Claude stream ended without a result", Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId));
288
+ this.terminalResult = result;
289
+ this.eventQueue.push({ kind: "error", message: result.text });
290
+ this.settlePending(result);
291
+ }
292
+ if (!this.closed && !this.terminalResult)
293
+ this.terminalResult = this.streamFailureResult("Claude stream ended without a result", Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId));
151
294
  this.eventQueue.close();
152
295
  }
153
296
  }
154
- handle(msg) {
297
+ async handle(msg) {
155
298
  if ("session_id" in msg && typeof msg.session_id === "string") {
299
+ this.transportObservedSessionId = msg.session_id;
156
300
  this._sessionId = msg.session_id;
301
+ this.observeSession(msg.session_id);
157
302
  }
158
303
  if (msg.type === "assistant") {
159
304
  if (msg.error) {
@@ -180,14 +325,43 @@ export class ClaudeAdapter {
180
325
  this.turnSignals.push(...msg.output.map((line) => `auth: ${line}`));
181
326
  }
182
327
  else if (msg.type === "system" && msg.subtype === "status") {
183
- if (msg.status === "compacting")
328
+ if (msg.status === "compacting") {
329
+ this.compactionTerminalObserved = false;
184
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
+ }
185
340
  if (msg.compact_result === "success") {
186
341
  this.compactResult = { ok: true };
187
342
  this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
343
+ if (!this.compactionTerminalObserved)
344
+ await this.completeCompaction(true);
188
345
  }
189
346
  else if (msg.compact_result === "failed") {
190
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);
191
365
  }
192
366
  }
193
367
  else if (msg.type === "system" && msg.subtype === "api_retry") {
@@ -221,24 +395,44 @@ export class ClaudeAdapter {
221
395
  ...this.turnSignals,
222
396
  ...this.stderrChunks,
223
397
  ].filter(Boolean).join("\n"));
224
- const failure = msg.is_error ? {
225
- runtime: "claude",
226
- phase: this.opts.runtimePhase ?? "builder",
227
- category: classifyClaudeSdkFailure(this.structuredError, this.apiErrorStatus, rawDiagnostics),
228
- executable: this.opts.runtimeExecutable ?? "claude",
229
- cwd: this.opts.cwd,
230
- diagnostics: rawDiagnostics,
231
- } : undefined;
398
+ const missingResumedSession = msg.is_error
399
+ && Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId)
400
+ && isMissingClaudeSession(rawDiagnostics);
401
+ const failure = msg.is_error ? missingResumedSession
402
+ ? new SessionUnavailableError({
403
+ runtime: "claude", phase: "turn", dispatchState: "unknown",
404
+ executable: this.opts.runtimeExecutable ?? "claude", cwd: this.opts.cwd,
405
+ diagnostics: rawDiagnostics,
406
+ availability: {
407
+ version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "not-found",
408
+ detail: rawDiagnostics, ...(this._sessionRef ? { sessionRef: this._sessionRef } : {}),
409
+ },
410
+ }).failure
411
+ : {
412
+ runtime: "claude",
413
+ phase: this.opts.runtimePhase ?? "builder",
414
+ category: classifyClaudeSdkFailure(this.structuredError, this.apiErrorStatus, rawDiagnostics),
415
+ executable: this.opts.runtimeExecutable ?? "claude",
416
+ cwd: this.opts.cwd,
417
+ diagnostics: rawDiagnostics,
418
+ } : undefined;
232
419
  const result = {
233
420
  text: failure ? formatClaudeFailure(failure) : text,
234
421
  isError: msg.is_error,
235
422
  numTurns: msg.num_turns,
236
423
  costUsd: msg.total_cost_usd,
424
+ costAuthoritative: Number.isFinite(msg.total_cost_usd),
237
425
  failure,
426
+ ...(this.boundaryInterrupt ? { interrupted: { reason: "compaction-boundary", ...(this.boundaryInterrupt.providerEventId ? { providerEventId: this.boundaryInterrupt.providerEventId } : {}) } } : {}),
238
427
  };
428
+ this.boundaryInterrupt = undefined;
429
+ const rawResult = msg;
430
+ const mergedUsage = mergeClaudeProviderSessionUsage(this.cumulativeUsage, rawResult);
431
+ result.inputTokens = mergedUsage.inputTokens;
432
+ result.outputTokens = mergedUsage.outputTokens;
433
+ this.cumulativeUsage = mergedUsage.sample;
239
434
  this.eventQueue.push({ kind: "turn-complete", result });
240
- this.pending?.resolve(result);
241
- this.pending = undefined;
435
+ this.settlePending(result, false);
242
436
  this.turnSignals = [];
243
437
  this.structuredError = undefined;
244
438
  this.apiErrorStatus = undefined;
@@ -246,11 +440,17 @@ export class ClaudeAdapter {
246
440
  }
247
441
  }
248
442
  sendTurn(text) {
249
- 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
+ });
250
448
  }
251
449
  sendTurnInternal(text) {
252
450
  if (this.closed)
253
451
  return Promise.reject(new Error("builder is closed"));
452
+ if (this.terminalResult || this.streamEnded)
453
+ return Promise.resolve(this.terminalResult ?? this.streamFailureResult("Claude stream is no longer available", Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId)));
254
454
  if (this.pending) {
255
455
  return Promise.reject(new Error("a turn is already in progress"));
256
456
  }
@@ -275,17 +475,46 @@ export class ClaudeAdapter {
275
475
  sessionId() {
276
476
  return this._sessionId;
277
477
  }
478
+ sessionRef() { return this._sessionRef; }
479
+ adoptSessionRef(ref) {
480
+ if (ref.provider !== "claude" || ref.sessionId !== this._sessionId)
481
+ throw new Error("cannot adopt a session reference for a different Claude conversation");
482
+ this._sessionRef = ref;
483
+ }
484
+ async validateSession() {
485
+ if (!this._sessionRef)
486
+ return { version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "Claude adapter was constructed from an unscoped raw session ID" };
487
+ return probeClaudeSession(this._sessionRef, {
488
+ cwd: this.opts.cwd,
489
+ configRoot: this.opts.configRoot,
490
+ workspaceIdentity: this.opts.workspaceIdentity,
491
+ role: this.opts.sessionRole,
492
+ stream: this.opts.sessionStream,
493
+ ticketId: this.opts.ticketId,
494
+ deliveryUnitId: this.opts.deliveryUnitId,
495
+ });
496
+ }
278
497
  async compact() {
279
498
  this.compactResult = undefined;
280
- const result = await this.sendTurn("/compact");
281
- if (this.compactResult)
282
- return this.compactResult;
283
- return { ok: false, error: sanitizeDiagnostics(result.text || "Claude did not emit an explicit compact status") };
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;
511
+ }
284
512
  }
285
513
  async contextUsage() {
286
514
  try {
287
515
  const usage = await this.query.getContextUsage();
288
- const result = { used: usage.totalTokens, maximum: usage.maxTokens, percentage: usage.percentage };
516
+ const result = this.makeContextUsage(usage);
517
+ this.lastContextUsage = result;
289
518
  this.eventQueue.push({ kind: "context-usage", ...result });
290
519
  return result;
291
520
  }
@@ -293,16 +522,48 @@ export class ClaudeAdapter {
293
522
  return undefined;
294
523
  }
295
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
+ }
551
+ async sessionUsage() {
552
+ return this.cumulativeUsage.observedAt === new Date(0).toISOString() ? undefined : { ...this.cumulativeUsage };
553
+ }
296
554
  async switchSettings(settings) {
297
555
  if (settings.effort !== this.opts.effort || settings.fast !== this.opts.fast)
298
556
  return { ok: false, error: "Claude SDK cannot change reasoning/fast controls on an existing transport" };
299
557
  if (settings.model === this.opts.model)
300
558
  return { ok: true };
301
- const result = await this.sendTurn(`/model ${settings.model ?? "default"}`);
302
- if (result.isError)
303
- return { ok: false, error: result.text };
304
- this.opts.model = settings.model;
305
- 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
+ }
306
567
  }
307
568
  events() {
308
569
  return this.eventQueue;
@@ -321,6 +582,253 @@ export class ClaudeAdapter {
321
582
  this.abort.abort();
322
583
  await this.pumpDone.catch(() => { });
323
584
  }
585
+ observeSession(sessionId) {
586
+ if (this._sessionRef?.sessionId === sessionId)
587
+ return;
588
+ this._sessionRef = createProviderSessionRef({
589
+ provider: "claude", sessionId, cwd: this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
590
+ role: this.opts.sessionRole, stream: this.opts.sessionStream, generation: this.opts.sessionGeneration,
591
+ workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
592
+ source: "observed",
593
+ });
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
+ }
789
+ settlePending(result, emit = true) {
790
+ const pending = this.pending;
791
+ if (!pending)
792
+ return;
793
+ this.pending = undefined;
794
+ if (emit)
795
+ this.eventQueue.push({ kind: "turn-complete", result });
796
+ pending.resolve(result);
797
+ }
798
+ streamFailureResult(message, sessionUnavailable) {
799
+ if (sessionUnavailable) {
800
+ const availability = {
801
+ version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "not-found",
802
+ detail: message, ...(this._sessionRef ? { sessionRef: this._sessionRef } : {}),
803
+ };
804
+ return sessionUnavailableResult(new SessionUnavailableError({
805
+ runtime: "claude", phase: "turn", dispatchState: "unknown", executable: this.opts.runtimeExecutable ?? "claude",
806
+ cwd: this.opts.cwd, diagnostics: message, availability,
807
+ }));
808
+ }
809
+ return {
810
+ text: message, isError: true, numTurns: 0, costUsd: 0, costAuthoritative: false,
811
+ failure: { runtime: "claude", phase: this.opts.runtimePhase ?? "builder", category: "agent-stream", executable: this.opts.runtimeExecutable ?? "claude", cwd: this.opts.cwd, diagnostics: message, dispatchState: "unknown" },
812
+ };
813
+ }
814
+ }
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
+ }
830
+ function isMissingClaudeSession(message) {
831
+ return /no conversation found with session id|session .* not found|conversation .* not found/i.test(message);
324
832
  }
325
833
  function activityPhase(phase) {
326
834
  if (phase === "planning")