ai-foreman 1.6.2 → 1.7.1

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 (55) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/claude.d.ts +5 -1
  3. package/dist/adapters/claude.js +41 -0
  4. package/dist/adapters/codex.d.ts +26 -28
  5. package/dist/adapters/codex.js +209 -148
  6. package/dist/adapters/recovering.d.ts +3 -1
  7. package/dist/adapters/recovering.js +2 -0
  8. package/dist/adapters/types.d.ts +29 -0
  9. package/dist/branch/planner.js +1 -1
  10. package/dist/branch/runner.d.ts +6 -0
  11. package/dist/branch/runner.js +149 -16
  12. package/dist/buildRuns.d.ts +9 -2
  13. package/dist/buildRuns.js +110 -7
  14. package/dist/cli/start.d.ts +4 -0
  15. package/dist/cli/start.js +283 -28
  16. package/dist/cli/status.js +25 -2
  17. package/dist/cli/tickets.d.ts +1 -0
  18. package/dist/cli/tickets.js +67 -27
  19. package/dist/foreman.d.ts +18 -3
  20. package/dist/foreman.js +112 -5
  21. package/dist/log.d.ts +1 -1
  22. package/dist/operationRecovery.d.ts +25 -0
  23. package/dist/operationRecovery.js +46 -0
  24. package/dist/outcomes.d.ts +16 -0
  25. package/dist/outcomes.js +27 -0
  26. package/dist/qaReview.d.ts +52 -0
  27. package/dist/qaReview.js +103 -0
  28. package/dist/qaSnapshot.d.ts +19 -0
  29. package/dist/qaSnapshot.js +88 -0
  30. package/dist/sessionLifecycle.d.ts +37 -0
  31. package/dist/sessionLifecycle.js +104 -0
  32. package/dist/sources/sourceRegistry.d.ts +49 -0
  33. package/dist/sources/sourceRegistry.js +577 -0
  34. package/dist/statusReporter.d.ts +40 -0
  35. package/dist/statusReporter.js +31 -0
  36. package/dist/ticketPopulation.d.ts +54 -0
  37. package/dist/ticketPopulation.js +209 -0
  38. package/dist/tickets/blockers.d.ts +1 -1
  39. package/dist/tickets/blockers.js +2 -0
  40. package/dist/tickets/commands.d.ts +1 -0
  41. package/dist/tickets/commands.js +31 -1
  42. package/dist/tickets/delivery.d.ts +36 -0
  43. package/dist/tickets/delivery.js +204 -4
  44. package/dist/tickets/queue.d.ts +3 -0
  45. package/dist/tickets/queue.js +50 -1
  46. package/dist/tickets/setupConfig.js +30 -2
  47. package/dist/tickets/sourceFetch.js +6 -1
  48. package/dist/tickets/stateDb.d.ts +2 -1
  49. package/dist/tickets/stateDb.js +24 -1
  50. package/dist/tickets/ticketSchema.d.ts +53 -0
  51. package/dist/tickets/ticketSchema.js +11 -0
  52. package/dist/tickets/validate.js +1 -1
  53. package/dist/workflowDb.d.ts +113 -0
  54. package/dist/workflowDb.js +261 -0
  55. package/package.json +20 -3
package/README.md CHANGED
@@ -110,7 +110,7 @@ What each part does:
110
110
  | `--ticket <id>` | `T001` | none | Selects a branch-mode ticket to continue. Repeatable with `--continue`. |
111
111
  | `--base <ref>` | `main` / `origin/main` / `HEAD` | current branch or `HEAD` | Base ref for root ticket branches. |
112
112
  | `--branch-prefix <prefix>` | `rafi` / `feature` | `rafi` | Prefix for generated ticket branches. |
113
- | `--max-branch-depth <n>` | `1` / `2` / `3` | `2` | Maximum selected branch stack depth. |
113
+ | `--max-branch-depth <n>` | positive integer | `5` | Maximum selected branch/PR stack depth (root PR is depth 1). |
114
114
 
115
115
  Auto-detected tracker files:
116
116
 
@@ -176,7 +176,7 @@ gh repo view <host>/<owner>/<repo>
176
176
  | `--fast` | flag | off | Lower latency. |
