ai-foreman 1.7.24 → 1.7.26

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 (42) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/claude.d.ts +15 -0
  3. package/dist/adapters/claude.js +149 -28
  4. package/dist/adapters/codex.d.ts +9 -0
  5. package/dist/adapters/codex.js +77 -8
  6. package/dist/adapters/fastMode.d.ts +4 -0
  7. package/dist/adapters/fastMode.js +9 -0
  8. package/dist/adapters/types.d.ts +2 -0
  9. package/dist/agentRun.js +1 -0
  10. package/dist/branch/runner.js +46 -44
  11. package/dist/buildAssignment.d.ts +21 -1
  12. package/dist/buildAssignment.js +215 -2
  13. package/dist/buildRuns.d.ts +6 -0
  14. package/dist/buildRuns.js +73 -7
  15. package/dist/cli/start.d.ts +8 -1
  16. package/dist/cli/start.js +132 -38
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +20 -2
  19. package/dist/continuity.js +11 -10
  20. package/dist/foreman.d.ts +8 -0
  21. package/dist/foreman.js +69 -10
  22. package/dist/handoffProtocol.d.ts +4 -0
  23. package/dist/handoffProtocol.js +19 -0
  24. package/dist/handoffs.d.ts +1 -1
  25. package/dist/handoffs.js +23 -4
  26. package/dist/permissions/policy.js +18 -11
  27. package/dist/permissions/shellForms.d.ts +3 -0
  28. package/dist/permissions/shellForms.js +203 -0
  29. package/dist/qaBuilderActivation.d.ts +19 -0
  30. package/dist/qaBuilderActivation.js +112 -0
  31. package/dist/qaFailureDelivery.js +1 -1
  32. package/dist/qaRecovery.js +2 -1
  33. package/dist/qaReview.d.ts +4 -0
  34. package/dist/qaReview.js +37 -7
  35. package/dist/sessionLifecycle.js +1 -1
  36. package/dist/supervisedStart.js +23 -3
  37. package/dist/supervisor.js +33 -0
  38. package/dist/tickets/groupReset.js +100 -96
  39. package/dist/tickets/reset.js +80 -69
  40. package/dist/workflowDb.d.ts +16 -1
  41. package/dist/workflowDb.js +276 -8
  42. package/package.json +2 -2
