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
package/README.md CHANGED
@@ -105,7 +105,7 @@ What each part does:
105
105
  | `-y, --yes` | flag | off | Skips preflight confirmation. |
106
106
  | `--no-qa` | flag | QA on | Disables per-ticket QA review. |
107
107
  | `--continue` | flag | off | Resumes the most recent logged session. |
108
- | `-r, --resume <sessionId>` | session ID from `.foreman/` logs | none | Resumes a specific Claude/Codex session. |
108
+ | `-r, --resume <sessionId>` | session ID with a stored scoped binding | none | Resumes only after provider, canonical worktree, and workspace identity validation. |
109
109
  | `--branch-per-ticket` | flag | off | Runs each selected structured ticket in its own git worktree and branch. Requires initialized `.tickets/`. |
110
110
  | `--create-pr` | flag | off | Pushes each successful ticket branch and creates a GitHub PR. Implies `--branch-per-ticket`. |
111
111
  | `--pr-ready` | flag | draft PRs | Creates ready-for-review PRs instead of draft PRs when used with `--create-pr`. |
@@ -114,6 +114,8 @@ What each part does:
114
114
  | `--base <ref>` | `main` / `origin/main` / `HEAD` | current branch or `HEAD` | Base ref for root ticket branches. |
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
+ | `--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. |
117
119
 
118
120
  Auto-detected tracker files:
119
121
 
@@ -124,6 +126,7 @@ Auto-detected tracker files:
124
126
  Resume rule:
125
127
 
126
128
  - Use either `--continue` or `--resume`.
129
+ - Exact resume requires the original canonical worktree and its recorded identity. A deleted, recreated, cross-worktree, ambiguous, or unverifiable session is rejected with fresh/handoff recovery guidance.
127
130
  - Do not use both in the same command.
128
131
  - In branch mode, use `--continue --ticket <id>` to continue a ticket with its saved builder session.
129
132
  - For multiple branch tickets, repeat `--ticket` with `--continue`; do not reuse one explicit `--resume <sessionId>` across multiple tickets.
@@ -141,6 +144,10 @@ ai-foreman start ./my-project --steps 1 --branch-per-ticket --continue --ticket
141
144
 
142
145
  Saved `tickets.build` defaults in `rafi-config.yaml` can enable branch-per-ticket and completion behavior automatically. Use `--completion pr|auto-merge|direct-merge|none`, `--no-branch-per-ticket`, `--no-create-pr`, or `--auto-merge-wait` / `--no-auto-merge-wait` to override saved defaults for one run.
143
146
 
147
+ With the saved `current` strategy, Foreman works in the active branch while the user owns Git. It may edit, test, run QA, and update tracker/recovery state, but all branch/worktree, commit, push, merge, rebase, and review lifecycle commands are fenced. An unexpected active ref or worktree change pauses the run. Explicit isolated flags are reported as run overrides.
148
+
149
+ The active terminal line always includes the current role/provider, activity, truthful context state, successful compaction count, and handoff generation. At the Builder threshold, Foreman settles the in-flight action, verifies native compaction plus a fresh provider usage sample, and resumes the frozen action. The configured maximum defaults to ten successful compactions per location-scoped provider session; the next crossing uses a validated fresh handoff instead of exceeding it. Disposable QA snapshots never reuse a prior QA conversation in a new `/tmp/rafi-qa-*` worktree; they transfer cumulative state through an accepted handoff.
150
+
144
151
  ### GitHub PR Failure Recovery
145
152
 
146
153
  When `--create-pr` cannot prepare GitHub, push a branch, or create a PR, Foreman records a structured failure and blocks the affected ticket cleanly. Blocked worktrees are retained automatically so you can repair the environment and retry without losing the ticket session.
@@ -502,8 +509,14 @@ ai-foreman tickets accept-future-work 1 --project ./my-project --ticket-id T051
502
509
  ai-foreman tickets cancel T001 --project ./my-project --summary "Superseded by T002"
503
510
  ai-foreman tickets reorder T051 --project ./my-project --after T050
504
511
  ai-foreman tickets archive --project ./my-project --older-than-days 30
512
+
513
+ # Inspect immutable creation groups and reset an exact approved group
514
+ ai-foreman tickets groups list --project ./my-project
515
+ ai-foreman tickets reset --group TG-3 --project ./my-project
505
516
  ```
