ai-foreman 1.7.3 → 1.7.5

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 CHANGED
@@ -15,6 +15,9 @@ Loop Claude Code or Codex through tickets.
15
15
  - **Rich ticket system** — tickets carry acceptance criteria, required tests, dependencies, priority, size, and risk level. The agent reads the full context before starting each step.
16
16
  - **Future work tracking** — when the builder discovers out-of-scope work during a run, `ai-foreman tickets discover` captures it without derailing the current ticket. Discovered items live in a separate inbox until you promote them.
17
17
  - **QA that actually gates** — QA runs after every completed ticket and checks code quality, test passing, and regression protection. The ticket only closes after QA passes. QA turns do not count against `--steps`.
18
+ - **Visible long-running work** — one elapsed-time activity line identifies provider, phase, tools, retries, and quiet periods; redirected output receives timestamped heartbeats instead of ANSI animation.
19
+
20
+ Provider-managed retries are reported immediately as durable lines. A quiet-provider warning appears after 60 seconds without a signal, but Foreman does not add retries, abort the provider, or change provider retry limits. Non-TTY output receives a heartbeat every 30 seconds while work remains pending.
18
21
 
19
22
  ## Install
20
23
 
@@ -523,6 +526,8 @@ STEP_STATUS: plan_complete | ticket="T001" summary="all requested work is comple
523
526
  STEP_STATUS: needs_input | question="Which storage backend?" choices="SQLite|Postgres"
524
527
  ```
525
528
 
529
+ Provider-native `AskUserQuestion` prompts preserve their existing input/output behavior. Hosts may also observe a question only after the user successfully supplies a non-empty answer; cancelled, denied, malformed, and empty prompts do not emit answered-question telemetry. Rafi uses this boundary to recognize machine-shaped exhaustive-planning exchanges without treating generic questions as grill-me coverage.
530
+
526
531
  When QA is enabled, Foreman asks the builder to review its own work:
527
532
 
528
533
  ```txt