package/README.md CHANGED
@@ -116,7 +116,7 @@ Provider sessions are historical after import. Resume with `rafi build:resume <p
116
116
  | `-a, --agent <agent>` | `claude` / `codex` | single `rafi-config.yaml` target, otherwise `claude` | Builder agent. Explicit `--agent` always wins. |
117
117
  | `-m, --model <model>` | `gpt-5.5` / any supported agent model | agent default | Overrides the builder model. |
118
118
  | `--effort <level>` | Claude Code: `low` / `medium` / `high` / `xhigh`<br>Codex: `low` / `medium` / `high` / `xhigh` | agent default | Reasoning level. |
119
- | `--fast` | flag | off | Lower latency. For Codex, maps to `effort=low` when `--effort` is not set. |
119
+ | `--fast` | flag | off | Use native fast mode when the selected model supports it. Otherwise continue at standard speed with the same model and reasoning settings. |
120
120
  | `-t, --tickets <path>` | `.md` / `.txt` / `.yaml` | auto-detects standard tracker files | Sends a task file to the builder during preflight planning. |
121
121
  | `-y, --yes` | flag | off | Skips preflight confirmation. |
122
122
  | `--no-qa` | flag | QA on | Disables per-ticket QA review. |
@@ -640,6 +640,7 @@ For Claude:
640
640
  - Bash is denied if it contains an always-escalate substring.
641
641
  - Bash is otherwise allowed only for configured prefixes.
642
642
  - Every chained segment must start with an `allowBash` prefix.
643
+ - Supported project test forms include literal in-worktree `cd directory &&`, constrained `PYTHONPATH` or test environment assignments, existing project-local virtualenv Python/pytest executables, and `2>&1`. `uv run --offline --no-sync` may wrap an already allowed test command. Every pipeline executable must be allowed. Configure additional test commands under `permissions.allowBash` in `foreman.yaml`; PATH/loader overrides, substitutions, file redirection, and dependency acquisition remain separate decisions.
643
644
  - Unknown tools are denied.
644
645
  - Unknown Bash commands are denied.
645
646
 
@@ -95,9 +95,22 @@ export declare class ClaudeAdapter implements BuilderAdapter {
95
95
  private terminalResult?;
96
96
  private streamEnded;
97
97
  private closed;
98
+ private closePromise?;
99
+ private launchScopeObserved;
100
+ private providerIdentityObserved;
101
+ private toolCwd?;
102
+ private readonly toolCwds;
103
+ private fastModePrepared?;
104
+ private fastModeEnabled;
105
+ private fastModeUnavailable;
106
+ private turnSawWork;
107
+ private prepareFastMode;
108
+ private disableFastModeNotice;
109
+ private disableFastMode;
98
110
  static create(opts: BuilderAdapterOptions): Promise<ClaudeAdapter>;
99
111
  private constructor();
100
112
  /** Background loop: consume the SDK message stream until it ends. */
113
+ private providerInputs;
101
114
  private pump;
102
115
  private handle;
103
116
  sendTurn(text: string, policy?: {
@@ -107,6 +120,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
107
120
  purpose?: ProviderTurnPurpose;
108
121
  }): Promise<TurnResult>;
109
122
  private sendTurnInternal;
123
+ private sendTurnAttempt;
110
124
  private captureStderr;
111
125
  private observationContext;
112
126
  private observeToolResults;
@@ -128,6 +142,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
128
142
  observeEvents(listener: (event: BuilderEvent) => void): () => void;
129
143
  events(): AsyncIterable<BuilderEvent>;
130
144
  close(): Promise<void>;
145
+ private closeInternal;
131
146
  private failSessionIdentity;
132
147
  private observeSession;
133
148
  private settlePending;
@@ -1,7 +1,7 @@
1
1
  import { ProviderPhaseBarrier } from "../providerPhase.js";
2
2
  import { OperationDeadline } from "../util/deadline.js";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, join, relative, isAbsolute } from "node:path";
5
5
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
6
6
  export async function requireClaudeSDK() {
7
7
  try {
@@ -20,6 +20,8 @@ import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
20
20
  import { classifyClaudeSdkFailure, resolveExecutablePath, sanitizeDiagnostics, } from "../runtimeReadiness.js";
21
21
  import { createProviderSessionRef, validateProviderSessionScope, canonicalSessionPath } from "../sessionIdentity.js";
22
22
  import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFailure.js";
23
+ import { isFastModeUnavailable, reportFastModeUnavailable } from "./fastMode.js";
24
+ import { handoffSystemInstructions } from "../handoffProtocol.js";
23
25
  const DEFAULT_PROVIDER_IDLE_TIMEOUT_MS = 30 * 60_000;
24
26
  /**
25
27
  * Pure function: build the `options` object passed to `query()`.
@@ -37,7 +39,9 @@ export function buildClaudeQueryOptions(opts) {
37
39
  resume: opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId,
38
40
  permissionMode: qaReadOnly ? "default" : "acceptEdits",
39
41
  effort: opts.effort,
40
- extraArgs: opts.fast ? { fast: null } : undefined,
42
+ // Start at standard speed even when user/project settings enable fast mode.
43
+ // Enabling it before checking capabilities can silently switch the model.
44
+ settings: { fastMode: false },
41
45
  // Read-only roles still need the user's Claude authentication settings (for
42
46
  // example, an enterprise apiKeyHelper). Do not load repository-controlled
43
47
  // settings in those roles.
@@ -60,7 +64,7 @@ export function buildClaudeQueryOptions(opts) {
60
64
  const exactSkills = opts.preloadedSkillContent?.length
61
65
  ? ["# Preloaded Skills", "Use the following skills for this run.", ...opts.preloadedSkillContent.map((skill) => skill.content)].join("\n\n")
62
66
  : undefined;
63
- const systemAppend = [opts.systemPromptAppend, opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${opts.networkAccess === true ? "explicitly approved for this build" : "disabled"}; sandbox fallback disabled. If acquisition requires unavailable access, request input first or use a provenance-verified local bundle. A ticket answer does not grant runtime permissions.` : undefined, exactSkills].filter((part) => Boolean(part)).join("\n\n");
67
+ const systemAppend = [handoffSystemInstructions(opts.sessionRole, opts.systemPromptAppend), opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${opts.networkAccess === true ? "explicitly approved for this build" : "disabled"}; sandbox fallback disabled. If acquisition requires unavailable access, request input first or use a provenance-verified local bundle. A ticket answer does not grant runtime permissions.` : undefined, exactSkills].filter((part) => Boolean(part)).join("\n\n");
64
68
  if (systemAppend) {
65
69
  base.systemPrompt = { type: "preset", preset: "claude_code", append: systemAppend };
66
70
  }
@@ -216,6 +220,58 @@ export class ClaudeAdapter {
216
220
  terminalResult;
217
221
  streamEnded = false;
218
222
  closed = false;
223
+ closePromise;
224
+ launchScopeObserved = false;
225
+ providerIdentityObserved = false;
226
+ toolCwd;
227
+ toolCwds = new Map();
228
+ fastModePrepared;
229
+ fastModeEnabled = false;
230
+ fastModeUnavailable = false;
231
+ turnSawWork = false;
232
+ async prepareFastMode() {
233
+ if (!this.opts.fast || this.fastModeUnavailable)
234
+ return;
235
+ this.fastModePrepared ??= (async () => {
236
+ // A missing capability is conservative: standard speed, same model.
237
+ let models;
238
+ try {
239
+ const init = await new OperationDeadline("Claude model capabilities", this.opts.rpcTimeoutMs ?? 60_000).run(() => this.query.initializationResult());
240
+ models = init.models ?? [];
241
+ }
242
+ catch (error) {
243
+ if (!isFastModeUnavailable(String(error)))
244
+ throw error;
245
+ this.disableFastModeNotice();
246
+ return;
247
+ }
248
+ const model = models.find(item => item.value === (this.opts.model ?? "default"));
249
+ if (model?.supportsFastMode !== true || typeof this.query.applyFlagSettings !== "function") {
250
+ this.disableFastModeNotice();
251
+ return;
252
+ }
253
+ try {
254
+ await new OperationDeadline("Claude fast mode", this.opts.rpcTimeoutMs ?? 60_000).run(() => this.query.applyFlagSettings({ fastMode: true }));
255
+ this.fastModeEnabled = true;
256
+ }
257
+ catch (error) {
258
+ if (!isFastModeUnavailable(String(error)))
259
+ throw error;
260
+ await this.disableFastMode();
261
+ }
262
+ })();
263
+ await this.fastModePrepared;
264
+ }
265
+ disableFastModeNotice() {
266
+ if (!this.fastModeUnavailable)
267
+ reportFastModeUnavailable(this.opts.model);
268
+ this.fastModeUnavailable = true;
269
+ this.fastModeEnabled = false;
270
+ }
271
+ async disableFastMode() {
272
+ await new OperationDeadline("Claude standard speed", this.opts.rpcTimeoutMs ?? 60_000).run(() => this.query.applyFlagSettings({ fastMode: false }));
273
+ this.disableFastModeNotice();
274
+ }
219
275
  static async create(opts) {
220
276
  try {
221
277
  const runtimeExecutable = opts.runtimeExecutable ?? resolveExecutablePath("claude");
@@ -269,7 +325,7 @@ export class ClaudeAdapter {
269
325
  finish: (spanId, outcome) => opts.observer.store.finishSpan(spanId, { outcome }),
270
326
  } : undefined);
271
327
  this.query = query({
272
- prompt: this.inbox,
328
+ prompt: this.providerInputs(),
273
329
  options: {
274
330
  ...buildClaudeQueryOptions(opts),
275
331
  abortController: this.abort,
@@ -277,6 +333,10 @@ export class ClaudeAdapter {
277
333
  PreToolUse: [{ hooks: [async (input) => {
278
334
  if (input.hook_event_name !== "PreToolUse")
279
335
  return {};
336
+ this.toolCwd = input.cwd;
337
+ this.toolCwds.set(input.tool_use_id, input.cwd);
338
+ if (this.toolCwds.size > 1000)
339
+ this.toolCwds.delete(this.toolCwds.keys().next().value);
280
340
  const reason = this.phaseBarrier.denial(input.tool_name);
281
341
  return reason ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } } : {};
282
342
  }] }],
@@ -306,6 +366,7 @@ export class ClaudeAdapter {
306
366
  const decision = await opts.permission({
307
367
  toolName,
308
368
  input,
369
+ executionCwd: (requestOptions.toolUseID ? this.toolCwds.get(requestOptions.toolUseID) : undefined) ?? this.toolCwd ?? opts.cwd,
309
370
  signal: requestOptions.signal,
310
371
  title: requestOptions.title,
311
372
  displayName: requestOptions.displayName,
@@ -334,6 +395,17 @@ export class ClaudeAdapter {
334
395
  this.pumpDone = this.pump();
335
396
  }
336
397
  /** Background loop: consume the SDK message stream until it ends. */
398
+ async *providerInputs() {
399
+ for await (const message of this.inbox) {
400
+ // A closed queue can still contain buffered input. Never let the SDK
401
+ // submit it after we recorded a proven pre-submit cancellation/failure.
402
+ if (this.closed || this.terminalResult || this.streamEnded)
403
+ return;
404
+ if (this.pending?.instruction === message.message.content)
405
+ this.pending.submitted = true;
406
+ yield message;
407
+ }
408
+ }
337
409
  async pump() {
338
410
  try {
339
411
  for await (const msg of this.query) {
@@ -394,10 +466,14 @@ export class ClaudeAdapter {
394
466
  for (const block of msg.message.content) {
395
467
  this.eventQueue.push({ kind: "provider-item", provider: "claude", lifecycle: "completed", itemType: String(block.type ?? "unknown"), payload: structuredClone(block), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
396
468
  if (block.type === "text" && block.text) {
469
+ if (msg.error && this.fastModeEnabled && isFastModeUnavailable(block.text) && !this.turnSawWork)
470
+ continue;
471
+ this.turnSawWork = true;
397
472
  this.eventQueue.push({ kind: "text", text: block.text, byteCount: Buffer.byteLength(block.text), digest: createHash("sha256").update(block.text).digest("hex"), eventId: randomUUID(), observedAt: new Date().toISOString() });
398
473
  this.opts.observer?.signal(true);
399
474
  }
400
475
  else if (block.type === "tool_use") {
476
+ this.turnSawWork = true;
401
477
  const callId = block.id;
402
478
  this.toolCalls.set(callId, { name: block.name, input: block.input });
403
479
  if (this.toolCalls.size > 1000)
@@ -530,7 +606,8 @@ export class ClaudeAdapter {
530
606
  result.outputTokens = mergedUsage.outputTokens;
531
607
  result.usage = { scope: "session-cumulative", inputTokens: mergedUsage.sample.inputTokens, outputTokens: mergedUsage.sample.outputTokens, totalTokens: mergedUsage.sample.totalTokens, costUsd: mergedUsage.sample.authoritativeCostUsd };
532
608
  this.cumulativeUsage = mergedUsage.sample;
533
- this.eventQueue.push({ kind: "turn-complete", result, turnId: result.turnId });
609
+ if (!(result.isError && this.fastModeEnabled && !this.turnSawWork && isFastModeUnavailable(text)))
610
+ this.eventQueue.push({ kind: "turn-complete", result, turnId: result.turnId });
534
611
  this.settlePending(result, false);
535
612
  this.turnSignals = [];
536
613
  this.structuredError = undefined;
@@ -582,7 +659,17 @@ export class ClaudeAdapter {
582
659
  this.responseOnlyTurn = false;
583
660
  }
584
661
  }
585
- sendTurnInternal(text) {
662
+ async sendTurnInternal(text) {
663
+ if (this.opts.fast)
664
+ await this.prepareFastMode();
665
+ const result = await this.sendTurnAttempt(text);
666
+ if (result.isError && this.fastModeEnabled && !this.turnSawWork && isFastModeUnavailable(result.rawResponse ?? result.text) && !this.streamEnded) {
667
+ await this.disableFastMode();
668
+ return this.sendTurnAttempt(text);
669
+ }
670
+ return result;
671
+ }
672
+ sendTurnAttempt(text) {
586
673
  if (this.closed)
587
674
  return Promise.reject(new Error("builder is closed"));
588
675
  if (this.terminalResult || this.streamEnded)
@@ -593,9 +680,10 @@ export class ClaudeAdapter {
593
680
  return new Promise((resolve, reject) => {
594
681
  this.eventQueue.push({ kind: "activity", state: "starting Claude turn", provider: "claude", model: this.opts.model });
595
682
  this.turnSignals = [];
683
+ this.turnSawWork = false;
596
684
  this.structuredError = undefined;
597
685
  this.apiErrorStatus = undefined;
598
- const pending = this.pending = { resolve, reject, instruction: text, turnId: this.activeProviderTurnId ?? randomUUID() };
686
+ const pending = this.pending = { resolve, reject, instruction: text, submitted: false, turnId: this.activeProviderTurnId ?? randomUUID() };
599
687
  this.armPendingTurn(pending);
600
688
  this.inbox.push({
601
689
  type: "user",
@@ -642,15 +730,16 @@ export class ClaudeAdapter {
642
730
  // continue the same turn. Preserve that state as unknown until a
643
731
  // correlated, explicit terminal result arrives.
644
732
  const resultReason = elapsedOnly ? "elapsed_placeholder"
645
- : failed && /timed?\s*out|timeout/i.test(text) ? "timeout"
646
- : failed && /cancel(?:led|ed)/i.test(text) ? "cancelled"
647
- : failed && /permission denied/i.test(text) ? "permission_denied"
648
- : failed && /not found/i.test(text) ? "not_found"
649
- : failed && /\b(?:exit(?:ed)?|status)\s+[1-9]\d*\b/i.test(text) ? "nonzero_exit"
650
- : failed && /\b(?:failed|error|exception)\b/i.test(text) ? "explicit_error"
651
- : failed ? "unexplained_error"
652
- : "completed";
653
- const status = resultReason === "timeout" ? "timed_out" : resultReason === "cancelled" ? "cancelled" : ["permission_denied", "not_found", "nonzero_exit", "explicit_error"].includes(resultReason) ? "failed" : resultReason === "completed" ? "completed" : "unknown";
733
+ : failed && /Foreman policy: this action needs human approval\b[\s\S]*\band was NOT performed\./.test(text) ? "host_policy_denied"
734
+ : failed && /timed?\s*out|timeout/i.test(text) ? "timeout"
735
+ : failed && /cancel(?:led|ed)/i.test(text) ? "cancelled"
736
+ : failed && /permission denied/i.test(text) ? "permission_denied"
737
+ : failed && /not found/i.test(text) ? "not_found"
738
+ : failed && /\b(?:exit(?:ed)?|status)\s+[1-9]\d*\b/i.test(text) ? "nonzero_exit"
739
+ : failed && /\b(?:failed|error|exception)\b/i.test(text) ? "explicit_error"
740
+ : failed ? "unexplained_error"
741
+ : "completed";
742
+ const status = resultReason === "host_policy_denied" ? "denied" : resultReason === "timeout" ? "timed_out" : resultReason === "cancelled" ? "cancelled" : ["permission_denied", "not_found", "nonzero_exit", "explicit_error"].includes(resultReason) ? "failed" : resultReason === "completed" ? "completed" : "unknown";
654
743
  if (status !== "unknown") {
655
744
  this.completedToolCalls.add(callId);
656
745
  if (this.completedToolCalls.size > 1000)
@@ -699,7 +788,8 @@ export class ClaudeAdapter {
699
788
  });
700
789
  const prepare = async () => {
701
790
  await this.query.initializationResult();
702
- if (!this._sessionRef?.validatedAt && typeof this.query.supportedCommands === "function") {
791
+ await this.prepareFastMode();
792
+ if (!this.providerIdentityObserved && typeof this.query.supportedCommands === "function") {
703
793
  const commands = await this.query.supportedCommands();
704
794
  // Some SDK/CLI pairs expose init identity only after their first input.
705
795
  // Require an advertised built-in local command; never substitute a
@@ -708,7 +798,10 @@ export class ClaudeAdapter {
708
798
  this.opts.onLifecycleTrace?.({ phase: "local-context-initialization-start", at: new Date().toISOString() });
709
799
  const result = await this.sendTurn("/context", { responseOnly: true });
710
800
  this.opts.onLifecycleTrace?.({ phase: "local-context-initialization-complete", at: new Date().toISOString() });
711
- if (result.isError || result.failure || result.costUsd > 0)
801
+ // SDK total_cost_usd includes earlier conversation cost on resume.
802
+ // The advertised built-in command must report zero model turns;
803
+ // historical cumulative cost does not make this a paid warm-up.
804
+ if (result.isError || result.failure || result.numTurns !== 0 || (result.costUsd > 0 && result.usage?.scope !== "session-cumulative"))
712
805
  throw new Error("Claude local context initialization did not complete without model work");
713
806
  }
714
807
  }
@@ -803,7 +896,7 @@ export class ClaudeAdapter {
803
896
  // The SDK can acknowledge initialization while its CLI is still waiting
804
897
  // for first input. Sending getContextUsage then deadlocks the control
805
898
  // queue, including a later local command. Establish identity first.
806
- if (!this._sessionRef?.validatedAt && typeof this.query.supportedCommands === "function")
899
+ if (!this.providerIdentityObserved && typeof this.query.supportedCommands === "function")
807
900
  await this.prepareSession();
808
901
  await new OperationDeadline("Claude settings", this.opts.rpcTimeoutMs ?? 60_000).run(() => this.query.applyFlagSettings({ autoCompactEnabled: true }));
809
902
  const baseline = await new OperationDeadline("Claude context usage", this.opts.rpcTimeoutMs ?? 60_000).run(() => this.query.getContextUsage());
@@ -825,10 +918,10 @@ export class ClaudeAdapter {
825
918
  || !Number.isFinite(installed.autoCompactThreshold) || installed.autoCompactThreshold === undefined) {
826
919
  throw new Error("Claude did not expose an enabled native automatic-compaction threshold after configuration");
827
920
  }
828
- // `applyFlagSettings` can legitimately clamp autoCompactWindow. The
829
- // provider's reported trigger is authoritative; rejecting the entire run
830
- // merely because a configured value is below that minimum made defaults
831
- // unusable. Lifecycle code consumes this verified effective ceiling.
921
+ // A control acknowledgment may disagree with the SDK's subsequent report.
922
+ // Use that reported threshold as the available lifecycle telemetry rather
923
+ // than rejecting the build. This does not prove clamping or independently
924
+ // verify the native trigger's actual enforcement.
832
925
  const effectiveThresholdPercent = Math.max(1, Math.min(99, Math.floor(installed.autoCompactThreshold / installed.maxTokens * 100)));
833
926
  this.preparedAutoCompactionPolicy = {
834
927
  requestedThresholdPercent: threshold,
@@ -866,20 +959,32 @@ export class ClaudeAdapter {
866
959
  return { ok: false, error: "Claude SDK cannot change reasoning/fast controls on an existing transport" };
867
960
  if (settings.model === this.opts.model)
868
961
  return { ok: true };
962
+ if (this.opts.fast)
963
+ await this.prepareFastMode();
964
+ if (this.fastModeEnabled)
965
+ await new OperationDeadline("Claude standard speed", this.opts.rpcTimeoutMs ?? 60_000).run(() => this.query.applyFlagSettings({ fastMode: false }));
966
+ this.fastModeEnabled = false;
869
967
  const result = await new OperationDeadline("Claude model settings", this.opts.rpcTimeoutMs ?? 60_000).run(() => this.sendTurn(`/model ${settings.model ?? "default"}`), () => this.abort.abort());
870
968
  if (result.isError)
871
969
  return { ok: false, error: result.text, ...(result.failure ? { failure: result.failure } : {}) };
872
970
  this.opts.model = settings.model;
971
+ this.fastModePrepared = undefined;
972
+ this.fastModeUnavailable = false;
973
+ if (this.opts.fast)
974
+ await this.prepareFastMode();
873
975
  return { ok: true };
874
976
  }
875
977
  observeEvents(listener) { return this.eventQueue.observe(listener); }
876
978
  events() {
877
979
  return this.eventQueue;
878
980
  }
879
- async close() {
981
+ close() { return this.closePromise ??= this.closeInternal(); }
982
+ async closeInternal() {
880
983
  if (this.closed)
881
984
  return;
882
985
  this.closed = true;
986
+ if (this.pending)
987
+ this.settlePending(this.streamFailureResult("Claude turn interrupted; submitted work requires recovery", false));
883
988
  this.questionTrace?.finish("closed");
884
989
  this.rejectSessionIdentity(new Error("Claude session closed during initialization"));
885
990
  this.abort.abort();
@@ -896,7 +1001,7 @@ export class ClaudeAdapter {
896
1001
  failSessionIdentity(error) {
897
1002
  this.rejectSessionIdentity(error);
898
1003
  const result = sessionUnavailableResult(new SessionUnavailableError({
899
- runtime: "claude", phase: this.pending ? "turn" : "preflight", dispatchState: this.pending ? "unknown" : "not-sent",
1004
+ runtime: "claude", phase: this.pending ? "turn" : "preflight", dispatchState: this.pending?.submitted ? "unknown" : "not-sent",
900
1005
  executable: this.opts.runtimeExecutable ?? "claude", cwd: this.opts.cwd, diagnostics: error.message,
901
1006
  availability: { version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "attach-failed", detail: error.message, ...(this._sessionRef ? { sessionRef: this._sessionRef } : {}) },
902
1007
  }));
@@ -912,11 +1017,26 @@ export class ClaudeAdapter {
912
1017
  this.failSessionIdentity(error);
913
1018
  throw error;
914
1019
  }
915
- if (observedCwd !== undefined && canonicalSessionPath(observedCwd) !== canonicalSessionPath(this.opts.cwd)) {
1020
+ const launchCwd = canonicalSessionPath(this.opts.cwd);
1021
+ const currentCwd = observedCwd === undefined ? undefined : canonicalSessionPath(observedCwd);
1022
+ const withinLaunchScope = currentCwd === undefined ? false : (() => {
1023
+ const path = relative(launchCwd, currentCwd);
1024
+ return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`));
1025
+ })();
1026
+ // Establish the launch scope before trusting mutable Bash cwd. Subsequent
1027
+ // messages for the same identity may report a directory inside that scope;
1028
+ // they must not replace the immutable cwd in the durable session reference.
1029
+ const trustedResumeScope = Boolean(this.opts.resumeSessionRef?.validatedAt && canonicalSessionPath(this.opts.resumeSessionRef.cwd) === launchCwd);
1030
+ if (currentCwd !== undefined && (this.launchScopeObserved || trustedResumeScope ? !withinLaunchScope : currentCwd !== launchCwd)) {
916
1031
  const error = new Error(`Claude session cwd ${observedCwd} does not match ${this.opts.cwd}`);
917
1032
  this.failSessionIdentity(error);
918
1033
  throw error;
919
1034
  }
