ai-foreman 1.7.6 → 1.7.7

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 +16 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +29 -4
  4. package/dist/adapters/claude.d.ts +33 -2
  5. package/dist/adapters/claude.js +208 -17
  6. package/dist/adapters/codex.d.ts +11 -1
  7. package/dist/adapters/codex.js +195 -20
  8. package/dist/adapters/recovering.d.ts +8 -2
  9. package/dist/adapters/recovering.js +23 -3
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +52 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +34 -0
  16. package/dist/branch/currentGuard.js +65 -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 +940 -42
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +86 -0
  32. package/dist/continuity.js +365 -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 +326 -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 +20 -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 +102 -3
  47. package/dist/sessionLifecycle.js +578 -20
  48. package/dist/statusReporter.d.ts +58 -8
  49. package/dist/statusReporter.js +210 -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 +99 -2
  68. package/dist/workflowDb.js +434 -8
  69. package/package.json +31 -3
@@ -5,6 +5,8 @@ import { createInterface } from "node:readline";
5
5
  import { loadSkill } from "special-agents";
6
6
  import { BuilderEventQueue, withActivityPhase } from "../activity.js";
7
7
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
8
+ import { canonicalSessionPath, createProviderSessionRef, validateProviderSessionScope } from "../sessionIdentity.js";
9
+ import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFailure.js";
8
10
  /** Compatibility parser for recorded pre-app-server JSONL fixtures. */