177
177
  | `-y, --yes` | flag | off | Skips confirmation before builder edits ticket files. |
178
178
 
179
- Use `ai-foreman tickets setup:init` or `setup:update` to save local, Linear, or Jira Cloud ticket sources in `rafi-config.yaml`. Linear imports use `LINEAR_API_KEY`; Jira Cloud imports use `JIRA_EMAIL` and `JIRA_API_TOKEN`. Imported provider payloads are snapshotted under ignored `.tickets/imports/`, and mirrored tickets carry `external_refs`.
179
+ Use `ai-foreman tickets setup:init` or `setup:update` to append local, Linear, or Jira Cloud sources to the project-wide registry in `rafi-config.yaml`. Linear imports use `LINEAR_API_KEY`; Jira Cloud imports use `JIRA_EMAIL` and `JIRA_API_TOKEN`. Credentials are never persisted, and mirrored tickets retain source provenance.
180
180
 
181
181
  Use `ai-foreman tickets review` to accept, defer, or dismiss pending split/combine/duplicate recommendations rendered in the progress doc.
182
182
 
@@ -1,6 +1,6 @@
1
1
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
2
2
  export declare function requireClaudeSDK(): Promise<typeof import("@anthropic-ai/claude-agent-sdk")>;
3
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, TurnResult } from "./types.js";
3
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
4
4
  /**
5
5
  * Pure function: build the `options` object passed to `query()`.
6
6
  * Extracted so tests can assert on the shape without making a live SDK call.
@@ -25,6 +25,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
25
25
  private turnSignals;
26
26
  private structuredError?;
27
27
  private apiErrorStatus?;
28
+ private compactResult?;
28
29
  private pending?;
29
30
  private closed;
30
31
  static create(opts: BuilderAdapterOptions): Promise<ClaudeAdapter>;
@@ -35,6 +36,9 @@ export declare class ClaudeAdapter implements BuilderAdapter {
35
36
  sendTurn(text: string): Promise<TurnResult>;
36
37
  private captureStderr;
37
38
  sessionId(): string | undefined;
39
+ compact(): Promise<CompactResult>;
40
+ contextUsage(): Promise<ContextUsage | undefined>;
41
+ switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
38
42
  events(): AsyncIterable<BuilderEvent>;
39
43
  close(): Promise<void>;
40
44
  }
@@ -55,6 +55,7 @@ export class ClaudeAdapter {
55
55
  turnSignals = [];
56
56
  structuredError;
57
57
  apiErrorStatus;
58
+ compactResult;
58
59
  pending;
59
60
  closed = false;
60
61
  static async create(opts) {
@@ -142,6 +143,17 @@ export class ClaudeAdapter {
142
143
  if (msg.output.length > 0)
143
144
  this.turnSignals.push(...msg.output.map((line) => `auth: ${line}`));
144
145
  }
146
+ else if (msg.type === "system" && msg.subtype === "status") {
147
+ if (msg.status === "compacting")
148
+ this.eventQueue.push({ kind: "session-transition", transition: "compacting" });
149
+ if (msg.compact_result === "success") {
150
+ this.compactResult = { ok: true };
151
+ this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
152
+ }
153
+ else if (msg.compact_result === "failed") {
154
+ this.compactResult = { ok: false, error: sanitizeDiagnostics(msg.compact_error ?? "Claude native compaction failed") };
155
+ }
156
+ }
145
157
  else if (msg.type === "system" && msg.subtype === "api_retry") {
146
158
  this.structuredError = msg.error;
147
159
  this.apiErrorStatus = msg.error_status;
@@ -210,6 +222,35 @@ export class ClaudeAdapter {
210
222
  sessionId() {
211
223
  return this._sessionId;
212
224
  }
225
+ async compact() {
226
+ this.compactResult = undefined;
227
+ const result = await this.sendTurn("/compact");
228
+ if (this.compactResult)
229
+ return this.compactResult;
230
+ return { ok: false, error: sanitizeDiagnostics(result.text || "Claude did not emit an explicit compact status") };
231
+ }
232
+ async contextUsage() {
233
+ try {
234
+ const usage = await this.query.getContextUsage();
235
+ const result = { used: usage.totalTokens, maximum: usage.maxTokens, percentage: usage.percentage };
236
+ this.eventQueue.push({ kind: "context-usage", ...result });
237
+ return result;
238
+ }
239
+ catch {
240
+ return undefined;
241
+ }
242
+ }
243
+ async switchSettings(settings) {
244
+ if (settings.effort !== this.opts.effort || settings.fast !== this.opts.fast)
245
+ return { ok: false, error: "Claude SDK cannot change reasoning/fast controls on an existing transport" };
246
+ if (settings.model === this.opts.model)
247
+ return { ok: true };
248
+ const result = await this.sendTurn(`/model ${settings.model ?? "default"}`);
249
+ if (result.isError)
250
+ return { ok: false, error: result.text };
251
+ this.opts.model = settings.model;
252
+ return { ok: true };
253
+ }
213
254
  events() {
214
255
  return this.eventQueue;
215
256
  }
@@ -1,47 +1,45 @@
1
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, TurnResult } from "./types.js";
2
- /** Result of parsing one JSONL line from `codex exec --json` output. */
1
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
3
2
  export interface CodexLineResult {
4
- /** Events to emit to the BuilderEvent stream. */
5
3
  events: BuilderEvent[];
6
- /** If set, update the stored session ID to this value. */
7
4
  sessionId?: string;
8
- /** If set, append this to the turn's accumulated text. */
9
5
  text?: string;
10
6
  }