@@ -0,0 +1,63 @@
1
+ import type { BuilderEvent } from "./adapters/types.js";
2
+ import { AsyncQueue } from "./util/asyncQueue.js";
3
+ export interface ActivityOutput {
4
+ write(text: string): void;
5
+ readonly isTTY: boolean;
6
+ }
7
+ export interface ActivityReporterOptions {
8
+ output?: ActivityOutput;
9
+ now?: () => number;
10
+ displayDelayMs?: number;
11
+ tickMs?: number;
12
+ heartbeatMs?: number;
13
+ quietWarningMs?: number;
14
+ }
15
+ /** One process-local live view shared by nested RAFI operations. */
16
+ export declare class ActivityReporter {
17
+ readonly command: string;
18
+ private readonly output;
19
+ private readonly now;
20
+ private readonly displayDelayMs;
21
+ private readonly tickMs;
22
+ private readonly heartbeatMs;
23
+ private readonly quietWarningMs;
24
+ private readonly commandStartedAt;
25
+ private readonly phases;
26
+ private nextPhaseId;
27
+ private frame;
28
+ private timer?;
29
+ private detail?;
30
+ private provider?;
31
+ private model?;
32
+ private lastSignalAt;
33
+ private quietWarningPrinted;
34
+ private lastHeartbeatAt;
35
+ private lineVisible;
36
+ private paused;
37
+ constructor(command: string, options?: ActivityReporterOptions);
38
+ begin(label: string): () => void;
39
+ update(state: string, detail?: string, metadata?: {
40
+ provider?: string;
41
+ model?: string;
42
+ immediate?: boolean;
43
+ }): void;
44
+ pulse(detail?: string): void;
45
+ note(message: string): void;
46
+ writePersistent(text: string): void;
47
+ pause(): () => void;
48
+ dispose(): void;
49
+ private startTimer;
50
+ private idle;
51
+ private currentPhase;
52
+ private render;
53
+ private clearLine;
54
+ }
55
+ export declare function currentActivity(): ActivityReporter | undefined;
56
+ export declare function withActivityContext<T>(command: string, operation: () => Promise<T>, options?: ActivityReporterOptions): Promise<T>;
57
+ export declare function withActivityPhase<T>(label: string, operation: () => Promise<T>): Promise<T>;
58
+ export declare function pauseActivityForInput<T>(operation: () => Promise<T>): Promise<T>;
59
+ export declare function reportBuilderEvent(event: BuilderEvent): void;
60
+ /** Async event queue that also feeds the active CLI reporter without consuming the stream. */
61
+ export declare class BuilderEventQueue extends AsyncQueue<BuilderEvent> {
62
+ push(event: BuilderEvent): void;
63
+ }
@@ -0,0 +1,244 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { AsyncQueue } from "./util/asyncQueue.js";
3
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
+ /** One process-local live view shared by nested RAFI operations. */
5
+ export class ActivityReporter {
6
+ command;
7
+ output;
8
+ now;
9
+ displayDelayMs;
10
+ tickMs;
11
+ heartbeatMs;
12
+ quietWarningMs;
13
+ commandStartedAt;
14
+ phases = new Map();
15
+ nextPhaseId = 1;
16
+ frame = 0;
17
+ timer;
18
+ detail;
19
+ provider;
20
+ model;
21
+ lastSignalAt = 0;
22
+ quietWarningPrinted = false;
23
+ lastHeartbeatAt = 0;
24
+ lineVisible = false;
25
+ paused = 0;
26
+ constructor(command, options = {}) {
27
+ this.command = command;
28
+ this.output = options.output ?? {
29
+ isTTY: Boolean(process.stdout.isTTY),
30
+ write: (text) => process.stdout.write(text),
31
+ };
32
+ this.now = options.now ?? Date.now;
33
+ this.displayDelayMs = options.displayDelayMs ?? 250;
34
+ this.tickMs = options.tickMs ?? 1_000;
35
+ this.heartbeatMs = options.heartbeatMs ?? 30_000;
36
+ this.quietWarningMs = options.quietWarningMs ?? 60_000;
37
+ this.commandStartedAt = this.now();
38
+ }
39
+ begin(label) {
40
+ const phase = { id: this.nextPhaseId++, label: clean(label, 100), startedAt: this.now() };
41
+ this.phases.set(phase.id, phase);
42
+ if (this.phases.size === 1) {
43
+ this.lastSignalAt = phase.startedAt;
44
+ this.lastHeartbeatAt = phase.startedAt;
45
+ this.quietWarningPrinted = false;
46
+ this.startTimer();
47
+ }
48
+ this.render(!this.output.isTTY);
49
+ let ended = false;
50
+ return () => {
51
+ if (ended)
52
+ return;
53
+ ended = true;
54
+ this.phases.delete(phase.id);
55
+ if (this.phases.size === 0)
56
+ this.idle();
57
+ else
58
+ this.render(true);
59
+ };
60
+ }
61
+ update(state, detail, metadata = {}) {
62
+ const phase = this.currentPhase();
63
+ if (phase && state)
64
+ phase.label = clean(state, 100);
65
+ this.detail = detail ? clean(detail, 120) : undefined;
66
+ this.provider = metadata.provider ?? this.provider;
67
+ this.model = metadata.model ?? this.model;
68
+ this.lastSignalAt = this.now();
69
+ this.quietWarningPrinted = false;
70
+ this.render(metadata.immediate !== false || this.output.isTTY);
71
+ }
72
+ pulse(detail) {
73
+ if (detail)
74
+ this.detail = clean(detail, 120);
75
+ this.lastSignalAt = this.now();
76
+ this.quietWarningPrinted = false;
77
+ this.render(this.output.isTTY);
78
+ }
79
+ note(message) {
80
+ this.clearLine();
81
+ this.output.write(`${clean(message, 500)}\n`);
82
+ this.render(true);
83
+ }
84
+ writePersistent(text) {
85
+ if (!text)
86
+ return;
87
+ this.clearLine();
88
+ this.output.write(text);
89
+ if (!text.endsWith("\n"))
90
+ this.output.write("\n");
91
+ this.render(true);
92
+ }
93
+ pause() {
94
+ this.paused++;
95
+ this.clearLine();
96
+ let resumed = false;
97
+ return () => {
98
+ if (resumed)
99
+ return;
100
+ resumed = true;
101
+ this.paused = Math.max(0, this.paused - 1);
102
+ this.render(true);
103
+ };
104
+ }
105
+ dispose() {
106
+ this.phases.clear();
107
+ this.idle();
108
+ }
109
+ startTimer() {
110
+ if (this.timer)
111
+ return;
112
+ this.timer = setInterval(() => this.render(false), this.tickMs);
113
+ this.timer.unref();
114
+ }
115
+ idle() {
116
+ if (this.timer)
117
+ clearInterval(this.timer);
118
+ this.timer = undefined;
119
+ this.clearLine();
120
+ this.detail = undefined;
121
+ this.provider = undefined;
122
+ this.model = undefined;
123
+ }
124
+ currentPhase() {
125
+ return [...this.phases.values()].at(-1);
126
+ }
127
+ render(force) {
128
+ const phase = this.currentPhase();
129
+ if (!phase || this.paused > 0)
130
+ return;
131
+ const now = this.now();
132
+ const elapsed = now - phase.startedAt;
133
+ if (!force && elapsed < this.displayDelayMs)
134
+ return;
135
+ const quietFor = now - this.lastSignalAt;
136
+ if (quietFor >= this.quietWarningMs && !this.quietWarningPrinted) {
137
+ this.quietWarningPrinted = true;
138
+ this.note(`rafi: ${this.provider ?? "provider"} has been quiet for ${formatDuration(quietFor)}; RAFI is still responsive and will keep waiting`);
139
+ return;
140
+ }
141
+ const provider = [this.provider, this.model].filter(Boolean).join("/");
142
+ 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(" — ");
144
+ if (this.output.isTTY) {
145
+ const line = `${FRAMES[this.frame++ % FRAMES.length]} RAFI working: ${body} (${formatDuration(now - this.commandStartedAt)})`;
146
+ this.output.write(`\r\x1b[2K${line}`);
147
+ this.lineVisible = true;
148
+ return;
149
+ }
150
+ if (force || now - this.lastHeartbeatAt >= this.heartbeatMs) {
151
+ this.lastHeartbeatAt = now;
152
+ this.output.write(`[${new Date(now).toISOString()}] rafi working: ${body} (${formatDuration(now - this.commandStartedAt)})\n`);
153
+ }
154
+ }
155
+ clearLine() {
156
+ if (!this.lineVisible || !this.output.isTTY)
157
+ return;
158
+ this.output.write("\r\x1b[2K");
159
+ this.lineVisible = false;
160
+ }
161
+ }
162
+ const activityContext = new AsyncLocalStorage();
163
+ export function currentActivity() { return activityContext.getStore(); }
164
+ export async function withActivityContext(command, operation, options = {}) {
165
+ const existing = currentActivity();
166
+ if (existing)
167
+ return operation();
168
+ const reporter = new ActivityReporter(command, options);
169
+ return activityContext.run(reporter, async () => {
170
+ try {
171
+ return await operation();
172
+ }
173
+ finally {
174
+ reporter.dispose();
175
+ }
176
+ });
177
+ }
178
+ export async function withActivityPhase(label, operation) {
179
+ const end = currentActivity()?.begin(label);
180
+ try {
181
+ return await operation();
182
+ }
183
+ finally {
184
+ end?.();
185
+ }
186
+ }
187
+ export async function pauseActivityForInput(operation) {
188
+ const resume = currentActivity()?.pause();
189
+ try {
190
+ return await operation();
191
+ }
192
+ finally {
193
+ resume?.();
194
+ }
195
+ }
196
+ export function reportBuilderEvent(event) {
197
+ const reporter = currentActivity();
198
+ if (!reporter)
199
+ return;
200
+ if (event.kind === "activity")
201
+ reporter.update(event.state, event.detail, { provider: event.provider, model: event.model, immediate: !event.transient });
202
+ else if (event.kind === "retry") {
203
+ const attempt = event.attempt ? ` (${event.attempt}${event.maximum ? `/${event.maximum}` : ""})` : "";
204
+ const delay = event.delayMs ? ` in ${formatDuration(event.delayMs)}` : "";
205
+ reporter.note(`rafi: ${event.provider} ${clean(event.reason, 300)}; retrying${attempt}${delay}`);
206
+ reporter.update(`retrying ${event.provider}`, event.reason, { provider: event.provider });
207
+ }
208
+ else if (event.kind === "tool")
209
+ reporter.update(`running ${event.name}`, briefInput(event.input));
210
+ else if (event.kind === "text")
211
+ reporter.update("processing agent response");
212
+ else if (event.kind === "session-transition")
213
+ reporter.update(`session ${event.transition}`, event.detail);
214
+ else if (event.kind === "context-usage")
215
+ reporter.pulse(event.percentage === undefined ? undefined : `context ${event.percentage.toFixed(0)}%`);
216
+ else if (event.kind === "turn-complete")
217
+ reporter.update(event.result.isError ? "agent turn failed" : "agent turn complete");
218
+ else if (event.kind === "error")
219
+ reporter.update("provider error", event.message);
220
+ }
221
+ /** Async event queue that also feeds the active CLI reporter without consuming the stream. */
222
+ export class BuilderEventQueue extends AsyncQueue {
223
+ push(event) {
224
+ reportBuilderEvent(event);
225
+ super.push(event);
226
+ }
227
+ }
228
+ function briefInput(input) {
229
+ if (!input || typeof input !== "object")
230
+ return undefined;
231
+ const value = input;
232
+ const detail = value.command ?? value.file_path ?? value.path ?? value.pattern ?? value.query;
233
+ return detail === undefined ? undefined : clean(String(detail), 100);
234
+ }
235
+ function clean(value, maximum) {
236
+ return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
237
+ }
238
+ function formatDuration(milliseconds) {
239
+ const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
240
+ if (seconds < 60)
241
+ return `${seconds}s`;
242
+ const minutes = Math.floor(seconds / 60);
243
+ return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
244
+ }
@@ -1,12 +1,20 @@
1
+ import type { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
1
2
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
2
3
  export declare function requireClaudeSDK(): Promise<typeof import("@anthropic-ai/claude-agent-sdk")>;
3
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
4
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, PermissionDecision, ProviderSettingSwitch, TurnResult } from "./types.js";
4
5
  /**
5
6
  * Pure function: build the `options` object passed to `query()`.
6
7
  * Extracted so tests can assert on the shape without making a live SDK call.
7
8
  * `canUseTool` is omitted here — it's a closure that the constructor adds.
8
9
  */