1035
+ if (currentCwd === launchCwd)
1036
+ this.launchScopeObserved = true;
1037
+ this.providerIdentityObserved = true;
1038
+ if (currentCwd !== undefined)
1039
+ this.toolCwd = currentCwd;
920
1040
  this._sessionId = sessionId;
921
1041
  if (this._sessionRef?.validatedAt) {
922
1042
  this.resolveSessionIdentity(this._sessionRef);
@@ -1021,19 +1141,20 @@ export class ClaudeAdapter {
1021
1141
  return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
1022
1142
  }
1023
1143
  streamFailureResult(message, sessionUnavailable) {
1144
+ const dispatchState = this.pending?.submitted === false ? "not-sent" : "unknown";
1024
1145
  if (sessionUnavailable) {
1025
1146
  const availability = {
1026
1147
  version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "not-found",
1027
1148
  detail: message, ...(this._sessionRef ? { sessionRef: this._sessionRef } : {}),
1028
1149
  };
1029
1150
  return sessionUnavailableResult(new SessionUnavailableError({
1030
- runtime: "claude", phase: "turn", dispatchState: "unknown", executable: this.opts.runtimeExecutable ?? "claude",
1151
+ runtime: "claude", phase: "turn", dispatchState, executable: this.opts.runtimeExecutable ?? "claude",
1031
1152
  cwd: this.opts.cwd, diagnostics: message, availability,
1032
1153
  }));
1033
1154
  }
1034
1155
  return {
1035
1156
  text: message, isError: true, numTurns: 0, costUsd: 0, costAuthoritative: false,
1036
- failure: { runtime: "claude", phase: this.opts.runtimePhase ?? "builder", category: "agent-stream", executable: this.opts.runtimeExecutable ?? "claude", cwd: this.opts.cwd, diagnostics: message, dispatchState: "unknown" },
1157
+ failure: { runtime: "claude", phase: this.opts.runtimePhase ?? "builder", category: "agent-stream", executable: this.opts.runtimeExecutable ?? "claude", cwd: this.opts.cwd, diagnostics: message, dispatchState },
1037
1158
  };
1038
1159
  }
1039
1160
  }