11
- /**
12
- * Pure function: parse one JSONL event from `codex exec --json` output into
13
- * structured actions. Exported for unit testing; production code calls this
14
- * via handleEvent().
15
- */
7
+ /** Compatibility parser for recorded pre-app-server JSONL fixtures. */
16
8
  export declare function parseCodexLine(raw: Record<string, unknown>): CodexLineResult;
17
- /**
18
- * Drives Codex CLI as a subprocess. Each sendTurn() spawns `codex exec --json`
19
- * and reads JSONL from stdout. Session continuity is maintained via the
20
- * thread_id captured from the first run and passed as `resume <id>` on
21
- * subsequent turns.
22
- *
23
- * NOTE: opts.permission is accepted for interface conformance but is not
24
- * invoked. Codex manages its own tool sandboxing via --sandbox.
25
- */
9
+ /** Persistent JSON-RPC controller for one live Codex thread. */
26
10
  export declare class CodexAdapter implements BuilderAdapter {
27
11
  private readonly opts;
28
12
  readonly agent: "codex";
29
13
  private readonly eventQueue;
14
+ private process?;
15
+ private nextId;
16
+ private pending;
17
+ private notificationWaiters;
30
18
  private _sessionId?;
31
- private _closed;
32
- private _activeProc?;
19
+ private initialized;
20
+ private closed;
21
+ private activeText;
22
+ private usage?;
23
+ private stderr;
33
24
  constructor(opts: BuilderAdapterOptions);
34
- /**
35
- * Prepend role system text to a turn instruction when `systemPromptAppend` is
36
- * set. Codex reads everything from the prompt (no persistent system prompt), so
37
- * role guidance must ride along with each turn.
38
- */
39
25
  buildInstruction(instruction: string): string;
40
- /** Build the `codex exec` argument list for the given instruction. */
26
+ buildAppServerArgs(): string[];
27
+ /** Deprecated fixture helper. Runtime execution uses only app-server. */
41
28
  buildArgs(instruction: string): string[];
42
- private buildSkillsAppendix;
43
29
  sendTurn(instruction: string): Promise<TurnResult>;
30
+ compact(): Promise<CompactResult>;
31
+ contextUsage(): Promise<ContextUsage | undefined>;
32
+ switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
44
33
  sessionId(): string | undefined;
45
34
  events(): AsyncIterable<BuilderEvent>;
46
35
  close(): Promise<void>;
36
+ private ensureThread;
37
+ private threadAttached;
38
+ private ensureConnection;
39
+ private request;
40
+ private write;
41
+ private handle;
42
+ private waitFor;
43
+ private disconnect;
44
+ private buildSkillsAppendix;
47
45
  }
@@ -5,195 +5,256 @@ import { createInterface } from "node:readline";
5
5
  import { loadSkill } from "special-agents";
