ai-foreman 1.7.4 → 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
+ }
@@ -9,6 +9,12 @@ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult
9
9
  */
10
10
  export declare function buildClaudeQueryOptions(opts: Omit<BuilderAdapterOptions, "permission">): Record<string, unknown>;
11
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;
12
18
  /**
13
19
  * Drives Claude Code through the Claude Agent SDK in streaming-input mode:
14
20
  * one persistent session, follow-up turns pushed as user messages, permission
@@ -36,6 +42,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
36
42
  private pump;
37
43
  private handle;
38
44
  sendTurn(text: string): Promise<TurnResult>;
45
+ private sendTurnInternal;
39
46
  private captureStderr;
40
47
  sessionId(): string | undefined;
41
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
  /**
@@ -53,6 +54,17 @@ export function permissionDecisionToClaudeResult(decision, toolUseID) {
53
54
  toolUseID,
54
55
  };
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
+ }
56
68
  /**
57
69
  * Drives Claude Code through the Claude Agent SDK in streaming-input mode:
58
70
  * one persistent session, follow-up turns pushed as user messages, permission
@@ -62,7 +74,7 @@ export class ClaudeAdapter {
62
74
  opts;
63
75
  agent = "claude";
64
76
  inbox = new AsyncQueue();
65
- eventQueue = new AsyncQueue();
77
+ eventQueue = new BuilderEventQueue();
66
78
  query;
67
79
  abort = new AbortController();
68
80
  pumpDone;
@@ -182,6 +194,19 @@ export class ClaudeAdapter {
182
194
  this.structuredError = msg.error;
183
195
  this.apiErrorStatus = msg.error_status;
184
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
+ }
185
210
  }
186
211
  else if (msg.type === "result") {
187
212
  if ("api_error_status" in msg)
@@ -221,12 +246,16 @@ export class ClaudeAdapter {
221
246
  }
222
247
  }
223
248
  sendTurn(text) {
249
+ return withActivityPhase(`Claude ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(text));
250
+ }
251
+ sendTurnInternal(text) {
224
252
  if (this.closed)
225
253
  return Promise.reject(new Error("builder is closed"));
226
254
  if (this.pending) {
227
255
  return Promise.reject(new Error("a turn is already in progress"));
228
256
  }
229
257
  return new Promise((resolve, reject) => {
258
+ this.eventQueue.push({ kind: "activity", state: "starting Claude turn", provider: "claude", model: this.opts.model });
230
259
  this.turnSignals = [];
231
260
  this.structuredError = undefined;
232
261
  this.apiErrorStatus = undefined;
@@ -293,6 +322,17 @@ export class ClaudeAdapter {
293
322
  await this.pumpDone.catch(() => { });
294
323
  }
295
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
+ }
296
336
  function formatClaudeFailure(failure) {
297
337
  const environmentNames = Object.keys(process.env)
298
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);
@@ -59,6 +59,21 @@ export type BuilderEvent = {
59
59
  kind: "tool";
60
60
  name: string;
61
61
  input: unknown;
62
+ } | {
63
+ kind: "activity";
64
+ state: string;
65
+ detail?: string;
66
+ provider?: "claude" | "codex";
67
+ model?: string;
68
+ transient?: boolean;
69
+ } | {
70
+ kind: "retry";
71
+ provider: "claude" | "codex";
72
+ reason: string;
73
+ attempt?: number;
74
+ maximum?: number;
75
+ delayMs?: number;
76
+ managedBy: "provider" | "rafi";
62
77
  } | {
63
78
  kind: "turn-complete";
64
79
  result: TurnResult;
@@ -2,6 +2,7 @@ import { type PermissionConfig } from "./config.js";
2
2
  import { Log } from "./log.js";
3
3
  import { type StepStatus } from "./foreman.js";
4
4
  import type { BuilderAdapter, BuilderAdapterOptions, EffortLevel, TurnResult } from "./adapters/types.js";
5
+ import type { AnsweredProviderQuestion } from "./providerQuestions.js";
5
6
  import { type RoleBundle } from "./roles.js";
6
7
  import type { AgentRuntime } from "./runtimeAuth.js";
7
8
  import type { AgentRoleDefaultsV1 } from "rafi-spec";
@@ -22,6 +23,10 @@ export interface RoleBuilderOptions {
22
23
  extraSkills?: string[];
23
24
  sandboxMode?: BuilderAdapterOptions["sandboxMode"];
24
25
  resumeSessionId?: string;
26
+ /** Observe successfully answered provider-native questions. */
27
+ onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
28
+ /** Observe any provider-native question attempt, including denied prompts. */
29
+ onProviderQuestion?: (request: import("./adapters/types.js").PermissionRequest) => void;
25
30
  }
26
31
  export interface RoleBuilder {
27
32
  builder: BuilderAdapter;
package/dist/agentRun.js CHANGED
@@ -115,7 +115,11 @@ export async function createRoleBuilder(opts) {
115
115
  runtimePhase: phaseForRole(opts.role),
116
116
  model,
117
117
  resumeSessionId,
118
- permission: createPermissionHandler(policy, log, { interactive }),
118
+ permission: createPermissionHandler(policy, log, {
119
+ interactive,
120
+ onAnsweredQuestion: opts.onAnsweredQuestion,
121
+ onProviderQuestion: opts.onProviderQuestion,
122
+ }),
119
123
  effort,
120
124
  fast: opts.fast ?? saved?.fast,
121
125
  sandboxMode: opts.sandboxMode,
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { branchNodeFooter } from "./presentation.js";
4
5
  const DEFAULT_COMMAND_TIMEOUT_MS = 15_000;
5
6
  const OUTPUT_LIMIT = 2_000;
6
7
  function runCommand(cwd, bin, args, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS) {
@@ -440,7 +441,7 @@ function buildPrBody(opts) {
440
441
  `- Dependencies: ${opts.node.dependencies.length ? opts.node.dependencies.join(", ") : "None"}`,
441
442
  `- Commit: ${opts.commit ?? "N/A"}`,
442
443
  "",
443
- "Generated by Rafi ai-foreman branch-per-ticket mode.",
444
+ branchNodeFooter(opts.node),
444
445
  "",
445
446
  ].join("\n");
446
447
  }