@@ -28,6 +28,7 @@ export declare class CodexAdapter implements BuilderAdapter {
28
28
  private sessionAvailability?;
29
29
  private initialized;
30
30
  private closed;
31
+ private closePromise?;
31
32
  private activeText;
32
33
  private usage?;
33
34
  private usageRevision;
@@ -53,6 +54,13 @@ export declare class CodexAdapter implements BuilderAdapter {
53
54
  private readonly answeredApprovalRequests;
54
55
  private activeProviderTurnSpanId?;
55
56
  private readonly toolSpans;
57
+ private effectiveModel?;
58
+ private fastPreparedModel?;
59
+ private fastTier?;
60
+ private readonly fastUnavailableModels;
61
+ private turnSawTool;
62
+ private disableFastMode;
63
+ private prepareFastMode;
56
64
  constructor(opts: BuilderAdapterOptions);
57
65
  contractCapabilities(): {
58
66
  sameSessionAcceptance: boolean;
@@ -89,6 +97,7 @@ export declare class CodexAdapter implements BuilderAdapter {
89
97
  observeEvents(listener: (event: BuilderEvent) => void): () => void;
90
98
  events(): AsyncIterable<BuilderEvent>;
91
99
  close(): Promise<void>;
100
+ private closeInternal;
92
101
  private ensureThread;
93
102
  private ensureThreadInternal;
94
103
  private threadAttached;
@@ -9,6 +9,8 @@ import { BuilderEventQueue, withActivityPhase } from "../activity.js";
9
9
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
10
10
  import { canonicalSessionPath, createProviderSessionRef, validateProviderSessionScope } from "../sessionIdentity.js";
11
11
  import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFailure.js";
12
+ import { isFastModeUnavailable, reportFastModeUnavailable } from "./fastMode.js";
13
+ import { handoffSystemInstructions } from "../handoffProtocol.js";
12
14
  /** Compatibility parser for recorded pre-app-server JSONL fixtures. */
13
15
  export function parseCodexLine(raw) {
14
16
  const type = raw.type;
@@ -42,6 +44,7 @@ export class CodexAdapter {
42
44
  sessionAvailability;
43
45
  initialized = false;
44
46
  closed = false;
47
+ closePromise;
45
48
  activeText = [];
46
49
  usage;
47
50
  usageRevision = 0;
@@ -67,6 +70,47 @@ export class CodexAdapter {
67
70
  answeredApprovalRequests = new Set();
68
71
  activeProviderTurnSpanId;
69
72
  toolSpans = new Map();
73
+ effectiveModel;
74
+ fastPreparedModel;
75
+ fastTier;
76
+ fastUnavailableModels = new Set();
77
+ turnSawTool = false;
78
+ disableFastMode() {
79
+ const model = this.opts.model ?? this.effectiveModel ?? "default";
80
+ if (!this.fastUnavailableModels.has(model))
81
+ reportFastModeUnavailable(model);
82
+ this.fastUnavailableModels.add(model);
83
+ this.fastTier = undefined;
84
+ }
85
+ async prepareFastMode() {
86
+ const model = this.opts.model ?? this.effectiveModel ?? "default";
87
+ if (!this.opts.fast || this.fastPreparedModel === model)
88
+ return;
89
+ this.fastPreparedModel = model;
90
+ this.fastTier = undefined;
91
+ if (this.fastUnavailableModels.has(model))
92
+ return;
93
+ try {
94
+ let cursor;
95
+ for (let page = 0; page < 20; page++) {
96
+ const result = await this.request("model/list", { includeHidden: true, ...(cursor ? { cursor } : {}) });
97
+ const entry = result.data?.find(item => item.model === model || item.id === model);
98
+ if (entry) {
99
+ this.fastTier = entry.serviceTiers?.find(tier => tier.name?.toLowerCase() === "fast" || ["fast", "priority"].includes(tier.id))?.id;
100
+ // Older app-server versions publish only the legacy speed list.
101
+ if (entry.serviceTiers === undefined && entry.additionalSpeedTiers?.includes("fast"))
102
+ this.fastTier = "fast";
103
+ break;
104
+ }
105
+ if (!result.nextCursor || result.nextCursor === cursor)
106
+ break;
107
+ cursor = result.nextCursor;
108
+ }
109
+ }
110
+ catch { /* Optional capability discovery never blocks a standard turn. */ }
111
+ if (!this.fastTier)
112
+ this.disableFastMode();
113
+ }
70
114
  constructor(opts) {
71
115
  this.opts = opts;
72
116
  this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
@@ -93,8 +137,6 @@ export class CodexAdapter {
93
137
  args.push("-m", this.opts.model);
94
138
  if (this.opts.effort)
95
139
  args.push("-c", `model_reasoning_effort=${this.opts.effort}`);
96
- else if (this.opts.fast)
97
- args.push("-c", "model_reasoning_effort=low");
98
140
  if (this._sessionId)
99
141
  args.push("resume", this._sessionId);
100
142
  args.push(instruction);
@@ -144,23 +186,38 @@ export class CodexAdapter {
144
186
  const providerInstruction = this.buildInstruction(instruction);
145
187
  try {
146
188
  await this.ensureThread();
189
+ if (this.opts.fast)
190
+ await this.prepareFastMode();
147
191
  const toolsBefore = this.observedToolCalls;
192
+ this.turnSawTool = false;
148
193
  this.activeText = [];
149
194
  this.lastTurnTokens = undefined;
150
195
  this.turnUsageBaseline = this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined;
151
196
  const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId, this.providerIdleTimeoutMs(), true);
197
+ void completion.catch(() => { });
152
198
  turnStartDispatched = true;
153
- const started = await this.request("turn/start", {
199
+ const startParams = {
154
200
  threadId: this._sessionId,
155
201
  input: [{ type: "text", text: providerInstruction, text_elements: [] }],
156
202
  cwd: this.opts.cwd,
157
- model: this.opts.model ?? null,
158
- effort: this.opts.effort ?? (this.opts.fast ? "low" : null),
203
+ model: this.opts.model ?? this.effectiveModel ?? null,
204
+ effort: this.opts.effort ?? null,
205
+ ...(this.opts.fast ? { serviceTier: this.fastTier ?? "default" } : {}),
159
206
  approvalPolicy: policy?.responseOnly || policy?.purpose && policy.purpose !== "implementation" ? "never" : this.effectiveApprovalPolicy(),
160
207
  sandboxPolicy: policy?.responseOnly || policy?.purpose && policy.purpose !== "implementation" || this.opts.sandboxMode === "read-only" || this.opts.sessionRole === "qa"
161
208
  ? { type: "readOnly", networkAccess: false }
162
209
  : { type: "workspaceWrite", writableRoots: [this.opts.cwd], networkAccess: this.effectiveNetworkAccess() },
163
- });
210
+ };
211
+ let started;
212
+ try {
213
+ started = await this.request("turn/start", startParams);
214
+ }
215
+ catch (error) {
216
+ if (!this.fastTier || !isFastModeUnavailable(String(error)) || this.turnSawTool || this.activeText.length)
217
+ throw error;
218
+ this.disableFastMode();
219
+ started = await this.request("turn/start", { ...startParams, serviceTier: "default" });
220
+ }
164
221
  this.activeNativeTurnId = String(started.turn?.id ?? "") || undefined;
165
222
  const params = await new OperationDeadline("Codex active turn", this.opts.turnDeadlineMs ?? 3_600_000).run(() => completion);
166
223
  const turn = params.turn;
@@ -168,6 +225,10 @@ export class CodexAdapter {
168
225
  const failed = turn?.status === "failed" || responseOnlyViolation;
169
226
  const error = turn?.error;
170
227
  const text = this.activeText.join("\n");
228
+ if (failed && !responseOnlyViolation && this.fastTier && !this.turnSawTool && !text && isFastModeUnavailable(String(error?.message ?? ""))) {
229
+ this.disableFastMode();
230
+ return this.sendTurnInternal(instruction, policy);
231
+ }
171
232
  const result = {
172
233
  text: failed ? normalizeRuntimeErrorText("codex", String(responseOnlyViolation ? "Response-only correction used tools; its result cannot authorize completion" : error?.message ?? text), null, "app-server turn") : text,
173
234
  isError: failed, numTurns: 1, costUsd: 0, costAuthoritative: false,
@@ -381,6 +442,8 @@ export class CodexAdapter {
381
442
  }
382
443
  async sessionUsage() { return this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined; }
383
444
  async switchSettings(settings) {
445
+ if (settings.model !== this.opts.model || settings.fast !== this.opts.fast)
446
+ this.fastPreparedModel = undefined;
384
447
  this.opts.model = settings.model;
385
448
  this.opts.effort = settings.effort;
386
449
  this.opts.fast = settings.fast;
@@ -417,7 +480,8 @@ export class CodexAdapter {
417
480
  }
418
481
  observeEvents(listener) { return this.eventQueue.observe(listener); }
419
482
  events() { return this.eventQueue; }
420
- async close() {
483
+ close() { return this.closePromise ??= this.closeInternal(); }
484
+ async closeInternal() {
421
485
  if (this.closed)
422
486
  return;
423
487
  this.closed = true;
@@ -475,7 +539,7 @@ export class CodexAdapter {
475
539
  model: this.opts.model ?? null, approvalPolicy: this.effectiveApprovalPolicy(),
476
540
  config: { sandbox_workspace_write: { network_access: this.effectiveNetworkAccess() } },
477
541
  sandbox: this.opts.sandboxMode === "read-only" || this.opts.sessionRole === "qa" ? "read-only" : "workspace-write",
478
- developerInstructions: this.opts.systemPromptAppend ?? null,
542
+ developerInstructions: handoffSystemInstructions(this.opts.sessionRole, this.opts.systemPromptAppend) ?? null,
479
543
  });
480
544
  }
481
545
  catch (error) {
@@ -546,6 +610,7 @@ export class CodexAdapter {
546
610
  }
547
611
  this.sessionAvailability = { version: 1, status: "available", checkedAt: new Date().toISOString(), observedCwd: providerCwd ?? canonicalSessionPath(this.opts.cwd), sessionRef: this._sessionRef };
548
612
  this.threadAttached = true;
613
+ this.effectiveModel = typeof result.model === "string" ? result.model : this.opts.model;
549
614
  this.eventQueue.push({ kind: "session-transition", transition: method === "thread/resume" ? "resumed" : "started" });
550
615
  }
551
616
  threadAttached = false;
@@ -614,6 +679,7 @@ export class CodexAdapter {
614
679
  if (item)
615
680
  this.eventQueue.push({ kind: "provider-item", provider: "codex", lifecycle: "started", itemType: String(item.type ?? "unknown"), payload: exactProviderItem(item), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
616
681
  if (isCodexToolItem(item)) {
682
+ this.turnSawTool = true;
617
683
  this.observedToolCalls += 1;
618
684
  const callId = codexItemId(item) ?? randomUUID();
619
685
  const name = codexToolName(item);
@@ -633,6 +699,7 @@ export class CodexAdapter {
633
699
  if (item)
634
700
  this.eventQueue.push({ kind: "provider-item", provider: "codex", lifecycle: "completed", itemType: String(item.type ?? "unknown"), payload: exactProviderItem(item), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
635
701
  if (isCodexToolItem(item)) {
702
+ this.turnSawTool = true;
636
703
  const callId = codexItemId(item);
637
704
  const spanId = callId ? this.toolSpans.get(callId) : undefined;
638
705
  const status = typeof item?.status === "string" ? item.status : undefined;
@@ -710,6 +777,8 @@ export class CodexAdapter {
710
777
  else if (message.method === "error") {
711
778
  const error = params.error;
712
779
  const reason = String(error?.message ?? params.message ?? "Codex app-server error");
780
+ if (this.fastTier && isFastModeUnavailable(reason) && !this.activeText.length && !this.turnSawTool)
781
+ return;
713
782
  if (params.willRetry === true) {
714
783
  const retryId = randomUUID();
715
784
  this.eventQueue.push(this.opts.observer
@@ -0,0 +1,4 @@
1
+ /** Only explicit speed-option rejection permits a same-session retry. Auth,
2
+ * generic model failures, timeouts, and uncertain transport failures do not. */
3
+ export declare function isFastModeUnavailable(detail: string): boolean;
4
+ export declare function reportFastModeUnavailable(model?: string): void;