6
6
  import { AsyncQueue } from "../util/asyncQueue.js";
7
7
  import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
8
- /**
9
- * Pure function: parse one JSONL event from `codex exec --json` output into
10
- * structured actions. Exported for unit testing; production code calls this
11
- * via handleEvent().
12
- */
8
+ /** Compatibility parser for recorded pre-app-server JSONL fixtures. */
13
9
  export function parseCodexLine(raw) {
14
10
  const type = raw.type;
15
- if (type === "thread.started") {
16
- const sessionId = raw.thread_id;
17
- return { events: [], sessionId };
18
- }
11
+ if (type === "thread.started")
12
+ return { events: [], sessionId: raw.thread_id };
19
13
  if (type === "item.completed") {
20
14
  const item = raw.item;
21
- if (!item)
22
- return { events: [] };
23
- if (item.type === "agent_message") {
24
- const text = item.text;
25
- if (!text)
26
- return { events: [] };
27
- return { events: [{ kind: "text", text }], text };
28
- }
29
- if (item.type === "command_execution") {
30
- return {
31
- events: [{ kind: "tool", name: "command_execution", input: { command: item.command } }],
32
- };
33
- }
34
- return { events: [] };
35
- }
36
- if (type === "error") {
37
- const msg = raw.error?.message
38
- ?? JSON.stringify(raw);
39
- return { events: [{ kind: "error", message: msg }] };
15
+ if (item?.type === "agent_message" && typeof item.text === "string")
16
+ return { events: [{ kind: "text", text: item.text }], text: item.text };
17
+ if (item?.type === "command_execution")
18
+ return { events: [{ kind: "tool", name: "command_execution", input: { command: item.command } }] };
40
19
  }
20
+ if (type === "error")
21
+ return { events: [{ kind: "error", message: String(raw.error?.message ?? JSON.stringify(raw)) }] };
41
22
  return { events: [] };
42
23
  }
