ai-foreman 1.7.5 → 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.
- package/README.md +17 -2
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +29 -4
- package/dist/adapters/claude.d.ts +33 -2
- package/dist/adapters/claude.js +208 -17
- package/dist/adapters/codex.d.ts +11 -1
- package/dist/adapters/codex.js +195 -20
- package/dist/adapters/recovering.d.ts +8 -2
- package/dist/adapters/recovering.js +23 -3
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +52 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +34 -0
- package/dist/branch/currentGuard.js +65 -0
- package/dist/branch/github.js +3 -2
- package/dist/branch/gitlab.js +3 -2
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +37 -3
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +51 -18
- package/dist/buildRuns.js +318 -31
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +1040 -62
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +615 -80
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +9 -5
- package/dist/foreman.js +76 -20
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +326 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +7 -6
- package/dist/qaReview.js +31 -20
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +102 -3
- package/dist/sessionLifecycle.js +578 -20
- package/dist/statusReporter.d.ts +58 -8
- package/dist/statusReporter.js +210 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- package/dist/tickets/details.d.ts +17 -0
- package/dist/tickets/details.js +94 -0
- package/dist/tickets/eligibility.d.ts +28 -0
- package/dist/tickets/eligibility.js +71 -0
- package/dist/tickets/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +28 -0
- package/dist/tickets/reset.js +214 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +23 -0
- package/dist/tickets/setupConfig.js +115 -3
- package/dist/tickets/stateDb.d.ts +42 -1
- package/dist/tickets/stateDb.js +261 -23
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +102 -2
- package/dist/workflowDb.js +460 -8
- package/package.json +35 -3
package/README.md
CHANGED
|
@@ -105,15 +105,17 @@ 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
|
|
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`. |
|
|
112
112
|
| `--keep-worktrees` | flag | remove successful worktrees | Keeps successful ticket worktrees for inspection. Blocked worktrees are kept automatically. |
|
|
113
113
|
| `--ticket <id>` | `T001` | none | Selects a branch-mode ticket to continue. Repeatable with `--continue`. |
|
|
114
114
|
| `--base <ref>` | `main` / `origin/main` / `HEAD` | current branch or `HEAD` | Base ref for root ticket branches. |
|
|
115
|
-
| `--branch-prefix <prefix>` | `
|
|
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.
|
package/dist/activity.d.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
|
96
|
-
|
|
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.
|
|
147
|
-
this.
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
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")
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -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;
|