9
10
  export declare function buildClaudeQueryOptions(opts: Omit<BuilderAdapterOptions, "permission">): Record<string, unknown>;
11
+ export declare function permissionDecisionToClaudeResult(decision: PermissionDecision, toolUseID?: string): PermissionResult;
12
+ export declare function claudeApiRetryEvent(message: {
13
+ error: string;
14
+ attempt: number;
15
+ max_retries: number;
16
+ retry_delay_ms: number;
17
+ }): BuilderEvent;
10
18
  /**
11
19
  * Drives Claude Code through the Claude Agent SDK in streaming-input mode:
12
20
  * one persistent session, follow-up turns pushed as user messages, permission
@@ -34,6 +42,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
34
42
  private pump;
35
43
  private handle;
36
44
  sendTurn(text: string): Promise<TurnResult>;
45
+ private sendTurnInternal;
37
46
  private captureStderr;
38
47
  sessionId(): string | undefined;
39
48
  compact(): Promise<CompactResult>;
@@ -10,6 +10,7 @@ export async function requireClaudeSDK() {
10
10
  }
11
11
  }
12
12
  import { AsyncQueue } from "../util/asyncQueue.js";
13
+ import { BuilderEventQueue, withActivityPhase } from "../activity.js";
13
14
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
14
15
  import { classifyClaudeSdkFailure, resolveExecutablePath, sanitizeDiagnostics, } from "../runtimeReadiness.js";
15
16
  /**
@@ -37,6 +38,33 @@ export function buildClaudeQueryOptions(opts) {
37
38
  }
38
39
  return base;
39
40
  }
41
+ export function permissionDecisionToClaudeResult(decision, toolUseID) {
42
+ if (decision.behavior === "allow") {
43
+ return {
44
+ behavior: "allow",
45
+ updatedInput: decision.updatedInput,
46
+ updatedPermissions: decision.updatedPermissions,
47
+ toolUseID,
48
+ };
49
+ }
50
+ return {
51
+ behavior: "deny",
52
+ message: decision.message,
53
+ interrupt: decision.interrupt,
54
+ toolUseID,
55
+ };
56
+ }
57
+ export function claudeApiRetryEvent(message) {
58
+ return {
59
+ kind: "retry",
60
+ provider: "claude",
61
+ reason: message.error,
62
+ attempt: message.attempt,
63
+ maximum: message.max_retries,
64
+ delayMs: message.retry_delay_ms,
65
+ managedBy: "provider",
66
+ };
67
+ }
40
68
  /**
41
69
  * Drives Claude Code through the Claude Agent SDK in streaming-input mode:
42
70
  * one persistent session, follow-up turns pushed as user messages, permission
@@ -46,7 +74,7 @@ export class ClaudeAdapter {
46
74
  opts;
47
75
  agent = "claude";
48
76
  inbox = new AsyncQueue();
49
- eventQueue = new AsyncQueue();
77
+ eventQueue = new BuilderEventQueue();
50
78
  query;
51
79
  abort = new AbortController();
52
80
  pumpDone;
@@ -81,11 +109,19 @@ export class ClaudeAdapter {
81
109
  ...buildClaudeQueryOptions(opts),
82
110
  abortController: this.abort,
83
111
  stderr: (data) => this.captureStderr(data),
84
- canUseTool: async (toolName, input) => {
85
- const decision = await opts.permission({ toolName, input });
86
- return decision.behavior === "allow"
87
- ? { behavior: "allow" }
88
- : { behavior: "deny", message: decision.message };
112
+ canUseTool: async (toolName, input, requestOptions = {}) => {
113
+ const decision = await opts.permission({
114
+ toolName,
115
+ input,
116
+ signal: requestOptions.signal,
117
+ title: requestOptions.title,
118
+ displayName: requestOptions.displayName,
119
+ description: requestOptions.description,
120
+ decisionReason: requestOptions.decisionReason,
121
+ blockedPath: requestOptions.blockedPath,
122
+ toolUseID: requestOptions.toolUseID,
123
+ });
124
+ return permissionDecisionToClaudeResult(decision, requestOptions.toolUseID);
89
125
  },
90
126
  },
91
127
  });
@@ -158,6 +194,19 @@ export class ClaudeAdapter {
158
194
  this.structuredError = msg.error;
159
195
  this.apiErrorStatus = msg.error_status;
160
196
  this.turnSignals.push(`API retry ${msg.attempt}/${msg.max_retries}: ${msg.error}${msg.error_status === null ? "" : ` (HTTP ${msg.error_status})`}`);
197
+ this.eventQueue.push(claudeApiRetryEvent(msg));
198
+ }
199
+ else if (msg.type === "tool_progress") {
200
+ this.eventQueue.push({ kind: "activity", provider: "claude", state: "running tool", detail: `${msg.tool_name} (${Math.floor(msg.elapsed_time_seconds)}s)`, transient: true });
201
+ }
202
+ else if (msg.type === "system") {
203
+ const system = msg;
204
+ if (system.subtype === "task_started" || system.subtype === "task_progress" || system.subtype === "task_updated") {
205
+ this.eventQueue.push({ kind: "activity", provider: "claude", state: "working on task", detail: typeof system.summary === "string" ? system.summary : undefined, transient: system.subtype === "task_progress" });
206
+ }
207
+ else if (system.subtype === "session_state_changed") {
208
+ this.eventQueue.push({ kind: "activity", provider: "claude", state: "provider working", detail: typeof system.state === "string" ? system.state : undefined });
209
+ }
161
210
  }
162
211
  else if (msg.type === "result") {
163
212
  if ("api_error_status" in msg)
@@ -197,12 +246,16 @@ export class ClaudeAdapter {
197
246
  }
198
247
  }
199
248
  sendTurn(text) {
249
+ return withActivityPhase(`Claude ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(text));
250
+ }
251
+ sendTurnInternal(text) {
200
252
  if (this.closed)
201
253
  return Promise.reject(new Error("builder is closed"));
202
254
  if (this.pending) {
203
255
  return Promise.reject(new Error("a turn is already in progress"));
204
256
  }
205
257
  return new Promise((resolve, reject) => {
258
+ this.eventQueue.push({ kind: "activity", state: "starting Claude turn", provider: "claude", model: this.opts.model });
206
259
  this.turnSignals = [];
207
260
  this.structuredError = undefined;
208
261
  this.apiErrorStatus = undefined;
@@ -269,6 +322,17 @@ export class ClaudeAdapter {
269
322
  await this.pumpDone.catch(() => { });
270
323
  }
271
324
  }
325
+ function activityPhase(phase) {
326
+ if (phase === "planning")
327
+ return "planning";
328
+ if (phase === "ticket-population")
329
+ return "populating tickets";
330
+ if (phase === "qa")
331
+ return "reviewing with QA";
332
+ if (phase === "uninstaller")
333
+ return "planning uninstall";
334
+ return "building";
335
+ }
272
336
  function formatClaudeFailure(failure) {
273
337
  const environmentNames = Object.keys(process.env)
274
338
  .filter((name) => /^(ANTHROPIC|CLAUDE|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i.test(name))
@@ -27,6 +27,7 @@ export declare class CodexAdapter implements BuilderAdapter {
27
27
  /** Deprecated fixture helper. Runtime execution uses only app-server. */