43
- /**
44
- * Drives Codex CLI as a subprocess. Each sendTurn() spawns `codex exec --json`
45
- * and reads JSONL from stdout. Session continuity is maintained via the
46
- * thread_id captured from the first run and passed as `resume <id>` on
47
- * subsequent turns.
48
- *
49
- * NOTE: opts.permission is accepted for interface conformance but is not
50
- * invoked. Codex manages its own tool sandboxing via --sandbox.
51
- */
24
+ /** Persistent JSON-RPC controller for one live Codex thread. */
52
25
  export class CodexAdapter {
53
26
  opts;
54
27
  agent = "codex";
55
28
  eventQueue = new AsyncQueue();
29
+ process;
30
+ nextId = 1;
31
+ pending = new Map();
32
+ notificationWaiters = new Map();
56
33
  _sessionId;
57
- _closed = false;
58
- _activeProc;
34
+ initialized = false;
35
+ closed = false;
36
+ activeText = [];
37
+ usage;
38
+ stderr = "";
59
39
  constructor(opts) {
60
40
  this.opts = opts;
61
41
  this._sessionId = opts.resumeSessionId;
62
42
  }
63
- /**
64
- * Prepend role system text to a turn instruction when `systemPromptAppend` is
65
- * set. Codex reads everything from the prompt (no persistent system prompt), so
66
- * role guidance must ride along with each turn.
67
- */
68
43
  buildInstruction(instruction) {
69
- return [
70
- this.opts.systemPromptAppend,
71
- this.buildSkillsAppendix(),
72
- instruction,
73
- ].filter((part) => Boolean(part)).join("\n\n");
44
+ return [this.opts.systemPromptAppend, this.buildSkillsAppendix(), instruction]
45
+ .filter((part) => Boolean(part)).join("\n\n");
74
46
  }
75
- /** Build the `codex exec` argument list for the given instruction. */
47
+ buildAppServerArgs() { return ["app-server", "--listen", "stdio://"]; }
48
+ /** Deprecated fixture helper. Runtime execution uses only app-server. */
76
49
  buildArgs(instruction) {
77
- const args = [
78
- "exec",
79
- "--json",
80
- "--skip-git-repo-check",
81
- "--sandbox",
82
- this.opts.sandboxMode ?? "workspace-write",
83
- "-C",
84
- this.opts.cwd,
85
- ];
50
+ const args = ["exec", "--json", "--skip-git-repo-check", "--sandbox", this.opts.sandboxMode ?? "workspace-write", "-C", this.opts.cwd];
86
51
  if (this.opts.model)
87
52
  args.push("-m", this.opts.model);
88
- if (this.opts.effort) {
53
+ if (this.opts.effort)
89
54
  args.push("-c", `model_reasoning_effort=${this.opts.effort}`);
90
- }
91
- else if (this.opts.fast) {
55
+ else if (this.opts.fast)
92
56
  args.push("-c", "model_reasoning_effort=low");
93
- }
94
- if (this._sessionId) {
57
+ if (this._sessionId)
95
58
  args.push("resume", this._sessionId);
96
- }
97
59
  args.push(instruction);
98
60
  return args;
99
61
  }
100
- buildSkillsAppendix() {
101
- if (!this.opts.skills || this.opts.skills.length === 0)
102
- return undefined;
103
- const blocks = this.opts.skills
104
- .map((skill) => loadSkillMarkdown(this.opts.cwd, skill))
105
- .filter((block) => Boolean(block));
106
- if (blocks.length === 0)
107
- return undefined;
108
- return [
109
- "# Preloaded Skills",
110
- "Use the following skills for this run.",
111
- ...blocks,
112
- ].join("\n\n");
113
- }
114
- sendTurn(instruction) {
115
- if (this._closed)
116
- return Promise.reject(new Error("builder is closed"));
117
- const args = this.buildArgs(this.buildInstruction(instruction));
118
- const textParts = [];
119
- const stderrChunks = [];
120
- return new Promise((resolve, reject) => {
121
- const proc = spawn("codex", args, {
62
+ async sendTurn(instruction) {
63
+ if (this.closed)
64
+ throw new Error("builder is closed");
65
+ try {
66
+ await this.ensureThread();
67
+ this.activeText = [];
68
+ const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId);
69
+ await this.request("turn/start", {
70
+ threadId: this._sessionId,
71
+ input: [{ type: "text", text: this.buildInstruction(instruction), text_elements: [] }],
122
72
  cwd: this.opts.cwd,
123
- stdio: ["ignore", "pipe", "pipe"],
124
- });
125
- this._activeProc = proc;
126
- const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
127
- rl.on("line", (line) => {
128
- if (!line.trim())
129
- return;
130
- let raw;
131
- try {
132
- raw = JSON.parse(line);
133
- }
134
- catch {
135
- return;
136
- }
137
- const result = parseCodexLine(raw);
138
- if (result.sessionId)
139
- this._sessionId = result.sessionId;
140
- if (result.text)
141
- textParts.push(result.text);
142
- for (const ev of result.events)
143
- this.eventQueue.push(ev);
144
- });
145
- proc.stderr.on("data", (chunk) => {
146
- stderrChunks.push(chunk);
147
- });
148
- proc.on("close", (code) => {
149
- this._activeProc = undefined;
150
- let text = textParts.join("\n");
151
- if (!text && code !== 0) {
152
- text = Buffer.concat(stderrChunks).toString().trim();
153
- }
154
- if (code !== 0) {
155
- text = normalizeRuntimeErrorText("codex", text, code, "builder turn");
156
- }
157
- const result = {
158
- text,
159
- isError: code !== 0,
160
- numTurns: 1,
161
- costUsd: 0,
162
- };
163
- this.eventQueue.push({ kind: "turn-complete", result });
164
- resolve(result);
73
+ model: this.opts.model ?? null,
74
+ effort: this.opts.effort ?? (this.opts.fast ? "low" : null),
165
75
  });
166
- proc.on("error", (err) => {
167
- this._activeProc = undefined;
168
- this.eventQueue.push({ kind: "error", message: err.message });
169
- reject(err);
76
+ const params = await completion;
77
+ const turn = params.turn;
78
+ const failed = turn?.status === "failed";
79
+ const error = turn?.error;
80
+ const text = this.activeText.join("\n");
81
+ const result = { text: failed ? normalizeRuntimeErrorText("codex", String(error?.message ?? text), null, "app-server turn") : text, isError: failed, numTurns: 1, costUsd: 0 };
82
+ this.eventQueue.push({ kind: "turn-complete", result });
83
+ return result;
84
+ }
85
+ catch (error) {
86
+ const detail = [error instanceof Error ? error.message : String(error), this.stderr].filter(Boolean).join("\n");
87
+ const text = normalizeRuntimeErrorText("codex", detail, this.process?.exitCode ?? null, "builder turn");
88
+ this.disconnect(new Error(text));
89
+ const result = { text, isError: true, numTurns: 1, costUsd: 0 };
90
+ this.eventQueue.push({ kind: "error", message: text });
91
+ this.eventQueue.push({ kind: "turn-complete", result });
92
+ return result;
93
+ }
94
+ }
95
+ async compact() {
96
+ try {
97
+ await this.ensureThread();
98
+ this.eventQueue.push({ kind: "session-transition", transition: "compacting" });
99
+ const done = this.waitFor("item/completed", (params) => {
100
+ const item = params.item;
101
+ return params.threadId === this._sessionId && item?.type === "contextCompaction";
170
102
  });
103
+ await this.request("thread/compact/start", { threadId: this._sessionId });
104
+ await done;
105
+ this.usage = undefined;
106
+ this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
107
+ return { ok: true };
108
+ }
109
+ catch (error) {
110
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
111
+ }
112
+ }
113
+ async contextUsage() { return this.usage; }
114
+ async switchSettings(settings) {
115
+ this.opts.model = settings.model;
116
+ this.opts.effort = settings.effort;
117
+ this.opts.fast = settings.fast;
118
+ return { ok: true };
119
+ }
120
+ sessionId() { return this._sessionId; }
121
+ events() { return this.eventQueue; }
122
+ async close() {
123
+ if (this.closed)
124
+ return;
125
+ this.closed = true;
126
+ this.disconnect(new Error("app-server closed"));
127
+ this.eventQueue.close();
128
+ }
129
+ async ensureThread() {
130
+ await this.ensureConnection();
131
+ if (this._sessionId && this.initialized === true && this.threadAttached)
132
+ return;
133
+ const method = this._sessionId ? "thread/resume" : "thread/start";
134
+ const result = await this.request(method, {
135
+ ...(this._sessionId ? { threadId: this._sessionId } : {}), cwd: this.opts.cwd,
136
+ model: this.opts.model ?? null, approvalPolicy: "never",
137
+ sandbox: this.opts.sandboxMode === "read-only" ? "read-only" : "workspace-write",
138
+ developerInstructions: this.opts.systemPromptAppend ?? null,
171
139
  });
140
+ const thread = result.thread;
141
+ this._sessionId = String(thread?.id ?? this._sessionId ?? "") || undefined;
142
+ if (!this._sessionId)
143
+ throw new Error(`${method} did not return a thread ID`);
144
+ this.threadAttached = true;
145
+ this.eventQueue.push({ kind: "session-transition", transition: method === "thread/resume" ? "resumed" : "started" });
172
146
  }
173
- sessionId() {
174
- return this._sessionId;
147
+ threadAttached = false;
148
+ async ensureConnection() {
149
+ if (this.process && this.initialized)
150
+ return;
151
+ this.stderr = "";
152
+ const executable = this.opts.runtimeExecutable ?? "codex";
153
+ const child = spawn(executable, this.buildAppServerArgs(), { cwd: this.opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
154
+ this.process = child;
155
+ // A short-lived server may close stdin before its stderr/exit event. The
156
+ // close handler owns rejection so diagnostics include the complete stderr.
157
+ child.stdin.on("error", () => { });
158
+ const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
159
+ lines.on("line", (line) => { if (line.trim())
160
+ try {
161
+ this.handle(JSON.parse(line));
162
+ }
163
+ catch { /* ignore non-protocol stdout */ } });
164
+ child.stderr.on("data", (chunk) => { this.stderr = `${this.stderr}${chunk.toString()}`.slice(-8192); });
165
+ child.on("error", (error) => this.disconnect(error));
166
+ child.on("close", (code) => this.disconnect(new Error(`Codex app-server exited with code ${code ?? "unknown"}`)));
167
+ await this.request("initialize", { clientInfo: { name: "rafi", title: "Rafi", version: "1" }, capabilities: { experimentalApi: true, requestAttestation: false } });
168
+ this.write({ method: "initialized", params: {} });
169
+ this.initialized = true;
175
170
  }
176
- events() {
177
- return this.eventQueue;
171
+ request(method, params) {
172
+ const id = this.nextId++;
173
+ return new Promise((resolve, reject) => {
174
+ this.pending.set(id, { resolve, reject });
175
+ try {
176
+ this.write({ id, method, params });
177
+ }
178
+ catch (error) {
179
+ this.pending.delete(id);
180
+ reject(error);
181
+ }
182
+ });
178
183
  }
179
- close() {
180
- if (this._closed)
181
- return Promise.resolve();
182
- this._closed = true;
183
- this._activeProc?.kill("SIGTERM");
184
- this.eventQueue.close();
185
- return Promise.resolve();
184
+ write(message) {
185
+ if (!this.process?.stdin.writable)
186
+ throw new Error("Codex app-server is not connected");
187
+ this.process.stdin.write(`${JSON.stringify(message)}\n`);
188
+ }
189
+ handle(message) {
190
+ if (message.id !== undefined) {
191
+ const pending = this.pending.get(message.id);
192
+ if (pending) {
193
+ this.pending.delete(message.id);
194
+ message.error ? pending.reject(new Error(message.error.message ?? `JSON-RPC error ${message.error.code ?? "unknown"}`)) : pending.resolve(message.result);
195
+ }
196
+ return;
197
+ }
198
+ if (!message.method)
199
+ return;
200
+ const params = message.params ?? {};
201
+ if (message.method === "item/completed") {
202
+ const item = params.item;
203
+ if (item?.type === "agentMessage" && typeof item.text === "string") {
204
+ this.activeText.push(item.text);
205
+ this.eventQueue.push({ kind: "text", text: item.text });
206
+ }
207
+ else if (item?.type === "commandExecution")
208
+ this.eventQueue.push({ kind: "tool", name: "command_execution", input: { command: item.command } });
209
+ }
210
+ else if (message.method === "thread/tokenUsage/updated") {
211
+ const tokenUsage = params.tokenUsage;
212
+ const total = tokenUsage?.total;
213
+ const used = Number(total?.totalTokens ?? 0);
214
+ const maximum = Number(tokenUsage?.modelContextWindow ?? 0) || undefined;
215
+ this.usage = { used, maximum, ...(maximum ? { percentage: used / maximum * 100 } : {}) };
216
+ this.eventQueue.push({ kind: "context-usage", ...this.usage });
217
+ }
218
+ else if (message.method === "error")
219
+ this.eventQueue.push({ kind: "error", message: String(params.message ?? "Codex app-server error") });
220
+ const waiters = this.notificationWaiters.get(message.method) ?? [];
221
+ const remaining = [];
222
+ for (const waiter of waiters)
223
+ waiter.predicate(params) ? waiter.resolve(params) : remaining.push(waiter);
224
+ this.notificationWaiters.set(message.method, remaining);
225
+ }
226
+ waitFor(method, predicate) {
227
+ return new Promise((resolve, reject) => { const waiters = this.notificationWaiters.get(method) ?? []; waiters.push({ predicate, resolve, reject }); this.notificationWaiters.set(method, waiters); });
228
+ }
229
+ disconnect(error) {
230
+ const child = this.process;
231
+ this.process = undefined;
232
+ this.initialized = false;
233
+ this.threadAttached = false;
234
+ if (child && child.exitCode === null)
235
+ child.kill("SIGTERM");
236
+ for (const pending of this.pending.values())
237
+ pending.reject(error);
238
+ this.pending.clear();
239
+ for (const waiters of this.notificationWaiters.values())
240
+ for (const waiter of waiters)
241
+ waiter.reject(error);
242
+ this.notificationWaiters.clear();
243
+ }
244
+ buildSkillsAppendix() {
245
+ if (!this.opts.skills?.length)
246
+ return undefined;
247
+ const blocks = this.opts.skills.map((skill) => loadSkillMarkdown(this.opts.cwd, skill)).filter((block) => Boolean(block));
248
+ return blocks.length ? ["# Preloaded Skills", "Use the following skills for this run.", ...blocks].join("\n\n") : undefined;
186
249
  }
187
250
  }
188
251
  function loadSkillMarkdown(cwd, skill) {
189
252
  const projectPath = join(cwd, ".agents", "skills", skill, "SKILL.md");
190
- if (existsSync(projectPath)) {
253
+ if (existsSync(projectPath))
191
254
  return `## ${skill}\n${readFileSync(projectPath, "utf8").trim()}`;
192
- }
193
255
  try {
194
256
  const bundled = loadSkill(skill);
195
- const body = bundled.body?.trim();
196
- return body ? `## ${bundled.name}\n${body}` : undefined;
257
+ return bundled.body?.trim() ? `## ${bundled.name}\n${bundled.body.trim()}` : undefined;
197
258
  }
198
259
  catch {
199
260
  return undefined;
@@ -1,5 +1,5 @@
1
1
  import type { AgentRuntime } from "../runtimeAuth.js";
2
- import type { BuilderAdapter, BuilderEvent, TurnResult } from "./types.js";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, TurnResult } from "./types.js";
3
3
  export type TurnRecoveryChoice = "retry" | "switch" | "cancel";
4
4
  export interface RecoveringAdapterOptions {
5
5
  initial: BuilderAdapter;
@@ -23,6 +23,8 @@ export declare class RecoveringAdapter implements BuilderAdapter {
23
23
  get agent(): "claude" | "codex";
24
24
  sendTurn(text: string): Promise<TurnResult>;
25
25
  sessionId(): string | undefined;
26
+ compact(): Promise<CompactResult>;
27
+ contextUsage(): Promise<ContextUsage | undefined>;
26
28
  events(): AsyncIterable<BuilderEvent>;
27
29
  close(): Promise<void>;
28
30
  private replaceAdapter;
@@ -41,6 +41,8 @@ export class RecoveringAdapter {
41
41
  sessionId() {
42
42
  return this.adapter.sessionId();
43
43
  }
44
+ compact() { return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
45
+ contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
44
46
  events() {
45
47
  return this.eventQueue;
46
48
  }
@@ -45,10 +45,33 @@ export type BuilderEvent = {
45
45
  } | {
46
46
  kind: "turn-complete";
47
47
  result: TurnResult;
48
+ } | {
49
+ kind: "session-transition";
50
+ transition: "started" | "resumed" | "compacting" | "compacted" | "fresh-fallback";
51
+ detail?: string;
52
+ } | {
53
+ kind: "context-usage";
54
+ used: number;
55
+ maximum?: number;
56
+ percentage?: number;
48
57
  } | {
49
58
  kind: "error";
50
59
  message: string;
51
60
  };
61
+ export interface ContextUsage {
62
+ used: number;
63
+ maximum?: number;
64
+ percentage?: number;
65
+ }
66
+ export interface CompactResult {
67
+ ok: boolean;
68
+ error?: string;
69
+ }
70
+ export interface ProviderSettingSwitch {
71
+ model?: string;
72
+ effort?: EffortLevel;
73
+ fast?: boolean;
74
+ }
52
75
  export type EffortLevel = "low" | "medium" | "high" | "xhigh";
53
76
  export interface BuilderAdapterOptions {
54
77
  /** Working directory the builder operates in. */
@@ -80,6 +103,12 @@ export interface BuilderAdapter {
80
103
  sendTurn(text: string): Promise<TurnResult>;
81
104
  /** Current session id, once known — used for resume. */
82
105
  sessionId(): string | undefined;
106
+ /** Provider-native compaction on the exact live conversation. */
107
+ compact?(): Promise<CompactResult>;
108
+ /** Truthful provider context occupancy, when exposed by the provider. */
109
+ contextUsage?(): Promise<ContextUsage | undefined>;
110
+ /** Attempt a provider-supported in-conversation model/reasoning transition. */
111
+ switchSettings?(settings: ProviderSettingSwitch): Promise<CompactResult>;
83
112
  /** Stream of observability events. Iterate to drive a live view. */
84
113
  events(): AsyncIterable<BuilderEvent>;
85
114
  /** Shut the builder down and release resources. */