9
11
  export function parseCodexLine(raw) {
10
12
  const type = raw.type;
@@ -31,14 +33,20 @@ export class CodexAdapter {
31
33
  pending = new Map();
32
34
  notificationWaiters = new Map();
33
35
  _sessionId;
36
+ _sessionRef;
37
+ sessionAvailability;
34
38
  initialized = false;
35
39
  closed = false;
36
40
  activeText = [];
37
41
  usage;
42
+ usageRevision = 0;
43
+ providerSessionUsage;
44
+ lastTurnTokens;
38
45
  stderr = "";
39
46
  constructor(opts) {
40
47
  this.opts = opts;
41
- this._sessionId = opts.resumeSessionId;
48
+ this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
49
+ this._sessionRef = opts.resumeSessionRef;
42
50
  }
43
51
  buildInstruction(instruction) {
44
52
  return [this.opts.systemPromptAppend, this.buildSkillsAppendix(), instruction]
@@ -66,10 +74,13 @@ export class CodexAdapter {
66
74
  if (this.closed)
67
75
  throw new Error("builder is closed");
68
76
  this.eventQueue.push({ kind: "activity", state: "starting Codex turn", provider: "codex", model: this.opts.model });
77
+ let turnStartDispatched = false;
69
78
  try {
70
79
  await this.ensureThread();
71
80
  this.activeText = [];
81
+ this.lastTurnTokens = undefined;
72
82
  const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId);
83
+ turnStartDispatched = true;
73
84
  await this.request("turn/start", {
74
85
  threadId: this._sessionId,
75
86
  input: [{ type: "text", text: this.buildInstruction(instruction), text_elements: [] }],
@@ -82,15 +93,32 @@ export class CodexAdapter {
82
93
  const failed = turn?.status === "failed";
83
94
  const error = turn?.error;
84
95
  const text = this.activeText.join("\n");
85
- const result = { text: failed ? normalizeRuntimeErrorText("codex", String(error?.message ?? text), null, "app-server turn") : text, isError: failed, numTurns: 1, costUsd: 0 };
96
+ const result = {
97
+ text: failed ? normalizeRuntimeErrorText("codex", String(error?.message ?? text), null, "app-server turn") : text,
98
+ isError: failed, numTurns: 1, costUsd: 0, costAuthoritative: false,
99
+ ...(this.lastTurnTokens ?? {}),
100
+ };
86
101
  this.eventQueue.push({ kind: "turn-complete", result });
87
102
  return result;
88
103
  }
89
104
  catch (error) {
105
+ if (error instanceof SessionUnavailableError) {
106
+ const result = sessionUnavailableResult(error);
107
+ this.eventQueue.push({ kind: "error", message: result.text });
108
+ this.eventQueue.push({ kind: "turn-complete", result });
109
+ return result;
110
+ }
90
111
  const detail = [error instanceof Error ? error.message : String(error), this.stderr].filter(Boolean).join("\n");
91
112
  const text = normalizeRuntimeErrorText("codex", detail, this.process?.exitCode ?? null, "builder turn");
92
113
  this.disconnect(new Error(text));
93
- const result = { text, isError: true, numTurns: 1, costUsd: 0 };
114
+ const resumed = Boolean(this.opts.resumeSessionRef);
115
+ const result = resumed
116
+ ? sessionUnavailableResult(new SessionUnavailableError({
117
+ runtime: "codex", phase: turnStartDispatched ? "turn" : "attach", dispatchState: turnStartDispatched ? "unknown" : "not-sent",
118
+ executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: text,
119
+ availability: { version: 1, status: turnStartDispatched ? "unknown" : "unavailable", checkedAt: new Date().toISOString(), reason: turnStartDispatched ? "probe-failed" : "attach-failed", detail: text, sessionRef: this.opts.resumeSessionRef },
120
+ }))
121
+ : { text, isError: true, numTurns: 1, costUsd: 0, costAuthoritative: false };
94
122
  this.eventQueue.push({ kind: "error", message: text });
95
123
  this.eventQueue.push({ kind: "turn-complete", result });
96
124
  return result;
@@ -100,21 +128,37 @@ export class CodexAdapter {
100
128
  try {
101
129
  await this.ensureThread();
102
130
  this.eventQueue.push({ kind: "session-transition", transition: "compacting" });
131
+ const usageRevision = this.usageRevision;
132
+ // A pre-compaction observation must never be mistaken for proof that the
133
+ // provider reduced the live context. The app server emits a fresh token
134
+ // usage notification for the compacted thread; require it alongside the
135
+ // explicit contextCompaction completion item.
136
+ this.usage = undefined;
103
137
  const done = this.waitFor("item/completed", (params) => {
104
138
  const item = params.item;
105
139
  return params.threadId === this._sessionId && item?.type === "contextCompaction";
106
- });
107
- await this.request("thread/compact/start", { threadId: this._sessionId });
108
- await done;
109
- this.usage = undefined;
140
+ }, 30_000);
141
+ const postCompactUsage = this.waitFor("thread/tokenUsage/updated", (params) => {
142
+ return (params.threadId === undefined || params.threadId === this._sessionId)
143
+ && this.usageRevision > usageRevision
144
+ && this.usage !== undefined;
145
+ }, 30_000);
146
+ await Promise.all([
147
+ this.request("thread/compact/start", { threadId: this._sessionId }),
148
+ done,
149
+ postCompactUsage,
150
+ ]);
110
151
  this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
111
152
  return { ok: true };
112
153
  }
113
154
  catch (error) {
155
+ if (error instanceof SessionUnavailableError)
156
+ return { ok: false, error: error.message, failure: error.failure };
114
157
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
115
158
  }
116
159
  }
117
160
  async contextUsage() { return this.usage; }
161
+ async sessionUsage() { return this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined; }
118
162
  async switchSettings(settings) {
119
163
  this.opts.model = settings.model;
120
164
  this.opts.effort = settings.effort;
@@ -122,6 +166,26 @@ export class CodexAdapter {
122
166
  return { ok: true };
123
167
  }
124
168
  sessionId() { return this._sessionId; }
169
+ sessionRef() { return this._sessionRef; }
170
+ adoptSessionRef(ref) {
171
+ if (ref.provider !== "codex" || ref.sessionId !== this._sessionId)
172
+ throw new Error("cannot adopt a session reference for a different Codex thread");
173
+ this._sessionRef = ref;
174
+ }
175
+ async validateSession() {
176
+ const checkedAt = new Date().toISOString();
177
+ if (!this._sessionRef)
178
+ return { version: 1, status: "unknown", checkedAt, reason: "legacy-unscoped", detail: "Codex raw thread IDs cannot be declared exact without a stored location scope" };
179
+ try {
180
+ await this.ensureThread();
181
+ return this.sessionAvailability ?? { version: 1, status: "available", checkedAt, observedCwd: canonicalSessionPath(this.opts.cwd), sessionRef: this._sessionRef };
182
+ }
183
+ catch (error) {
184
+ if (error instanceof SessionUnavailableError && error.failure.availability)
185
+ return error.failure.availability;
186
+ return { version: 1, status: "unknown", checkedAt, reason: "probe-failed", detail: error instanceof Error ? error.message : String(error), sessionRef: this._sessionRef };
187
+ }
188
+ }
125
189
  events() { return this.eventQueue; }
126
190
  async close() {
127
191
  if (this.closed)
@@ -131,20 +195,93 @@ export class CodexAdapter {
131
195
  this.eventQueue.close();
132
196
  }
133
197
  async ensureThread() {
198
+ if (this._sessionRef && !this.threadAttached) {
199
+ if (this._sessionRef.source === "legacy-inferred") {
200
+ const availability = { version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "legacy Codex thread IDs cannot be proven exact without an observed scoped binding", sessionRef: this._sessionRef };
201
+ this.sessionAvailability = availability;
202
+ throw new SessionUnavailableError({ runtime: "codex", phase: "preflight", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: availability.detail, availability });
203
+ }
204
+ const scoped = validateProviderSessionScope(this._sessionRef, {
205
+ provider: "codex", cwd: this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
206
+ role: this.opts.sessionRole ?? this._sessionRef.role, stream: this.opts.sessionStream ?? this._sessionRef.stream,
207
+ workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
208
+ });
209
+ if (scoped.status !== "available" || !scoped.sessionRef) {
210
+ this.sessionAvailability = scoped;
211
+ throw new SessionUnavailableError({
212
+ runtime: "codex", phase: "preflight", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex",
213
+ cwd: this.opts.cwd, diagnostics: scoped.detail ?? `Codex session ${this._sessionRef.sessionId} is ${scoped.status}`, availability: scoped,
214
+ });
215
+ }
216
+ this._sessionRef = scoped.sessionRef;
217
+ }
134
218
  await this.ensureConnection();
135
219
  if (this._sessionId && this.initialized === true && this.threadAttached)
136
220
  return;
137
221
  const method = this._sessionId ? "thread/resume" : "thread/start";
138
- const result = await this.request(method, {
139
- ...(this._sessionId ? { threadId: this._sessionId } : {}), cwd: this.opts.cwd,
140
- model: this.opts.model ?? null, approvalPolicy: "never",
141
- sandbox: this.opts.sandboxMode === "read-only" ? "read-only" : "workspace-write",
142
- developerInstructions: this.opts.systemPromptAppend ?? null,
143
- });
222
+ let result;
223
+ try {
224
+ result = await this.request(method, {
225
+ ...(this._sessionId ? { threadId: this._sessionId } : {}), cwd: this.opts.cwd,
226
+ model: this.opts.model ?? null, approvalPolicy: "never",
227
+ sandbox: this.opts.sandboxMode === "read-only" ? "read-only" : "workspace-write",
228
+ developerInstructions: this.opts.systemPromptAppend ?? null,
229
+ });
230
+ }
231
+ catch (error) {
232
+ if (this._sessionRef) {
233
+ const detail = error instanceof Error ? error.message : String(error);
234
+ const availability = {
235
+ version: 1, status: "unavailable", checkedAt: new Date().toISOString(),
236
+ reason: /not found|unknown thread|no thread/i.test(detail) ? "not-found" : "attach-failed", detail, sessionRef: this._sessionRef,
237
+ };
238
+ this.sessionAvailability = availability;
239
+ throw new SessionUnavailableError({ runtime: "codex", phase: "attach", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: detail, availability, cause: error });
240
+ }
241
+ throw error;
242
+ }
144
243
  const thread = result.thread;
145
- this._sessionId = String(thread?.id ?? this._sessionId ?? "") || undefined;
244
+ const returnedSessionId = String(thread?.id ?? this._sessionId ?? "") || undefined;
245
+ if (this._sessionRef && returnedSessionId !== this._sessionRef.sessionId) {
246
+ const availability = {
247
+ version: 1,
248
+ status: "unavailable",
249
+ checkedAt: new Date().toISOString(),
250
+ reason: "attach-failed",
251
+ detail: `Codex resumed thread ${returnedSessionId ?? "without an ID"} instead of requested thread ${this._sessionRef.sessionId}`,
252
+ sessionRef: this._sessionRef,
253
+ };
254
+ this.sessionAvailability = availability;
255
+ throw new SessionUnavailableError({
256
+ runtime: "codex",
257
+ phase: "attach",
258
+ dispatchState: "not-sent",
259
+ executable: this.opts.runtimeExecutable ?? "codex",
260
+ cwd: this.opts.cwd,
261
+ diagnostics: availability.detail,
262
+ availability,
263
+ });
264
+ }
265
+ this._sessionId = returnedSessionId;
146
266
  if (!this._sessionId)
147
267
  throw new Error(`${method} did not return a thread ID`);
268
+ const providerCwd = typeof thread?.cwd === "string" ? canonicalSessionPath(thread.cwd) : undefined;
269
+ if (this._sessionRef && providerCwd && providerCwd !== canonicalSessionPath(this._sessionRef.cwd)) {
270
+ const availability = { version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "cwd-mismatch", observedCwd: providerCwd, detail: `Codex thread cwd ${providerCwd} does not match ${this._sessionRef.cwd}`, sessionRef: this._sessionRef };
271
+ this.sessionAvailability = availability;
272
+ throw new SessionUnavailableError({ runtime: "codex", phase: "attach", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: availability.detail, availability });
273
+ }
274
+ if (!this._sessionRef) {
275
+ this._sessionRef = createProviderSessionRef({
276
+ provider: "codex", sessionId: this._sessionId, cwd: providerCwd ?? this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
277
+ role: this.opts.sessionRole, stream: this.opts.sessionStream, generation: this.opts.sessionGeneration,
278
+ workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
279
+ });
280
+ }
281
+ else {
282
+ this._sessionRef = { ...this._sessionRef, validatedAt: new Date().toISOString() };
283
+ }
284
+ this.sessionAvailability = { version: 1, status: "available", checkedAt: new Date().toISOString(), observedCwd: providerCwd ?? canonicalSessionPath(this.opts.cwd), sessionRef: this._sessionRef };
148
285
  this.threadAttached = true;
149
286
  this.eventQueue.push({ kind: "session-transition", transition: method === "thread/resume" ? "resumed" : "started" });
150
287
  }
@@ -233,10 +370,25 @@ export class CodexAdapter {
233
370
  else if (message.method === "thread/tokenUsage/updated") {
234
371
  const tokenUsage = params.tokenUsage;
235
372
  const total = tokenUsage?.total;
236
- const used = Number(total?.totalTokens ?? 0);
237
- const maximum = Number(tokenUsage?.modelContextWindow ?? 0) || undefined;
238
- this.usage = { used, maximum, ...(maximum ? { percentage: used / maximum * 100 } : {}) };
239
- this.eventQueue.push({ kind: "context-usage", ...this.usage });
373
+ const last = tokenUsage?.last;
374
+ const used = Number(total?.totalTokens);
375
+ const maximum = Number(tokenUsage?.modelContextWindow);
376
+ const observedAt = new Date().toISOString();
377
+ const totalInput = optionalNonNegative(total?.inputTokens);
378
+ const totalOutput = optionalNonNegative(total?.outputTokens);
379
+ const sessionTotal = optionalNonNegative(total?.totalTokens);
380
+ if (totalInput !== undefined || totalOutput !== undefined || sessionTotal !== undefined) {
381
+ this.providerSessionUsage = { inputTokens: totalInput, outputTokens: totalOutput, totalTokens: sessionTotal, observedAt, source: "provider" };
382
+ }
383
+ this.lastTurnTokens = {
384
+ inputTokens: optionalNonNegative(last?.inputTokens),
385
+ outputTokens: optionalNonNegative(last?.outputTokens),
386
+ };
387
+ if (Number.isFinite(used) && used >= 0 && Number.isFinite(maximum) && maximum > 0) {
388
+ this.usage = { used, maximum, percentage: used / maximum * 100, observedAt, source: "provider-event" };
389
+ this.usageRevision += 1;
390
+ this.eventQueue.push({ kind: "context-usage", ...this.usage });
391
+ }
240
392
  }
241
393
  else if (message.method === "error") {
242
394
  const error = params.error;
@@ -252,8 +404,27 @@ export class CodexAdapter {
252
404
  waiter.predicate(params) ? waiter.resolve(params) : remaining.push(waiter);
253
405
  this.notificationWaiters.set(message.method, remaining);
254
406
  }
255
- waitFor(method, predicate) {
256
- return new Promise((resolve, reject) => { const waiters = this.notificationWaiters.get(method) ?? []; waiters.push({ predicate, resolve, reject }); this.notificationWaiters.set(method, waiters); });
407
+ waitFor(method, predicate, timeoutMs) {
408
+ return new Promise((resolve, reject) => {
409
+ let timeout;
410
+ const waiter = {
411
+ predicate,
412
+ resolve: (params) => { if (timeout)
413
+ clearTimeout(timeout); resolve(params); },
414
+ reject: (error) => { if (timeout)
415
+ clearTimeout(timeout); reject(error); },
416
+ };
417
+ const waiters = this.notificationWaiters.get(method) ?? [];
418
+ waiters.push(waiter);
419
+ this.notificationWaiters.set(method, waiters);
420
+ if (timeoutMs !== undefined) {
421
+ timeout = setTimeout(() => {
422
+ const active = this.notificationWaiters.get(method) ?? [];
423
+ this.notificationWaiters.set(method, active.filter((candidate) => candidate !== waiter));
424
+ reject(new Error(`Timed out waiting for Codex app-server notification ${method}`));
425
+ }, timeoutMs);
426
+ }
427
+ });
257
428
  }
258
429
  disconnect(error) {
259
430
  const child = this.process;
@@ -277,6 +448,10 @@ export class CodexAdapter {
277
448
  return blocks.length ? ["# Preloaded Skills", "Use the following skills for this run.", ...blocks].join("\n\n") : undefined;
278
449
  }
279
450
  }
451
+ function optionalNonNegative(value) {
452
+ const number = Number(value);
453
+ return Number.isFinite(number) && number >= 0 ? number : undefined;
454
+ }
280
455
  function activityPhase(phase) {
281
456
  if (phase === "planning")
282
457
  return "planning";
@@ -1,5 +1,6 @@
1
1
  import type { AgentRuntime } from "../runtimeAuth.js";
2
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, TurnResult } from "./types.js";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, TurnResult } from "./types.js";
3
+ import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
4
  export type TurnRecoveryChoice = "retry" | "switch" | "cancel";
4
5
  export interface RecoveringAdapterOptions {
5
6
  initial: BuilderAdapter;
@@ -7,9 +8,10 @@ export interface RecoveringAdapterOptions {
7
8
  enabled: boolean;
8
9
  allowSwitch: boolean;
9
10
  label: string;
10
- recreate: (runtime: AgentRuntime, resumeSessionId?: string) => Promise<BuilderAdapter>;
11
+ recreate: (runtime: AgentRuntime, resumeSessionId?: string, resumeSessionRef?: ProviderSessionRefV1) => Promise<BuilderAdapter>;
11
12
  choose?: (result: TurnResult, runtime: AgentRuntime, otherRuntime: AgentRuntime) => Promise<TurnRecoveryChoice>;
12
13
  onRuntimeChange?: (runtime: AgentRuntime) => void;
14
+ onSessionRef?: (ref: ProviderSessionRefV1) => void;
13
15
  }
14
16
  /** Adds interactive recovery around structured runtime turn failures. */
15
17
  export declare class RecoveringAdapter implements BuilderAdapter {
@@ -23,8 +25,12 @@ export declare class RecoveringAdapter implements BuilderAdapter {
23
25
  get agent(): "claude" | "codex";
24
26
  sendTurn(text: string): Promise<TurnResult>;
25
27
  sessionId(): string | undefined;
28
+ sessionRef(): ProviderSessionRefV1 | undefined;
29
+ adoptSessionRef(ref: ProviderSessionRefV1): void;
30
+ validateSession(): Promise<SessionAvailabilityV1>;
26
31
  compact(): Promise<CompactResult>;
27
32
  contextUsage(): Promise<ContextUsage | undefined>;
33
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
28
34
  events(): AsyncIterable<BuilderEvent>;
29
35
  close(): Promise<void>;
30
36
  private replaceAdapter;
@@ -21,8 +21,21 @@ export class RecoveringAdapter {
21
21
  async sendTurn(text) {
22
22
  while (true) {
23
23
  const result = await this.adapter.sendTurn(text);
24
+ const sessionRef = this.adapter.sessionRef?.();
25
+ if (sessionRef && this.opts.onSessionRef) {
26
+ try {
27
+ this.opts.onSessionRef(sessionRef);
28
+ }
29
+ catch (error) {
30
+ this.eventQueue.push({ kind: "error", message: `failed to persist scoped provider session binding: ${error instanceof Error ? error.message : String(error)}` });
31
+ }
32
+ }
24
33
  if (!this.opts.enabled || !result.isError || !result.failure)
25
34
  return result;
35
+ // The host cannot know whether a missing exact session received this
36
+ // instruction. Never replay it or silently switch providers.
37
+ if (result.failure.category === "session-unavailable")
38
+ return result;
26
39
  const otherRuntime = this.runtime === "claude" ? "codex" : "claude";
27
40
  const choice = this.opts.choose
28
41
  ? await this.opts.choose(result, this.runtime, otherRuntime)
@@ -38,7 +51,8 @@ export class RecoveringAdapter {
38
51
  reportBuilderEvent(recoveryEvent);
39
52
  const nextRuntime = choice === "switch" ? otherRuntime : this.runtime;
40
53
  const resumeSessionId = choice === "retry" ? this.adapter.sessionId() : undefined;
41
- await this.replaceAdapter(nextRuntime, resumeSessionId);
54
+ const resumeSessionRef = choice === "retry" ? this.adapter.sessionRef?.() : undefined;
55
+ await this.replaceAdapter(nextRuntime, resumeSessionId, resumeSessionRef);
42
56
  if (choice === "switch") {
43
57
  log.info(`Using ${nextRuntime} for the rest of this run. This is a fresh provider session; conversational continuity was not transferred.`);
44
58
  }
@@ -47,8 +61,14 @@ export class RecoveringAdapter {
47
61
  sessionId() {
48
62
  return this.adapter.sessionId();
49
63
  }
64
+ sessionRef() { return this.adapter.sessionRef?.(); }
65
+ adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
66
+ validateSession() {
67
+ 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
+ }
50
69
  compact() { return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
51
70
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
71
+ sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
52
72
  events() {
53
73
  return this.eventQueue;
54
74
  }
@@ -60,10 +80,10 @@ export class RecoveringAdapter {
60
80
  await this.eventPump.catch(() => { });
61
81
  this.eventQueue.close();
62
82
  }
63
- async replaceAdapter(runtime, resumeSessionId) {
83
+ async replaceAdapter(runtime, resumeSessionId, resumeSessionRef) {
64
84
  await this.adapter.close().catch(() => { });
65
85
  await this.eventPump.catch(() => { });
66
- this.adapter = await this.opts.recreate(runtime, resumeSessionId);
86
+ this.adapter = await this.opts.recreate(runtime, resumeSessionId, resumeSessionRef);
67
87
  this.runtime = runtime;
68
88
  this.opts.onRuntimeChange?.(runtime);
69
89
  this.eventPump = this.forwardEvents(this.adapter);
@@ -0,0 +1,18 @@
1
+ import type { SessionAvailabilityV1 } from "rafi-spec";
2
+ import type { RuntimeFailure, SessionFailurePhase, TurnDispatchState, TurnResult } from "./types.js";
3
+ export declare class SessionUnavailableError extends Error {
4
+ readonly failure: RuntimeFailure;
5
+ constructor(input: {
6
+ runtime: "claude" | "codex";
7
+ phase: SessionFailurePhase;
8
+ dispatchState: TurnDispatchState;
9
+ executable: string;
10
+ cwd: string;
11
+ diagnostics: string;
12
+ availability?: SessionAvailabilityV1;
13
+ cause?: unknown;
14
+ });
15
+ }
16
+ export declare function sessionUnavailableResult(error: SessionUnavailableError): TurnResult;
17
+ export declare function isSessionUnavailableFailure(value: unknown): boolean;
18
+ export declare function sessionUnavailableErrorFromFailure(failure: RuntimeFailure): SessionUnavailableError;
@@ -0,0 +1,51 @@
1
+ export class SessionUnavailableError extends Error {
2
+ failure;
3
+ constructor(input) {
4
+ super(input.diagnostics, input.cause === undefined ? undefined : { cause: input.cause });
5
+ this.name = "SessionUnavailableError";
6
+ this.failure = {
7
+ runtime: input.runtime,
8
+ phase: input.phase,
9
+ category: "session-unavailable",
10
+ executable: input.executable,
11
+ cwd: input.cwd,
12
+ diagnostics: input.diagnostics,
13
+ dispatchState: input.dispatchState,
14
+ ...(input.availability ? { availability: input.availability } : {}),
15
+ };
16
+ }
17
+ }
18
+ export function sessionUnavailableResult(error) {
19
+ return {
20
+ text: error.message,
21
+ isError: true,
22
+ numTurns: 0,
23
+ costUsd: 0,
24
+ costAuthoritative: false,
25
+ failure: error.failure,
26
+ };
27
+ }
28
+ export function isSessionUnavailableFailure(value) {
29
+ if (value instanceof SessionUnavailableError)
30
+ return true;
31
+ if (!value || typeof value !== "object")
32
+ return false;
33
+ const failure = "failure" in value ? value.failure : value;
34
+ return failure?.category === "session-unavailable";
35
+ }
36
+ export function sessionUnavailableErrorFromFailure(failure) {
37
+ if (failure.category !== "session-unavailable")
38
+ throw new Error("runtime failure is not a session-unavailable failure");
39
+ const phase = failure.phase === "preflight" || failure.phase === "attach" || failure.phase === "turn"
40
+ ? failure.phase
41
+ : "turn";
42
+ return new SessionUnavailableError({
43
+ runtime: failure.runtime,
44
+ phase,
45
+ dispatchState: failure.dispatchState ?? "unknown",
46
+ executable: failure.executable,
47
+ cwd: failure.cwd,
48
+ diagnostics: failure.diagnostics,
49
+ availability: failure.availability,
50
+ });
51
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agent-agnostic interface over a coding agent ("builder").
3
3
  */
4
- import type { RuntimeProbeCategory, RuntimeProbePhase } from "rafi-spec";
4
+ import type { ConfigurableAgentRole, ProviderSessionRefV1, RuntimeProbeCategory, RuntimeProbePhase, SessionAvailabilityV1 } from "rafi-spec";
5
5
  /** A permission request surfaced by a builder before it runs a tool. */
6
6
  export interface PermissionRequest {
7
7
  toolName: string;
@@ -36,20 +36,29 @@ export type PermissionDecision = {
36
36
  /** Decides each permission request. Supplied by the foreman, called by the adapter. */
37
37
  export type PermissionHandler = (req: PermissionRequest) => Promise<PermissionDecision>;
38
38
  /** Result of a single completed turn. */
39
+ export type SessionFailurePhase = "preflight" | "attach" | "turn";
40
+ export type TurnDispatchState = "not-sent" | "unknown";
41
+ export interface RuntimeFailure {
42
+ runtime: "claude" | "codex";
43
+ phase: RuntimeProbePhase | SessionFailurePhase;
44
+ category: RuntimeProbeCategory;
45
+ executable: string;
46
+ cwd: string;
47
+ diagnostics: string;
48
+ dispatchState?: TurnDispatchState;
49
+ availability?: SessionAvailabilityV1;
50
+ }
39
51
  export interface TurnResult {
40
52
  /** Final assistant message text — where the STEP_STATUS marker lives. */
41
53
  text: string;
42
54
  isError: boolean;
43
55
  numTurns: number;
44
56
  costUsd: number;
45
- failure?: {
46
- runtime: "claude" | "codex";
47
- phase: RuntimeProbePhase;
48
- category: RuntimeProbeCategory;
49
- executable: string;
50
- cwd: string;
51
- diagnostics: string;
52
- };
57
+ /** True only when the provider supplied this value; never inferred by Rafi. */
58
+ costAuthoritative?: boolean;
59
+ inputTokens?: number;
60
+ outputTokens?: number;
61
+ failure?: RuntimeFailure;
53
62
  }
54
63
  /** Observability events emitted while a builder works. */
55
64
  export type BuilderEvent = {
@@ -86,6 +95,8 @@ export type BuilderEvent = {
86
95
  used: number;
87
96
  maximum?: number;
88
97
  percentage?: number;
98
+ observedAt?: string;
99
+ source?: "provider-event" | "provider-query" | "post-compact";
89
100
  } | {
90
101
  kind: "error";
91
102
  message: string;
@@ -94,10 +105,22 @@ export interface ContextUsage {
94
105
  used: number;
95
106
  maximum?: number;
96
107
  percentage?: number;
108
+ observedAt?: string;
109
+ source?: "provider-event" | "provider-query" | "post-compact";
110
+ }
111
+ export interface ProviderSessionUsage {
112
+ inputTokens?: number;
113
+ outputTokens?: number;
114
+ totalTokens?: number;
115
+ authoritativeCostUsd?: number;
116
+ observedAt: string;
117
+ source: "provider" | "turn-aggregate";
97
118
  }
98
119
  export interface CompactResult {
99
120
  ok: boolean;
100
121
  error?: string;
122
+ /** Preserve exact-session loss so lifecycle policy never treats it as an ordinary retryable compaction failure. */
123
+ failure?: RuntimeFailure;
101
124
  }
102
125
  export interface ProviderSettingSwitch {
103
126
  model?: string;
@@ -115,7 +138,19 @@ export interface BuilderAdapterOptions {
115
138
  /** Permission decision callback. */
116
139
  permission: PermissionHandler;
117
140
  /** Resume a prior session instead of starting fresh. */
141
+ /** @deprecated Foreman-controlled recovery must use resumeSessionRef. */
118
142
  resumeSessionId?: string;
143
+ /** Location-scoped provider conversation to validate before exact resume. */
144
+ resumeSessionRef?: ProviderSessionRefV1;
145
+ /** Canonical Rafi configuration/recovery root. Defaults to cwd for compatibility callers. */
146
+ configRoot?: string;
147
+ /** Metadata used when the provider first reveals a fresh session identity. */
148
+ sessionRole?: ConfigurableAgentRole;
149
+ sessionStream?: string;
150
+ sessionGeneration?: number;
151
+ workspaceIdentity?: string;
152
+ ticketId?: string;
153
+ deliveryUnitId?: string;
119
154
  /** Override the model; omit for the agent's default. */
120
155
  model?: string;
121
156
  /** Reasoning effort level. Claude also accepts "max"; Codex supports up to "xhigh". */
@@ -135,10 +170,18 @@ export interface BuilderAdapter {
135
170
  sendTurn(text: string): Promise<TurnResult>;
136
171
  /** Current session id, once known — used for resume. */
137
172
  sessionId(): string | undefined;
173
+ /** Current location-scoped session reference, once known. */
174
+ sessionRef?(): ProviderSessionRefV1 | undefined;
175
+ /** Host-only metadata promotion after a validated handoff is accepted. */
176
+ adoptSessionRef?(ref: ProviderSessionRefV1): void;
177
+ /** Attach/probe a requested exact session without starting a provider turn. */
178
+ validateSession?(): Promise<SessionAvailabilityV1>;
138
179
  /** Provider-native compaction on the exact live conversation. */
139
180
  compact?(): Promise<CompactResult>;
140
181
  /** Truthful provider context occupancy, when exposed by the provider. */
141
182
  contextUsage?(): Promise<ContextUsage | undefined>;
183
+ /** Provider/session cumulative totals; distinct from live context occupancy. */
184
+ sessionUsage?(): Promise<ProviderSessionUsage | undefined>;
142
185
  /** Attempt a provider-supported in-conversation model/reasoning transition. */
143
186
  switchSettings?(settings: ProviderSettingSwitch): Promise<CompactResult>;
144
187
  /** Stream of observability events. Iterate to drive a live view. */
@@ -5,7 +5,7 @@ import type { BuilderAdapter, BuilderAdapterOptions, EffortLevel, TurnResult } f
5
5
  import type { AnsweredProviderQuestion } from "./providerQuestions.js";
6
6
  import { type RoleBundle } from "./roles.js";
7
7
  import type { AgentRuntime } from "./runtimeAuth.js";
8
- import type { AgentRoleDefaultsV1 } from "rafi-spec";
8
+ import type { AgentRoleDefaultsV1, ProviderSessionRefV1 } from "rafi-spec";
9
9
  export type { EffortLevel } from "./adapters/types.js";
10
10
  export declare const VALID_EFFORT: readonly EffortLevel[];
11
11
  export interface RoleBuilderOptions {
@@ -23,6 +23,8 @@ export interface RoleBuilderOptions {
23
23
  extraSkills?: string[];
24
24
  sandboxMode?: BuilderAdapterOptions["sandboxMode"];
25
25
  resumeSessionId?: string;
26
+ /** Location-scoped exact session. Raw IDs are resolved through durable project bindings or treated as legacy candidates. */
27
+ resumeSessionRef?: ProviderSessionRefV1;
26
28
  /** Observe successfully answered provider-native questions. */
27
29
  onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
28
30
  /** Observe any provider-native question attempt, including denied prompts. */
@@ -51,6 +53,7 @@ export interface RoleInstructionRunResult {
51
53
  model?: string;
52
54
  effort?: EffortLevel;
53
55
  sessionId?: string;
56
+ sessionRef?: ProviderSessionRefV1;
54
57
  logPath: string;
55
58
  roleBundle: RoleBundle;
56
59
  skills: string[];