28
28
  buildArgs(instruction: string): string[];
29
29
  sendTurn(instruction: string): Promise<TurnResult>;
30
+ private sendTurnInternal;
30
31
  compact(): Promise<CompactResult>;
31
32
  contextUsage(): Promise<ContextUsage | undefined>;
32
33
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createInterface } from "node:readline";
5
5
  import { loadSkill } from "special-agents";
6
- import { AsyncQueue } from "../util/asyncQueue.js";
6
+ import { BuilderEventQueue, withActivityPhase } from "../activity.js";
7
7
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
8
8
  /** Compatibility parser for recorded pre-app-server JSONL fixtures. */
9
9
  export function parseCodexLine(raw) {
@@ -25,7 +25,7 @@ export function parseCodexLine(raw) {
25
25
  export class CodexAdapter {
26
26
  opts;
27
27
  agent = "codex";
28
- eventQueue = new AsyncQueue();
28
+ eventQueue = new BuilderEventQueue();
29
29
  process;
30
30
  nextId = 1;
31
31
  pending = new Map();
@@ -60,8 +60,12 @@ export class CodexAdapter {
60
60
  return args;
61
61
  }
62
62
  async sendTurn(instruction) {
63
+ return withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction));
64
+ }
65
+ async sendTurnInternal(instruction) {
63
66
  if (this.closed)
64
67
  throw new Error("builder is closed");
68
+ this.eventQueue.push({ kind: "activity", state: "starting Codex turn", provider: "codex", model: this.opts.model });
65
69
  try {
66
70
  await this.ensureThread();
67
71
  this.activeText = [];
@@ -198,7 +202,13 @@ export class CodexAdapter {
198
202
  if (!message.method)
199
203
  return;
200
204
  const params = message.params ?? {};
201
- if (message.method === "item/completed") {
205
+ if (message.method === "item/started") {
206
+ const item = params.item;
207
+ const activity = codexItemActivity(item);
208
+ if (activity)
209
+ this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
210
+ }
211
+ else if (message.method === "item/completed") {
202
212
  const item = params.item;
203
213
  if (item?.type === "agentMessage" && typeof item.text === "string") {
204
214
  this.activeText.push(item.text);
@@ -206,6 +216,19 @@ export class CodexAdapter {
206
216
  }
207
217
  else if (item?.type === "commandExecution")
208
218
  this.eventQueue.push({ kind: "tool", name: "command_execution", input: { command: item.command } });
219
+ else {
220
+ const activity = codexItemActivity(item, true);
221
+ if (activity)
222
+ this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
223
+ }
224
+ }
225
+ else if (message.method === "turn/plan/updated") {
226
+ const plan = params.plan;
227
+ const active = plan?.find((step) => step.status === "inProgress") ?? plan?.at(-1);
228
+ this.eventQueue.push({ kind: "activity", provider: "codex", state: "following plan", detail: typeof active?.step === "string" ? active.step : undefined });
229
+ }
230
+ else if (message.method.includes("reasoning") || message.method === "thread/status/changed") {
231
+ this.eventQueue.push({ kind: "activity", provider: "codex", state: message.method.includes("reasoning") ? "reasoning" : "provider working", transient: message.method.includes("Delta") });
209
232
  }
210
233
  else if (message.method === "thread/tokenUsage/updated") {
211
234
  const tokenUsage = params.tokenUsage;
@@ -215,8 +238,14 @@ export class CodexAdapter {
215
238
  this.usage = { used, maximum, ...(maximum ? { percentage: used / maximum * 100 } : {}) };
216
239
  this.eventQueue.push({ kind: "context-usage", ...this.usage });
217
240
  }
218
- else if (message.method === "error")
219
- this.eventQueue.push({ kind: "error", message: String(params.message ?? "Codex app-server error") });
241
+ else if (message.method === "error") {
242
+ const error = params.error;
243
+ const reason = String(error?.message ?? params.message ?? "Codex app-server error");
244
+ if (params.willRetry === true)
245
+ this.eventQueue.push({ kind: "retry", provider: "codex", reason, managedBy: "provider" });
246
+ else
247
+ this.eventQueue.push({ kind: "error", message: reason });
248
+ }
220
249
  const waiters = this.notificationWaiters.get(message.method) ?? [];
221
250
  const remaining = [];
222
251
  for (const waiter of waiters)
@@ -248,6 +277,37 @@ export class CodexAdapter {
248
277
  return blocks.length ? ["# Preloaded Skills", "Use the following skills for this run.", ...blocks].join("\n\n") : undefined;
249
278
  }
250
279
  }
280
+ function activityPhase(phase) {
281
+ if (phase === "planning")
282
+ return "planning";
283
+ if (phase === "ticket-population")
284
+ return "populating tickets";
285
+ if (phase === "qa")
286
+ return "reviewing with QA";
287
+ if (phase === "uninstaller")
288
+ return "planning uninstall";
289
+ return "building";
290
+ }
291
+ function codexItemActivity(item, completed = false) {
292
+ if (!item)
293
+ return undefined;
294
+ const state = completed ? "completed" : "running";
295
+ if (item.type === "commandExecution")
296
+ return { state: `${state} command`, detail: typeof item.command === "string" ? item.command : undefined };
297
+ if (item.type === "fileChange")
298
+ return { state: `${state} file changes` };
299
+ if (item.type === "mcpToolCall")
300
+ return { state: `${state} MCP tool`, detail: String(item.tool ?? item.name ?? "") || undefined };
301
+ if (item.type === "dynamicToolCall")
302
+ return { state: `${state} tool`, detail: String(item.tool ?? item.name ?? "") || undefined };
303
+ if (item.type === "webSearch")
304
+ return { state: `${state} web search`, detail: typeof item.query === "string" ? item.query : undefined };
305
+ if (item.type === "contextCompaction")
306
+ return { state: completed ? "context compacted" : "compacting context" };
307
+ if (item.type === "agentMessage")
308
+ return { state: completed ? "received response" : "writing response" };
309
+ return undefined;
310
+ }
251
311
  function loadSkillMarkdown(cwd, skill) {
252
312
  const projectPath = join(cwd, ".agents", "skills", skill, "SKILL.md");
253
313
  if (existsSync(projectPath))
@@ -1,5 +1,6 @@
1
1
  import { isCancel, log, select } from "@clack/prompts";
2
2
  import { AsyncQueue } from "../util/asyncQueue.js";
3
+ import { pauseActivityForInput, reportBuilderEvent } from "../activity.js";
3
4
  /** Adds interactive recovery around structured runtime turn failures. */
4
5
  export class RecoveringAdapter {
5
6
  opts;
@@ -25,11 +26,16 @@ export class RecoveringAdapter {
25
26
  const otherRuntime = this.runtime === "claude" ? "codex" : "claude";
26
27
  const choice = this.opts.choose
27
28
  ? await this.opts.choose(result, this.runtime, otherRuntime)
28
- : await promptTurnRecovery(result, this.opts.label, this.runtime, otherRuntime, this.opts.allowSwitch);
29
+ : await pauseActivityForInput(() => promptTurnRecovery(result, this.opts.label, this.runtime, otherRuntime, this.opts.allowSwitch));
29
30
  if (choice === "cancel")
30
31
  return result;
31
32
  if (choice === "switch" && !this.opts.allowSwitch)
32
33
  return result;
34
+ const recoveryEvent = choice === "retry"
35
+ ? { kind: "retry", provider: this.runtime, reason: `${this.opts.label} failed`, managedBy: "rafi" }
36
+ : { kind: "activity", provider: otherRuntime, state: `switching to ${otherRuntime}`, detail: "starting a fresh provider session" };
37
+ this.eventQueue.push(recoveryEvent);
38
+ reportBuilderEvent(recoveryEvent);
33
39
  const nextRuntime = choice === "switch" ? otherRuntime : this.runtime;
34
40
  const resumeSessionId = choice === "retry" ? this.adapter.sessionId() : undefined;
35
41
  await this.replaceAdapter(nextRuntime, resumeSessionId);