506
517
 
518
+ Ticket creation groups use stable repository-local IDs (`TG-1`, `TG-2`, …); newest-first list position is separate and may change. Missing definitions remain immutable members with a validated recovery snapshot. Non-interactive group restoration requires an explicit deleted-ticket policy, and missing dependency definitions stop the reset atomically.
519
+
507
520
  ## How The Loop Works
508
521
 
509
522
  Before implementation starts:
@@ -514,6 +527,8 @@ Before implementation starts:
514
527
 
515
528
  Every implementation turn must end with exactly one marker.
516
529
 
530
+ Builder and QA turns also publish a bounded cumulative continuity delta. Automatic fresh transitions stage a versioned JSON/Markdown handoff, require `HANDOFF_ACCEPTED` plus a valid successor checkpoint, and only then move the role lease. The all-in-one `rafi` CLI exposes durable handoff inspection and cache/history maintenance commands.
531
+
517
532
  Marker rules:
518
533
 
519
534
  - Put the marker on the final non-empty line.
@@ -34,6 +34,7 @@ export declare class ActivityReporter {
34
34
  private lastHeartbeatAt;
35
35
  private lineVisible;
36
36
  private paused;
37
+ private agentStatusLine?;
37
38
  constructor(command: string, options?: ActivityReporterOptions);
38
39
  begin(label: string): () => void;
39
40
  update(state: string, detail?: string, metadata?: {
@@ -44,6 +45,8 @@ export declare class ActivityReporter {
44
45
  pulse(detail?: string): void;
45
46
  note(message: string): void;
46
47
  writePersistent(text: string): void;
48
+ /** Keep role/provider/context state in the single bottom-most TTY line. */
49
+ setAgentStatus(line: string | undefined): void;
47
50
  pause(): () => void;
48
51
  dispose(): void;
49
52
  private startTimer;
package/dist/activity.js CHANGED
@@ -23,6 +23,7 @@ export class ActivityReporter {
23
23
  lastHeartbeatAt = 0;
24
24
  lineVisible = false;
25
25
  paused = 0;
26
+ agentStatusLine;
26
27
  constructor(command, options = {}) {
27
28
  this.command = command;
28
29
  this.output = options.output ?? {
@@ -90,6 +91,20 @@ export class ActivityReporter {
90
91
  this.output.write("\n");
91
92
  this.render(true);
92
93
  }
94
+ /** Keep role/provider/context state in the single bottom-most TTY line. */
95
+ setAgentStatus(line) {
96
+ this.agentStatusLine = line ? clean(line, 500) : undefined;
97
+ if (this.agentStatusLine && !this.timer) {
98
+ this.lastSignalAt = this.now();
99
+ this.lastHeartbeatAt = this.lastSignalAt;
100
+ this.startTimer();
101
+ }
102
+ if (!this.agentStatusLine && this.phases.size === 0) {
103
+ this.idle();
104
+ return;
105
+ }
106
+ this.render(true);
107
+ }
93
108
  pause() {
94
109
  this.paused++;
95
110
  this.clearLine();
@@ -104,6 +119,7 @@ export class ActivityReporter {
104
119
  }
105
120
  dispose() {
106
121
  this.phases.clear();
122
+ this.agentStatusLine = undefined;
107
123
  this.idle();
108
124
  }
109
125
  startTimer() {
@@ -113,6 +129,14 @@ export class ActivityReporter {
113
129
  this.timer.unref();
114
130
  }
115
131
  idle() {
132
+ if (this.agentStatusLine) {
133
+ this.clearLine();
134
+ this.detail = undefined;
135
+ this.provider = undefined;
136
+ this.model = undefined;
137
+ this.render(true);
138
+ return;
139
+ }
116
140
  if (this.timer)
117
141
  clearInterval(this.timer);
118
142
  this.timer = undefined;
@@ -120,27 +144,28 @@ export class ActivityReporter {
120
144
  this.detail = undefined;
121
145
  this.provider = undefined;
122
146
  this.model = undefined;
147
+ this.agentStatusLine = undefined;
123
148
  }
124
149
  currentPhase() {
125
150
  return [...this.phases.values()].at(-1);
126
151
  }
127
152
  render(force) {
128
153
  const phase = this.currentPhase();
129
- if (!phase || this.paused > 0)
154
+ if ((!phase && !this.agentStatusLine) || this.paused > 0)
130
155
  return;
131
156
  const now = this.now();
132
- const elapsed = now - phase.startedAt;
157
+ const elapsed = phase ? now - phase.startedAt : now - this.commandStartedAt;
133
158
  if (!force && elapsed < this.displayDelayMs)
134
159
  return;
135
160
  const quietFor = now - this.lastSignalAt;
136
- if (quietFor >= this.quietWarningMs && !this.quietWarningPrinted) {
161
+ if (phase && quietFor >= this.quietWarningMs && !this.quietWarningPrinted) {
137
162
  this.quietWarningPrinted = true;
138
163
  this.note(`rafi: ${this.provider ?? "provider"} has been quiet for ${formatDuration(quietFor)}; RAFI is still responsive and will keep waiting`);
139
164
  return;
140
165
  }
141
166
  const provider = [this.provider, this.model].filter(Boolean).join("/");
142
167
  const quiet = quietFor >= this.quietWarningMs ? `provider quiet ${formatDuration(quietFor)}; RAFI is responsive` : this.detail;
143
- const body = [phase.label, provider || undefined, quiet].filter(Boolean).join(" — ");
168
+ const body = [phase?.label, provider || undefined, phase ? quiet : undefined, this.agentStatusLine].filter(Boolean).join(" — ");
144
169
  if (this.output.isTTY) {
145
170
  const line = `${FRAMES[this.frame++ % FRAMES.length]} RAFI working: ${body} (${formatDuration(now - this.commandStartedAt)})`;
146
171
  this.output.write(`\r\x1b[2K${line}`);
@@ -1,14 +1,34 @@
1
- import type { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
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, TurnResult } from "./types.js";
4
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, PermissionDecision, ProviderSettingSwitch, ProviderSessionUsage, TurnResult } from "./types.js";
5
+ import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
5
6
  /**
6
7
  * Pure function: build the `options` object passed to `query()`.
7
8
  * Extracted so tests can assert on the shape without making a live SDK call.
8
9
  * `canUseTool` is omitted here — it's a closure that the constructor adds.
9
10
  */
10
11
  export declare function buildClaudeQueryOptions(opts: Omit<BuilderAdapterOptions, "permission">): Record<string, unknown>;
12
+ export declare function probeClaudeSession(ref: ProviderSessionRefV1, input: {
13
+ cwd: string;
14
+ configRoot?: string;
15
+ workspaceIdentity?: string;
16
+ role?: ProviderSessionRefV1["role"];
17
+ stream?: string;
18
+ ticketId?: string;
19
+ deliveryUnitId?: string;
20
+ getSessionInfo?: (sessionId: string, options?: {
21
+ dir?: string;
22
+ }) => Promise<SDKSessionInfo | undefined>;
23
+ now?: Date;
24
+ }): Promise<SessionAvailabilityV1>;
11
25
  export declare function permissionDecisionToClaudeResult(decision: PermissionDecision, toolUseID?: string): PermissionResult;
26
+ /** Project one cumulative SDK result without inventing or double-counting counters. */
27
+ export declare function mergeClaudeProviderSessionUsage(prior: ProviderSessionUsage, rawResult: Record<string, unknown>, observedAt?: string): {
28
+ sample: ProviderSessionUsage;
29
+ inputTokens?: number;
30
+ outputTokens?: number;
31
+ };
12
32
  export declare function claudeApiRetryEvent(message: {
13
33
  error: string;
14
34
  attempt: number;
@@ -29,12 +49,16 @@ export declare class ClaudeAdapter implements BuilderAdapter {
29
49
  private readonly abort;
30
50
  private readonly pumpDone;
31
51
  private _sessionId?;
52
+ private _sessionRef?;
32
53
  private readonly stderrChunks;
33
54
  private turnSignals;
34
55
  private structuredError?;
35
56
  private apiErrorStatus?;
36
57
  private compactResult?;
58
+ private cumulativeUsage;
37
59
  private pending?;
60
+ private terminalResult?;
61
+ private streamEnded;
38
62
  private closed;
39
63
  static create(opts: BuilderAdapterOptions): Promise<ClaudeAdapter>;
40
64
  private constructor();
@@ -45,9 +69,16 @@ export declare class ClaudeAdapter implements BuilderAdapter {
45
69
  private sendTurnInternal;
46
70
  private captureStderr;
47
71
  sessionId(): string | undefined;
72
+ sessionRef(): ProviderSessionRefV1 | undefined;
73
+ adoptSessionRef(ref: ProviderSessionRefV1): void;
74
+ validateSession(): Promise<SessionAvailabilityV1>;
48
75
  compact(): Promise<CompactResult>;
49
76
  contextUsage(): Promise<ContextUsage | undefined>;
77
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
50
78
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
51
79
  events(): AsyncIterable<BuilderEvent>;
52
80
  close(): Promise<void>;
81
+ private observeSession;
82
+ private settlePending;
83
+ private streamFailureResult;
53
84
  }
@@ -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",
@@ -79,12 +146,16 @@ export class ClaudeAdapter {
79
146
  abort = new AbortController();
80
147
  pumpDone;
81
148
  _sessionId;
149
+ _sessionRef;
82
150
  stderrChunks = [];
83
151
  turnSignals = [];
84
152
  structuredError;
85
153
  apiErrorStatus;
86
154
  compactResult;
155
+ cumulativeUsage = { observedAt: new Date(0).toISOString(), source: "provider" };
87
156
  pending;
157
+ terminalResult;
158
+ streamEnded = false;
88
159
  closed = false;
89
160
  static async create(opts) {
90
161
  try {
@@ -92,10 +163,33 @@ export class ClaudeAdapter {
92
163
  if (!runtimeExecutable) {
93
164
  throw new Error("Claude Code executable not found on PATH. Install your organization-approved Claude Code CLI, then retry.");
94
165
  }
95
- const { query } = await requireClaudeSDK();
96
- return new ClaudeAdapter({ ...opts, runtimeExecutable }, query);
166
+ const sdk = await requireClaudeSDK();
167
+ let validatedOpts = { ...opts, runtimeExecutable };
168
+ if (opts.resumeSessionRef) {
169
+ const availability = await probeClaudeSession(opts.resumeSessionRef, {
170
+ cwd: opts.cwd,
171
+ configRoot: opts.configRoot,
172
+ workspaceIdentity: opts.workspaceIdentity,
173
+ role: opts.sessionRole,
174
+ stream: opts.sessionStream,
175
+ ticketId: opts.ticketId,
176
+ deliveryUnitId: opts.deliveryUnitId,
177
+ getSessionInfo: sdk.getSessionInfo,
178
+ });
179
+ if (availability.status !== "available" || !availability.sessionRef) {
180
+ throw new SessionUnavailableError({
181
+ runtime: "claude", phase: "preflight", dispatchState: "not-sent", executable: runtimeExecutable,
182
+ cwd: opts.cwd, diagnostics: availability.detail ?? `Claude session ${opts.resumeSessionRef.sessionId} is ${availability.status}`,
183
+ availability,
184
+ });
185
+ }
186
+ validatedOpts = { ...validatedOpts, resumeSessionId: availability.sessionRef.sessionId, resumeSessionRef: availability.sessionRef };
187
+ }
188
+ return new ClaudeAdapter(validatedOpts, sdk.query);
97
189
  }
98
190
  catch (err) {
191
+ if (err instanceof SessionUnavailableError)
192
+ throw err;
99
193
  const message = err instanceof Error ? err.message : String(err);
100
194
  throw new Error(normalizeRuntimeErrorText("claude", message, null, "adapter startup"), { cause: err });
101
195
  }
@@ -103,6 +197,8 @@ export class ClaudeAdapter {
103
197
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
198
  constructor(opts, query) {
105
199
  this.opts = opts;
200
+ this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
201
+ this._sessionRef = opts.resumeSessionRef;
106
202
  this.query = query({
107
203
  prompt: this.inbox,
108
204
  options: {
@@ -143,17 +239,28 @@ export class ClaudeAdapter {
143
239
  const rawMessage = err instanceof Error ? err.message : String(err);
144
240
  const message = normalizeRuntimeErrorText("claude", rawMessage, null, "builder stream");
145
241
  this.eventQueue.push({ kind: "error", message });
146
- this.pending?.reject(new Error(message));
147
- this.pending = undefined;
242
+ const result = this.streamFailureResult(message, isMissingClaudeSession(rawMessage));
243
+ this.terminalResult = result;
244
+ this.settlePending(result);
148
245
  }
149
246
  }
150
247
  finally {
248
+ this.streamEnded = true;
249
+ if (!this.closed && this.pending) {
250
+ const result = this.streamFailureResult("Claude stream ended without a result", Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId));
251
+ this.terminalResult = result;
252
+ this.eventQueue.push({ kind: "error", message: result.text });
253
+ this.settlePending(result);
254
+ }
255
+ if (!this.closed && !this.terminalResult)
256
+ this.terminalResult = this.streamFailureResult("Claude stream ended without a result", Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId));
151
257
  this.eventQueue.close();
152
258
  }
153
259
  }
154
260
  handle(msg) {
155
261
  if ("session_id" in msg && typeof msg.session_id === "string") {
156
262
  this._sessionId = msg.session_id;
263
+ this.observeSession(msg.session_id);
157
264
  }
158
265
  if (msg.type === "assistant") {
159
266
  if (msg.error) {
@@ -221,24 +328,42 @@ export class ClaudeAdapter {
221
328
  ...this.turnSignals,
222
329
  ...this.stderrChunks,
223
330
  ].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;
331
+ const missingResumedSession = msg.is_error
332
+ && Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId)
333
+ && isMissingClaudeSession(rawDiagnostics);
334
+ const failure = msg.is_error ? missingResumedSession
335
+ ? new SessionUnavailableError({
336
+ runtime: "claude", phase: "turn", dispatchState: "unknown",
337
+ executable: this.opts.runtimeExecutable ?? "claude", cwd: this.opts.cwd,
338
+ diagnostics: rawDiagnostics,
339
+ availability: {
340
+ version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "not-found",
341
+ detail: rawDiagnostics, ...(this._sessionRef ? { sessionRef: this._sessionRef } : {}),
342
+ },
343
+ }).failure
344
+ : {
345
+ runtime: "claude",
346
+ phase: this.opts.runtimePhase ?? "builder",
347
+ category: classifyClaudeSdkFailure(this.structuredError, this.apiErrorStatus, rawDiagnostics),
348
+ executable: this.opts.runtimeExecutable ?? "claude",
349
+ cwd: this.opts.cwd,
350
+ diagnostics: rawDiagnostics,
351
+ } : undefined;
232
352
  const result = {
233
353
  text: failure ? formatClaudeFailure(failure) : text,
234
354
  isError: msg.is_error,
235
355
  numTurns: msg.num_turns,
236
356
  costUsd: msg.total_cost_usd,
357
+ costAuthoritative: Number.isFinite(msg.total_cost_usd),
237
358
  failure,
238
359
  };
360
+ const rawResult = msg;
361
+ const mergedUsage = mergeClaudeProviderSessionUsage(this.cumulativeUsage, rawResult);
362
+ result.inputTokens = mergedUsage.inputTokens;
363
+ result.outputTokens = mergedUsage.outputTokens;
364
+ this.cumulativeUsage = mergedUsage.sample;
239
365
  this.eventQueue.push({ kind: "turn-complete", result });
240
- this.pending?.resolve(result);
241
- this.pending = undefined;
366
+ this.settlePending(result, false);
242
367
  this.turnSignals = [];
243
368
  this.structuredError = undefined;
244
369
  this.apiErrorStatus = undefined;
@@ -251,6 +376,8 @@ export class ClaudeAdapter {
251
376
  sendTurnInternal(text) {
252
377
  if (this.closed)
253
378
  return Promise.reject(new Error("builder is closed"));
379
+ if (this.terminalResult || this.streamEnded)
380
+ return Promise.resolve(this.terminalResult ?? this.streamFailureResult("Claude stream is no longer available", Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId)));
254
381
  if (this.pending) {
255
382
  return Promise.reject(new Error("a turn is already in progress"));
256
383
  }
@@ -275,9 +402,31 @@ export class ClaudeAdapter {
275
402
  sessionId() {
276
403
  return this._sessionId;
277
404
  }
405
+ sessionRef() { return this._sessionRef; }
406
+ adoptSessionRef(ref) {
407
+ if (ref.provider !== "claude" || ref.sessionId !== this._sessionId)
408
+ throw new Error("cannot adopt a session reference for a different Claude conversation");
409
+ this._sessionRef = ref;
410
+ }
411
+ async validateSession() {
412
+ if (!this._sessionRef)
413
+ return { version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "Claude adapter was constructed from an unscoped raw session ID" };
414
+ return probeClaudeSession(this._sessionRef, {
415
+ cwd: this.opts.cwd,
416
+ configRoot: this.opts.configRoot,
417
+ workspaceIdentity: this.opts.workspaceIdentity,
418
+ role: this.opts.sessionRole,
419
+ stream: this.opts.sessionStream,
420
+ ticketId: this.opts.ticketId,
421
+ deliveryUnitId: this.opts.deliveryUnitId,
422
+ });
423
+ }
278
424
  async compact() {
279
425
  this.compactResult = undefined;
280
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 };
429
+ }
281
430
  if (this.compactResult)
282
431
  return this.compactResult;
283
432
  return { ok: false, error: sanitizeDiagnostics(result.text || "Claude did not emit an explicit compact status") };
@@ -285,7 +434,7 @@ export class ClaudeAdapter {
285
434
  async contextUsage() {
286
435
  try {
287
436
  const usage = await this.query.getContextUsage();
288
- const result = { used: usage.totalTokens, maximum: usage.maxTokens, percentage: usage.percentage };
437
+ const result = { used: usage.totalTokens, maximum: usage.maxTokens, percentage: usage.percentage, observedAt: new Date().toISOString(), source: "provider-query" };
289
438
  this.eventQueue.push({ kind: "context-usage", ...result });
290
439
  return result;
291
440
  }
@@ -293,6 +442,9 @@ export class ClaudeAdapter {
293
442
  return undefined;
294
443
  }
295
444
  }
445
+ async sessionUsage() {
446
+ return this.cumulativeUsage.observedAt === new Date(0).toISOString() ? undefined : { ...this.cumulativeUsage };
447
+ }
296
448
  async switchSettings(settings) {
297
449
  if (settings.effort !== this.opts.effort || settings.fast !== this.opts.fast)
298
450
  return { ok: false, error: "Claude SDK cannot change reasoning/fast controls on an existing transport" };
@@ -300,7 +452,7 @@ export class ClaudeAdapter {
300
452
  return { ok: true };
301
453
  const result = await this.sendTurn(`/model ${settings.model ?? "default"}`);
302
454
  if (result.isError)
303
- return { ok: false, error: result.text };
455
+ return { ok: false, error: result.text, ...(result.failure ? { failure: result.failure } : {}) };
304
456
  this.opts.model = settings.model;
305
457
  return { ok: true };
306
458
  }
@@ -321,6 +473,45 @@ export class ClaudeAdapter {
321
473
  this.abort.abort();
322
474
  await this.pumpDone.catch(() => { });
323
475
  }
476
+ observeSession(sessionId) {
477
+ if (this._sessionRef?.sessionId === sessionId)
478
+ return;
479
+ this._sessionRef = createProviderSessionRef({
480
+ provider: "claude", sessionId, cwd: this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
481
+ role: this.opts.sessionRole, stream: this.opts.sessionStream, generation: this.opts.sessionGeneration,
482
+ workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
483
+ source: "observed",
484
+ });
485
+ }
486
+ settlePending(result, emit = true) {
487
+ const pending = this.pending;
488
+ if (!pending)
489
+ return;
490
+ this.pending = undefined;
491
+ if (emit)
492
+ this.eventQueue.push({ kind: "turn-complete", result });
493
+ pending.resolve(result);
494
+ }
495
+ streamFailureResult(message, sessionUnavailable) {
496
+ if (sessionUnavailable) {
497
+ const availability = {
498
+ version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "not-found",
499
+ detail: message, ...(this._sessionRef ? { sessionRef: this._sessionRef } : {}),
500
+ };
501
+ return sessionUnavailableResult(new SessionUnavailableError({
502
+ runtime: "claude", phase: "turn", dispatchState: "unknown", executable: this.opts.runtimeExecutable ?? "claude",
503
+ cwd: this.opts.cwd, diagnostics: message, availability,
504
+ }));
505
+ }
506
+ return {
507
+ text: message, isError: true, numTurns: 0, costUsd: 0, costAuthoritative: false,
508
+ failure: { runtime: "claude", phase: this.opts.runtimePhase ?? "builder", category: "agent-stream", executable: this.opts.runtimeExecutable ?? "claude", cwd: this.opts.cwd, diagnostics: message, dispatchState: "unknown" },
509
+ };
510
+ }
511
+ }
512
+ function finiteNumber(value) { const parsed = Number(value); return value !== null && value !== undefined && value !== "" && Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; }
513
+ function isMissingClaudeSession(message) {
514
+ return /no conversation found with session id|session .* not found|conversation .* not found/i.test(message);
324
515
  }
325
516
  function activityPhase(phase) {
326
517
  if (phase === "planning")
@@ -1,4 +1,5 @@
1
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
1
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
2
+ import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
2
3
  export interface CodexLineResult {
3
4
  events: BuilderEvent[];
4
5
  sessionId?: string;
@@ -16,10 +17,15 @@ export declare class CodexAdapter implements BuilderAdapter {
16
17
  private pending;
17
18
  private notificationWaiters;
18
19
  private _sessionId?;
20
+ private _sessionRef?;
21
+ private sessionAvailability?;
19
22
  private initialized;
20
23
  private closed;
21
24
  private activeText;
22
25
  private usage?;
26
+ private usageRevision;
27
+ private providerSessionUsage?;
28
+ private lastTurnTokens?;
23
29
  private stderr;
24
30
  constructor(opts: BuilderAdapterOptions);
25
31
  buildInstruction(instruction: string): string;
@@ -30,8 +36,12 @@ export declare class CodexAdapter implements BuilderAdapter {
30
36
  private sendTurnInternal;
31
37
  compact(): Promise<CompactResult>;
32
38
  contextUsage(): Promise<ContextUsage | undefined>;
39
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
33
40
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
34
41
  sessionId(): string | undefined;
42
+ sessionRef(): ProviderSessionRefV1 | undefined;
43
+ adoptSessionRef(ref: ProviderSessionRefV1): void;
44
+ validateSession(): Promise<SessionAvailabilityV1>;
35
45
  events(): AsyncIterable<BuilderEvent>;
36
46
  close(): Promise<void>;
37
47
  private ensureThread;