feat: add agent runtime info tracking with model detection from rollout files

This commit is contained in:
Mohamed Boudra
2025-12-21 23:16:49 +07:00
parent a70842d9ef
commit abe3a42478
23 changed files with 1243 additions and 159 deletions
+299
View File
@@ -0,0 +1,299 @@
# Codex SDK Binary Investigation Report
**Date:** 2025-12-20
**SDK Version:** @openai/codex-sdk@0.76.0
**Status:** ✅ System binary usage is fully supported
---
## Executive Summary
The `@openai/codex-sdk` library **DOES support using a system-installed Codex binary** instead of the embedded one. This is accomplished via the `codexPathOverride` configuration option in the `CodexOptions` interface.
---
## How the SDK Currently Locates the Codex Binary
### Default Behavior (Embedded Binary)
When no override is provided, the SDK uses the `findCodexPath()` function to locate the embedded binary:
**Source:** `dist/index.js:278-330`
```typescript
function findCodexPath() {
const { platform, arch } = process;
let targetTriple = null;
// Determines platform-specific binary path
// e.g., "aarch64-apple-darwin" for Apple Silicon Macs
const vendorRoot = path2.join(scriptDirName, "..", "vendor");
const archRoot = path2.join(vendorRoot, targetTriple);
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
const binaryPath = path2.join(archRoot, "codex", codexBinaryName);
return binaryPath;
}
```
### Binary Location Structure
The SDK ships with pre-compiled binaries in the `vendor/` directory:
```
node_modules/@openai/codex-sdk/
├── vendor/
│ ├── aarch64-apple-darwin/codex/codex (40MB - Apple Silicon)
│ ├── x86_64-apple-darwin/codex/codex (Intel Mac)
│ ├── aarch64-unknown-linux-musl/codex/codex (ARM Linux)
│ ├── x86_64-unknown-linux-musl/codex/codex (x86_64 Linux)
│ ├── aarch64-pc-windows-msvc/codex/codex.exe (ARM Windows)
│ └── x86_64-pc-windows-msvc/codex/codex.exe (x86_64 Windows)
```
---
## Configuration Options Available
### `codexPathOverride` Option
**Type Definition:** `dist/index.d.ts:209-218`
```typescript
type CodexOptions = {
codexPathOverride?: string; // ← THIS IS THE KEY OPTION
baseUrl?: string;
apiKey?: string;
env?: Record<string, string>;
};
```
**Implementation:** `dist/index.js:336-338`
```typescript
constructor(options = {}) {
this.exec = new CodexExec(options.codexPathOverride, options.env);
this.options = options;
}
```
**How it works:** `dist/index.js:145-147`
```typescript
constructor(executablePath = null, env) {
this.executablePath = executablePath || findCodexPath();
this.envOverride = env;
}
```
The `codexPathOverride` is passed as `executablePath`. If provided, it bypasses the `findCodexPath()` function entirely.
---
## How to Use a System Codex Binary
### Method 1: Direct Path Specification
```typescript
import { Codex } from '@openai/codex-sdk';
const codex = new Codex({
codexPathOverride: '/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/codex'
});
```
### Method 2: Dynamic PATH Resolution
```typescript
import { Codex } from '@openai/codex-sdk';
import { execSync } from 'child_process';
const systemCodexPath = execSync('which codex', { encoding: 'utf8' }).trim();
const codex = new Codex({
codexPathOverride: systemCodexPath
});
```
### Method 3: Environment Variable Based (Custom Implementation)
```typescript
import { Codex } from '@openai/codex-sdk';
const codex = new Codex({
codexPathOverride: process.env.CODEX_BINARY_PATH || undefined
});
```
**Note:** The SDK does NOT have a built-in environment variable for this purpose. The `CODEX_BINARY_PATH` above is a custom convention you'd need to implement in your application.
---
## Verification Test Results
### Test Configuration
- **System Codex:** `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/codex`
- **Embedded Codex:** `node_modules/@openai/codex-sdk/vendor/aarch64-apple-darwin/codex/codex`
- **Versions:** Both are `codex-cli 0.76.0`
### Test Results
```bash
$ node test-codex-path-override.mjs
Testing codexPathOverride option...
Test 1: Using default embedded binary
✓ Created Codex instance with default settings
Test 2: Using system binary via codexPathOverride
✓ Created Codex instance with codexPathOverride
Test 3: Using which codex result
System codex location: /Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/codex
✓ Created Codex instance with system codex from PATH
✅ All tests passed!
```
---
## Benefits of Using System Codex
### 1. **Version Control**
- Update codex independently via package manager (npm, brew, asdf, etc.)
- No need to wait for SDK release to get latest codex features
- Easier to test beta/development versions
### 2. **Disk Space Savings**
- Embedded binary: ~40MB per architecture
- In a monorepo with multiple node_modules: Saves 40MB × number of installations
- System binary: Single installation regardless of projects
### 3. **Consistency**
- Same codex version across all tools and SDKs
- Easier debugging when CLI and SDK use identical binaries
- Simplified version management
### 4. **Development Workflow**
- Test local codex builds without reinstalling SDK
- Point to custom-compiled binaries for debugging
- A/B test different codex versions easily
---
## Potential Considerations
### 1. **Version Compatibility**
- SDK expects specific codex CLI behavior
- Using mismatched versions might cause issues
- SDK is at v0.76.0, ensure system codex matches or is compatible
### 2. **PATH Availability**
- System binary must be accessible when Node.js process runs
- Consider environment differences (development vs production)
- Absolute paths are safer than relying on PATH resolution
### 3. **No Fallback**
- If codexPathOverride points to invalid/missing binary, execution will fail
- SDK doesn't automatically fall back to embedded binary
- Implement error handling if using dynamic resolution
---
## Environment Variables (Built-in)
The SDK does NOT provide a built-in environment variable for binary path override, but it does use these:
### Used by SDK
1. **`CODEX_INTERNAL_ORIGINATOR_OVERRIDE`** (Internal)
- Source: `dist/index.js:140-141`
- Set to `"codex_sdk_ts"` to identify SDK usage
- Not for user configuration
2. **`OPENAI_BASE_URL`** (Configurable)
- Override via `CodexOptions.baseUrl`
- Also injectable via env
3. **`CODEX_API_KEY`** (Configurable)
- Override via `CodexOptions.apiKey`
- Also injectable via env
### NOT Provided
- ❌ `CODEX_BINARY_PATH` - Not a built-in feature
- ❌ `CODEX_PATH` - Not a built-in feature
- ❌ Auto-discovery via system PATH - Not implemented
---
## Recommended Implementation Pattern
For maximum flexibility, consider this pattern:
```typescript
import { Codex } from '@openai/codex-sdk';
import { execSync } from 'child_process';
import { existsSync } from 'fs';
function createCodexInstance(options = {}) {
// Priority order:
// 1. Explicit override in options
// 2. Environment variable
// 3. System PATH
// 4. Embedded binary (SDK default)
let codexPath = options.codexPathOverride;
if (!codexPath && process.env.CODEX_BINARY_PATH) {
codexPath = process.env.CODEX_BINARY_PATH;
}
if (!codexPath) {
try {
codexPath = execSync('which codex', { encoding: 'utf8' }).trim();
if (!existsSync(codexPath)) {
codexPath = undefined;
}
} catch {
// Fall back to embedded binary
codexPath = undefined;
}
}
return new Codex({
...options,
codexPathOverride: codexPath
});
}
// Usage
const codex = createCodexInstance();
```
---
## Summary
| Question | Answer |
|----------|--------|
| **Can SDK use system codex?** | ✅ Yes, via `codexPathOverride` |
| **Is configuration option available?** | ✅ Yes, in `CodexOptions` interface |
| **Built-in env variable support?** | ❌ No, must implement custom logic |
| **Automatic PATH resolution?** | ❌ No, must provide explicit path |
| **Fallback to embedded?** | ❌ No, fails if override is invalid |
| **Version tested** | 0.76.0 (both system and embedded) |
---
## Conclusion
The `@openai/codex-sdk` library provides full support for using a system-installed Codex binary through the `codexPathOverride` configuration option. This feature is well-designed and allows developers to choose between the convenience of embedded binaries and the flexibility of system-managed installations.
To use a system binary:
1. Install codex globally (via npm, brew, asdf, etc.)
2. Pass the path via `codexPathOverride` when instantiating `Codex`
3. Optionally implement PATH resolution logic for automatic discovery
The embedded binary remains the default, ensuring the SDK works out-of-the-box without requiring system-wide codex installation.
+87
View File
@@ -0,0 +1,87 @@
# Model Fetching Investigation Report
## Summary
Investigated the model fetching system to determine what models are actually available for both Claude and Codex agents.
## Code Location
The model fetching logic is located in:
- **File**: `packages/server/src/server/agent/model-catalog.ts` (lines 22-101)
- **Main Function**: `fetchProviderModelCatalog(provider, options)`
- **Claude Function**: `fetchClaudeModelCatalog(options)` (lines 35-66)
- **Codex Function**: `fetchCodexModelCatalog()` (lines 68-101)
## How It Works
### Claude Model Fetching
- Uses the Claude Agent SDK's `query.supportedModels()` method
- Creates a query instance with the given `cwd` (working directory)
- Returns models from the SDK with their display names and descriptions
### Codex Model Fetching
- Spawns the Codex binary (`codex app-server`) as a subprocess
- Communicates via JSON-RPC protocol
- Sends `initialize` followed by `model/list` requests
- Returns models with metadata including reasoning efforts
## Actual Models Returned
### Claude Models (3 total)
1. **Default (recommended)** (`default`)
- Description: "Sonnet 4.5 · Smartest model for daily use"
- Is Default: false
2. **Opus** (`opus`)
- Description: "Legacy: Opus 4.1 · Reaches usage limits faster"
- Is Default: false
3. **Haiku** (`haiku`)
- Description: "Haiku 4.5 · Fastest model for simple tasks"
- Is Default: false
### Codex Models (3 total)
1. **gpt-5.1-codex** (`gpt-5.1-codex`) ⭐ DEFAULT
- Description: "Optimized for codex."
- Is Default: true
- Model: gpt-5.1-codex
- Default Reasoning Effort: medium
2. **gpt-5.1-codex-mini** (`gpt-5.1-codex-mini`)
- Description: "Optimized for codex. Cheaper, faster, but less capable."
- Is Default: false
- Model: gpt-5.1-codex-mini
- Default Reasoning Effort: medium
3. **gpt-5.1** (`gpt-5.1`)
- Description: "Broad world knowledge with strong general reasoning."
- Is Default: false
- Model: gpt-5.1
- Default Reasoning Effort: medium
## Key Observations
1. **Claude models**: The model IDs returned are simplified (`default`, `opus`, `haiku`) rather than full model identifiers like `claude-sonnet-4-5-20250929`.
2. **Codex models**: All three models are available and properly marked with `isDefault` flag on `gpt-5.1-codex`.
3. **No Sonnet variant**: Interestingly, Claude returns a "default" model (which is Sonnet 4.5 based on the description) but doesn't expose a model ID called "sonnet" - only "default", "opus", and "haiku".
4. **Model resolution**: The `model-resolver.ts` file uses these catalogs to find the default model when none is specified, preferring models marked with `isDefault: true`.
## Testing Method
Created and ran a test script (`test-models.ts`) that directly imports and calls:
- `fetchClaudeModelCatalog()`
- `fetchCodexModelCatalog()`
Executed with: `npx tsx test-models.ts`
## Related Files
- `src/server/agent/model-catalog.ts` - Model fetching implementation
- `src/server/agent/model-resolver.ts` - Default model resolution
- `src/server/agent/model-catalog.e2e.test.ts` - End-to-end tests
- `src/server/session.ts:1589` - Usage in session handling (list_provider_models_response)
@@ -33,6 +33,7 @@ import {
HOME_NAVIGATION_KEY,
startNavigationTiming,
} from "@/utils/navigation-timing";
import { extractAgentModel } from "@/utils/extract-agent-model";
const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -44,47 +45,6 @@ type GitRepoInfoResponseMessage = Extract<
type BranchStatus = "idle" | "loading" | "ready" | "error";
function extractAgentModel(agent?: Agent | null): string | null {
if (!agent) {
return null;
}
const directModel = typeof agent.model === "string" ? agent.model.trim() : "";
if (directModel.length > 0) {
return directModel;
}
const metadata = agent.persistence?.metadata;
if (!metadata || typeof metadata !== "object") {
return null;
}
const persistedModel = (metadata as Record<string, unknown>).model;
if (typeof persistedModel === "string" && persistedModel.trim().length > 0) {
return persistedModel.trim();
}
const extra = (metadata as Record<string, unknown>).extra;
if (!extra || typeof extra !== "object") {
return null;
}
const getModelFrom = (source: unknown) => {
if (!source || typeof source !== "object") {
return null;
}
const candidate = (source as Record<string, unknown>).model;
return typeof candidate === "string" && candidate.trim().length > 0
? candidate.trim()
: null;
};
return (
getModelFrom((extra as Record<string, unknown>).codex) ??
getModelFrom((extra as Record<string, unknown>).claude)
);
}
export default function AgentScreen() {
const router = useRouter();
const { serverId, agentId } = useLocalSearchParams<{ serverId: string; agentId: string }>();
@@ -130,6 +130,7 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string
availableModes: snapshot.availableModes ?? [],
pendingPermissions: snapshot.pendingPermissions ?? [],
persistence: snapshot.persistence ?? null,
runtimeInfo: snapshot.runtimeInfo,
lastUsage: snapshot.lastUsage,
lastError: snapshot.lastError ?? null,
title: snapshot.title ?? null,
+9
View File
@@ -74,6 +74,14 @@ export type ProviderModelState = {
isLoading: boolean;
};
export interface AgentRuntimeInfo {
provider: AgentProvider;
sessionId: string | null;
model?: string | null;
modeId?: string | null;
extra?: Record<string, unknown>;
}
export interface Agent {
serverId: string;
id: string;
@@ -89,6 +97,7 @@ export interface Agent {
availableModes: AgentMode[];
pendingPermissions: any[];
persistence: AgentPersistenceHandle | null;
runtimeInfo?: AgentRuntimeInfo;
lastUsage?: AgentUsage;
lastError?: string | null;
title: string | null;
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { extractAgentModel } from "./extract-agent-model";
describe("extractAgentModel", () => {
it("returns runtime model when present", () => {
const agent = {
runtimeInfo: { model: "gpt-5.2-codex" },
} as Partial<Agent> as Agent;
expect(extractAgentModel(agent)).toBe("gpt-5.2-codex");
});
it("returns null when runtime model missing even if config model is set", () => {
const agent = {
model: "gpt-5.1-codex",
runtimeInfo: {},
} as Partial<Agent> as Agent;
expect(extractAgentModel(agent)).toBeNull();
});
});
@@ -0,0 +1,9 @@
import type { Agent } from "@/stores/session-store";
export function extractAgentModel(agent?: Agent | null): string | null {
if (!agent) return null;
const runtimeModel = agent.runtimeInfo?.model;
return typeof runtimeModel === "string" && runtimeModel.trim().length > 0
? runtimeModel.trim()
: null;
}
@@ -43,6 +43,7 @@ class TestAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private runtimeModel: string | null = null;
constructor(private readonly config: AgentSessionConfig) {}
@@ -54,10 +55,23 @@ class TestAgentSession implements AgentSession {
};
}
async *stream(): AsyncGenerator<AgentStreamEvent> {}
async *stream(): AsyncGenerator<AgentStreamEvent> {
yield { type: "turn_started", provider: this.provider };
yield { type: "turn_completed", provider: this.provider };
this.runtimeModel = "gpt-5.2-codex";
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: this.runtimeModel ?? this.config.model ?? null,
modeId: this.config.modeId ?? null,
};
}
async getAvailableModes() {
return [];
}
@@ -84,6 +98,26 @@ class TestAgentSession implements AgentSession {
}
describe("AgentManager", () => {
test("normalizeConfig does not inject default model when omitted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json");
const registry = new AgentRegistry(registryPath);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry,
idFactory: () => "agent-without-model",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
expect(snapshot.model).toBeUndefined();
});
test("createAgent persists provided title before returning", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json");
@@ -109,4 +143,53 @@ describe("AgentManager", () => {
expect(persisted?.title).toBe("Fix Login Bug");
expect(persisted?.id).toBe("agent-with-title");
});
test("createAgent populates runtimeInfo after session creation", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json");
const registry = new AgentRegistry(registryPath);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry,
idFactory: () => "agent-with-runtime-info",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
model: "gpt-5.2-codex",
modeId: "full-access",
});
expect(snapshot.runtimeInfo).toBeDefined();
expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex");
expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.sessionId);
});
test("runAgent refreshes runtimeInfo after completion", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json");
const registry = new AgentRegistry(registryPath);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry,
idFactory: () => "agent-with-run-runtime",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
expect(snapshot.runtimeInfo?.model ?? null).toBeNull();
await manager.runAgent(snapshot.id, "hello");
const refreshed = manager.getAgent(snapshot.id);
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
});
});
@@ -17,10 +17,10 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { resolveAgentModel } from "./model-resolver.js";
import type { AgentRegistry } from "./agent-registry.js";
export const AGENT_LIFECYCLE_STATUSES = [
@@ -80,6 +80,7 @@ type ManagedAgentBase = {
cwd: string;
capabilities: AgentCapabilityFlags;
config: AgentSessionConfig;
runtimeInfo?: AgentRuntimeInfo;
createdAt: Date;
updatedAt: Date;
availableModes: AgentMode[];
@@ -479,21 +480,21 @@ export class AgentManager {
const self = this;
return (async function* streamForwarder() {
let finalizeError: string | undefined;
try {
for await (const event of iterator) {
self.handleStreamEvent(agent, event);
yield event;
}
finalize();
} catch (error) {
const message =
finalizeError =
error instanceof Error ? error.message : "Agent stream failed";
finalize(message);
throw error;
} finally {
await self.refreshRuntimeInfo(agent);
// Ensure we always clear the pending run and emit state when the stream is
// cancelled early (e.g., via .return()) so the UI can exit the cancelling state.
finalize();
finalize(finalizeError);
}
})();
}
@@ -712,6 +713,7 @@ export class AgentManager {
sessionId: session.id,
capabilities: session.capabilities,
config,
runtimeInfo: undefined,
lifecycle: "initializing",
createdAt: new Date(),
updatedAt: new Date(),
@@ -729,6 +731,7 @@ export class AgentManager {
this.agents.set(agentId, managed);
// Initialize previousStatus to track transitions
this.previousStatuses.set(agentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed);
await this.persistSnapshot(managed, {
title: config.title ?? null,
});
@@ -773,6 +776,16 @@ export class AgentManager {
} catch {
agent.pendingPermissions.clear();
}
await this.refreshRuntimeInfo(agent);
}
private async refreshRuntimeInfo(agent: ActiveManagedAgent): Promise<void> {
try {
agent.runtimeInfo = await agent.session.getRuntimeInfo();
} catch {
// Keep existing runtimeInfo if refresh fails.
}
}
private async primeHistory(agent: ActiveManagedAgent): Promise<void> {
@@ -813,6 +826,7 @@ export class AgentManager {
case "turn_completed":
agent.lastUsage = event.usage;
agent.lastError = undefined;
void this.refreshRuntimeInfo(agent);
break;
case "turn_failed":
agent.lastError = event.error;
@@ -949,15 +963,7 @@ export class AgentManager {
normalized.cwd = resolve(normalized.cwd);
}
const resolvedModel = await resolveAgentModel({
provider: normalized.provider,
requestedModel: normalized.model,
cwd: normalized.cwd,
});
if (resolvedModel) {
normalized.model = resolvedModel;
} else if (typeof normalized.model === "string") {
if (typeof normalized.model === "string") {
const trimmed = normalized.model.trim();
normalized.model = trimmed.length > 0 ? trimmed : undefined;
}
@@ -83,11 +83,18 @@ function createManagedAgent(
pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
pendingRun: pendingRunValue as ManagedAgent["pendingRun"],
timeline: [],
runtimeInfo: {
provider: "claude",
sessionId: "session-123",
model: "claude-3.5-sonnet",
modeId: "plan",
},
persistence: { ...basePersistence },
lastUsage: undefined,
lastError: lastErrorValue,
historyPrimed: true,
lastUserMessageAt: now,
attention: { requiresAttention: false },
};
return {
@@ -149,6 +156,12 @@ describe("toStoredAgentRecord", () => {
note: "warm",
},
});
expect(record.runtimeInfo).toEqual({
provider: "claude",
sessionId: "session-123",
model: "claude-3.5-sonnet",
modeId: "plan",
});
expect(record.config).toEqual({
modeId: agent.config.modeId,
model: agent.config.model,
@@ -232,6 +245,8 @@ describe("toAgentPayload", () => {
requestedAt: "2025-02-02T00:00:00.000Z",
extra: { flag: true },
});
expect(payload.runtimeInfo).toEqual(agent.runtimeInfo);
expect(payload.runtimeInfo).not.toBe(agent.runtimeInfo);
expect(payload.availableModes).not.toBe(agent.availableModes);
expect(payload.availableModes).toEqual(agent.availableModes);
expect(payload.capabilities).not.toBe(agent.capabilities);
@@ -27,6 +27,7 @@ export function toStoredAgentRecord(
const createdAt = options?.createdAt ?? agent.createdAt.toISOString();
const config = buildSerializableConfig(agent.config);
const persistence = sanitizePersistenceHandle(agent.persistence);
const runtimeInfo = sanitizeOptionalJsonValue(agent.runtimeInfo);
return {
id: agent.id,
@@ -42,6 +43,7 @@ export function toStoredAgentRecord(
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
runtimeInfo,
persistence,
requiresAttention: agent.attention.requiresAttention,
attentionReason: agent.attention.requiresAttention
@@ -57,11 +59,14 @@ export function toAgentPayload(
agent: ManagedAgent,
options?: ProjectionOptions
): AgentSnapshotPayload {
const runtimeInfo = sanitizeOptionalJsonValue(agent.runtimeInfo);
const payload: AgentSnapshotPayload = {
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
model: agent.config.model ?? null,
runtimeInfo,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt
@@ -19,6 +19,8 @@ type ManagedAgentOverrides = Omit<
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
pendingRun?: ManagedAgent["pendingRun"];
runtimeInfo?: ManagedAgent["runtimeInfo"];
attention?: ManagedAgent["attention"];
};
function createManagedAgent(
@@ -71,6 +73,14 @@ function createManagedAgent(
new Map<string, AgentPermissionRequest>(),
pendingRun,
timeline: overrides.timeline ?? [],
attention: overrides.attention ?? { requiresAttention: false },
runtimeInfo:
overrides.runtimeInfo ?? {
provider,
sessionId: overrides.sessionId ?? "session-123",
model: config.model ?? null,
modeId: config.modeId ?? null,
},
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
@@ -40,6 +40,15 @@ const STORED_AGENT_SCHEMA = z.object({
lastStatus: AgentStatusSchema.default("closed"),
lastModeId: z.string().nullable().optional(),
config: SERIALIZABLE_CONFIG_SCHEMA,
runtimeInfo: z
.object({
provider: z.string(),
sessionId: z.string().nullable(),
model: z.string().nullable().optional(),
modeId: z.string().nullable().optional(),
extra: z.record(z.unknown()).optional(),
})
.optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA,
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
@@ -123,6 +123,14 @@ export type AgentRunResult = {
timeline: AgentTimelineItem[];
};
export type AgentRuntimeInfo = {
provider: AgentProvider;
sessionId: string | null;
model?: string | null;
modeId?: string | null;
extra?: Record<string, unknown>;
};
export type ListPersistedAgentsOptions = {
limit?: number;
};
@@ -162,6 +170,7 @@ export interface AgentSession {
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator<AgentStreamEvent>;
streamHistory(): AsyncGenerator<AgentStreamEvent>;
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
@@ -35,6 +35,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
@@ -386,6 +387,8 @@ class ClaudeAgentSession implements AgentSession {
private streamedAssistantTextThisTurn = false;
private streamedReasoningThisTurn = false;
private cancelCurrentTurn: (() => void) | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private lastOptionsModel: string | null = null;
constructor(
config: ClaudeAgentConfig,
@@ -417,6 +420,20 @@ class ClaudeAgentSession implements AgentSession {
return this.claudeSessionId;
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (this.cachedRuntimeInfo) {
return { ...this.cachedRuntimeInfo };
}
const info: AgentRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId ?? this.pendingLocalId ?? null,
model: this.lastOptionsModel,
modeId: this.currentMode ?? null,
};
this.cachedRuntimeInfo = info;
return { ...info };
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const events = this.stream(prompt, options);
const timeline: AgentTimelineItem[] = [];
@@ -436,6 +453,13 @@ class ClaudeAgentSession implements AgentSession {
}
}
this.cachedRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId ?? this.pendingLocalId ?? null,
model: this.lastOptionsModel,
modeId: this.currentMode ?? null,
};
return {
sessionId: this.claudeSessionId ?? this.pendingLocalId,
finalText,
@@ -608,11 +632,12 @@ class ClaudeAgentSession implements AgentSession {
if (!this.claudeSessionId) {
return null;
}
const { model: _ignoredModel, ...restConfig } = this.config;
this.persistence = {
provider: "claude",
sessionId: this.claudeSessionId,
nativeHandle: this.claudeSessionId,
metadata: this.config,
metadata: restConfig,
};
return this.persistence;
}
@@ -684,6 +709,7 @@ class ClaudeAgentSession implements AgentSession {
if (this.config.model) {
base.model = this.config.model;
}
this.lastOptionsModel = base.model ?? null;
if (this.claudeSessionId) {
base.resume = this.claudeSessionId;
base.continue = true;
@@ -35,6 +35,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
AgentRuntimeInfo,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
@@ -66,19 +67,21 @@ const CODEX_MODES: AgentMode[] = [
{
id: "full-access",
label: "Full Access",
description: "Codex can edit files, run commands, and access the network without additional prompts.",
description:
"Codex can edit files, run commands, and access the network without additional prompts.",
},
];
const VALID_CODEX_MODES = new Set(
CODEX_MODES.map((mode) => mode.id)
);
const VALID_CODEX_MODES = new Set(CODEX_MODES.map((mode) => mode.id));
const DEFAULT_CODEX_MODE_ID = "auto";
const MODE_PRESETS: Record<
string,
Partial<ThreadOptions> & { networkAccessEnabled?: boolean; webSearchEnabled?: boolean }
Partial<ThreadOptions> & {
networkAccessEnabled?: boolean;
webSearchEnabled?: boolean;
}
> = {
"read-only": {
sandboxMode: "read-only",
@@ -100,7 +103,9 @@ const MODE_PRESETS: Record<
},
};
type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean };
type CodexOptionsOverrides = Partial<ThreadOptions> & {
skipGitRepoCheck?: boolean;
};
const MAX_ROLLOUT_SEARCH_DEPTH = 5;
const PERSISTED_TIMELINE_LIMIT = 20;
@@ -142,7 +147,10 @@ function normalizeCallId(value?: string | null): string | undefined {
function extractThreadItemCallId(item: ThreadItem): string | undefined {
const withCallId = item as ThreadItemWithOptionalCallId;
return normalizeCallId(withCallId.call_id) ?? normalizeCallId((item as { id?: string }).id);
return (
normalizeCallId(withCallId.call_id) ??
normalizeCallId((item as { id?: string }).id)
);
}
function buildCommandDisplayName(command?: unknown): string {
@@ -155,7 +163,9 @@ function buildCommandDisplayName(command?: unknown): string {
return "Command";
}
function buildFileChangeSummary(files: { path: string; kind: string }[]): string {
function buildFileChangeSummary(
files: { path: string; kind: string }[]
): string {
if (!files.length) {
return "File change";
}
@@ -167,7 +177,11 @@ function buildFileChangeSummary(files: { path: string; kind: string }[]): string
function coerceSandboxMode(value?: string): SandboxMode | undefined {
if (!value) return undefined;
if (value === "read-only" || value === "workspace-write" || value === "danger-full-access") {
if (
value === "read-only" ||
value === "workspace-write" ||
value === "danger-full-access"
) {
return value;
}
return undefined;
@@ -175,21 +189,35 @@ function coerceSandboxMode(value?: string): SandboxMode | undefined {
function coerceApprovalMode(value?: string): ApprovalMode | undefined {
if (!value) return undefined;
if (value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted") {
if (
value === "never" ||
value === "on-request" ||
value === "on-failure" ||
value === "untrusted"
) {
return value;
}
return undefined;
}
function coerceReasoningEffort(value?: string): ModelReasoningEffort | undefined {
function coerceReasoningEffort(
value?: string
): ModelReasoningEffort | undefined {
if (!value) return undefined;
if (value === "minimal" || value === "low" || value === "medium" || value === "high") {
if (
value === "minimal" ||
value === "low" ||
value === "medium" ||
value === "high"
) {
return value;
}
return undefined;
}
function normalizeExtraOptions(extra?: Record<string, unknown>): CodexOptionsOverrides | undefined {
function normalizeExtraOptions(
extra?: Record<string, unknown>
): CodexOptionsOverrides | undefined {
if (!extra) return undefined;
const allowedKeys = new Set([
"model",
@@ -211,7 +239,10 @@ function normalizeExtraOptions(extra?: Record<string, unknown>): CodexOptionsOve
function detectSystemCodexPath(): string | undefined {
try {
const codexPath = execSync("which codex", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }).trim();
const codexPath = execSync("which codex", {
encoding: "utf8",
stdio: ["pipe", "pipe", "ignore"],
}).trim();
if (codexPath && codexPath.length > 0) {
return codexPath;
}
@@ -236,7 +267,9 @@ export class CodexAgentClient implements AgentClient {
codexOptions.codexPathOverride = systemCodexPath;
console.log(`[Codex] Using system binary: ${systemCodexPath}`);
} else {
console.log("[Codex] Using embedded binary (no system codex found in PATH)");
console.log(
"[Codex] Using embedded binary (no system codex found in PATH)"
);
}
}
@@ -248,18 +281,25 @@ export class CodexAgentClient implements AgentClient {
return CodexAgentSession.create(this.codex, codexConfig);
}
async resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession> {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>
): Promise<AgentSession> {
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const merged = { ...metadata, ...overrides } as Partial<AgentSessionConfig>;
if (!merged.cwd) {
throw new Error("Codex resume requires the original working directory in metadata");
throw new Error(
"Codex resume requires the original working directory in metadata"
);
}
const mergedConfig = { ...merged, provider: "codex" } as AgentSessionConfig;
const codexConfig = this.assertConfig(mergedConfig);
return CodexAgentSession.create(this.codex, codexConfig, handle);
}
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
async listPersistedAgents(
options?: ListPersistedAgentsOptions
): Promise<PersistedAgentDescriptor[]> {
const sessionRoot = resolveCodexSessionRoot();
if (!sessionRoot) {
return [];
@@ -271,7 +311,11 @@ export class CodexAgentClient implements AgentClient {
const candidates = await collectRecentRolloutFiles(sessionRoot, limit * 3);
const descriptors: PersistedAgentDescriptor[] = [];
for (const candidate of candidates) {
const descriptor = await parseRolloutDescriptor(candidate.path, candidate.mtime, sessionRoot);
const descriptor = await parseRolloutDescriptor(
candidate.path,
candidate.mtime,
sessionRoot
);
if (descriptor) {
descriptors.push(descriptor);
}
@@ -284,14 +328,20 @@ export class CodexAgentClient implements AgentClient {
private assertConfig(config: AgentSessionConfig): CodexAgentConfig {
if (config.provider !== "codex") {
throw new Error(`CodexAgentClient received config for provider '${config.provider}'`);
throw new Error(
`CodexAgentClient received config for provider '${config.provider}'`
);
}
return config as CodexAgentConfig;
}
}
class CodexAgentSession implements AgentSession {
static async create(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle): Promise<CodexAgentSession> {
static async create(
codex: Codex,
config: CodexAgentConfig,
handle?: AgentPersistenceHandle
): Promise<CodexAgentSession> {
const session = new CodexAgentSession(codex, config, handle);
if (handle) {
await session.loadReplayHistory(handle);
@@ -316,8 +366,13 @@ class CodexAgentSession implements AgentSession {
private historyEvents: AgentStreamEvent[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private cancelCurrentTurn: (() => void) | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
constructor(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle) {
constructor(
codex: Codex,
config: CodexAgentConfig,
handle?: AgentPersistenceHandle
) {
this.codex = codex;
this.config = { ...config };
@@ -336,6 +391,7 @@ class CodexAgentSession implements AgentSession {
this.threadId = handle?.sessionId ?? handle?.nativeHandle ?? null;
this.pendingLocalId = this.threadId ?? `codex-${randomUUID()}`;
this.persistence = handle ?? null;
this.cachedRuntimeInfo = null;
if (handle && !this.threadId) {
throw new Error("Codex resume requires a thread id");
}
@@ -348,7 +404,31 @@ class CodexAgentSession implements AgentSession {
return this.threadId;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (!this.cachedRuntimeInfo) {
await this.refreshRuntimeInfoFromRollout();
}
if (this.cachedRuntimeInfo) {
return { ...this.cachedRuntimeInfo };
}
const modelFromRollout = await this.readRuntimeModelFromRollout();
const info: AgentRuntimeInfo = {
provider: "codex",
sessionId: this.threadId ?? this.pendingLocalId ?? null,
model:
modelFromRollout ??
this.threadOptions.model ??
null,
modeId: this.currentMode ?? null,
};
this.cachedRuntimeInfo = info;
return { ...info };
}
async run(
prompt: AgentPromptInput,
options?: AgentRunOptions
): Promise<AgentRunResult> {
const events = this.stream(prompt, options);
const timeline: AgentTimelineItem[] = [];
let finalText = "";
@@ -367,6 +447,9 @@ class CodexAgentSession implements AgentSession {
}
}
// Update runtime info cache after the turn completes
await this.refreshRuntimeInfoFromRollout();
return {
sessionId: this.threadId ?? this.pendingLocalId,
finalText,
@@ -375,7 +458,10 @@ class CodexAgentSession implements AgentSession {
};
}
async *stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator<AgentStreamEvent> {
async *stream(
prompt: AgentPromptInput,
options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> {
const thread = this.thread;
if (!thread) {
throw new Error("Codex session is closed");
@@ -405,7 +491,10 @@ class CodexAgentSession implements AgentSession {
type: "turn.completed",
} as ThreadEvent);
void cancellation.catch((error) => {
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
console.warn(
"[CodexAgentSession] Failed to stop Codex stream:",
error
);
});
} catch (error) {
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
@@ -433,6 +522,7 @@ class CodexAgentSession implements AgentSession {
if (this.cancelCurrentTurn === requestCancel) {
this.cancelCurrentTurn = null;
}
await this.refreshRuntimeInfoFromRollout();
}
}
@@ -484,10 +574,15 @@ class CodexAgentSession implements AgentSession {
return Array.from(this.pendingPermissions.values());
}
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
async respondToPermission(
requestId: string,
response: AgentPermissionResponse
): Promise<void> {
const request = this.pendingPermissions.get(requestId);
if (!request) {
throw new Error(`No pending Codex permission request with id '${requestId}'`);
throw new Error(
`No pending Codex permission request with id '${requestId}'`
);
}
this.pendingPermissions.delete(requestId);
@@ -522,12 +617,13 @@ class CodexAgentSession implements AgentSession {
if (!this.threadId) {
return null;
}
const { model: _ignoredModel, ...restConfig } = this.config;
this.persistence = {
provider: "codex",
sessionId: this.threadId,
nativeHandle: this.threadId,
metadata: {
...this.config,
...restConfig,
codexSessionDir: this.codexSessionDir ?? undefined,
codexRolloutPath: this.rolloutPath ?? undefined,
},
@@ -568,10 +664,13 @@ class CodexAgentSession implements AgentSession {
options.model = this.config.model;
}
if (this.config.sandboxMode) {
options.sandboxMode = coerceSandboxMode(this.config.sandboxMode) ?? options.sandboxMode;
options.sandboxMode =
coerceSandboxMode(this.config.sandboxMode) ?? options.sandboxMode;
}
if (this.config.approvalPolicy) {
options.approvalPolicy = coerceApprovalMode(this.config.approvalPolicy) ?? options.approvalPolicy;
options.approvalPolicy =
coerceApprovalMode(this.config.approvalPolicy) ??
options.approvalPolicy;
}
if (typeof this.config.networkAccess === "boolean") {
options.networkAccessEnabled = this.config.networkAccess;
@@ -581,10 +680,13 @@ class CodexAgentSession implements AgentSession {
}
if (this.config.reasoningEffort) {
options.modelReasoningEffort =
coerceReasoningEffort(this.config.reasoningEffort) ?? options.modelReasoningEffort;
coerceReasoningEffort(this.config.reasoningEffort) ??
options.modelReasoningEffort;
}
const extra = normalizeExtraOptions(this.config.extra?.codex as Record<string, unknown> | undefined);
const extra = normalizeExtraOptions(
this.config.extra?.codex as Record<string, unknown> | undefined
);
if (extra) {
Object.assign(options, extra);
}
@@ -592,7 +694,9 @@ class CodexAgentSession implements AgentSession {
return options;
}
private async loadReplayHistory(handle: AgentPersistenceHandle): Promise<void> {
private async loadReplayHistory(
handle: AgentPersistenceHandle
): Promise<void> {
const threadIdentifier = handle.sessionId ?? handle.nativeHandle;
if (!threadIdentifier || !this.codexSessionDir) {
return;
@@ -604,7 +708,10 @@ class CodexAgentSession implements AgentSession {
}
if (!rolloutFile) {
rolloutFile = await findRolloutFile(threadIdentifier, this.codexSessionDir);
rolloutFile = await findRolloutFile(
threadIdentifier,
this.codexSessionDir
);
}
if (!rolloutFile) {
@@ -612,6 +719,7 @@ class CodexAgentSession implements AgentSession {
}
this.rolloutPath = rolloutFile;
await this.refreshRuntimeInfoFromRollout();
try {
const events = await parseRolloutFile(rolloutFile, threadIdentifier);
@@ -619,11 +727,16 @@ class CodexAgentSession implements AgentSession {
this.historyEvents = events;
}
} catch (error) {
console.warn(`[CodexAgentSession] Failed to parse rollout ${rolloutFile}:`, error);
console.warn(
`[CodexAgentSession] Failed to parse rollout ${rolloutFile}:`,
error
);
}
}
private resolveCodexSessionDir(handle?: AgentPersistenceHandle): string | null {
private resolveCodexSessionDir(
handle?: AgentPersistenceHandle
): string | null {
const metadataDir = readMetadataString(handle, "codexSessionDir");
if (metadataDir) {
return metadataDir;
@@ -639,7 +752,8 @@ class CodexAgentSession implements AgentSession {
}
const extraHome = readCodexExtraString(this.config, "home");
const codexHome = extraHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const codexHome =
extraHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
return path.join(codexHome, "sessions");
}
@@ -652,6 +766,46 @@ class CodexAgentSession implements AgentSession {
return extraRollout ?? null;
}
private async refreshRuntimeInfoFromRollout(): Promise<void> {
if (!this.rolloutPath) {
const threadIdentifier = this.threadId;
if (threadIdentifier && this.codexSessionDir) {
const rolloutFile = await findRolloutFile(
threadIdentifier,
this.codexSessionDir
);
if (rolloutFile) {
this.rolloutPath = rolloutFile;
}
}
}
const modelFromRollout = await this.readRuntimeModelFromRollout();
if (!modelFromRollout && this.cachedRuntimeInfo) {
return;
}
this.cachedRuntimeInfo = {
provider: "codex",
sessionId: this.threadId ?? this.pendingLocalId ?? null,
model:
modelFromRollout ??
this.threadOptions.model ??
null,
modeId: this.currentMode ?? null,
};
}
private async readRuntimeModelFromRollout(): Promise<string | null> {
const rollout = this.rolloutPath;
if (!rollout) {
return null;
}
try {
return await readLatestTurnContextModel(rollout);
} catch {
return null;
}
}
private buildTurnOptions(options?: AgentRunOptions): TurnOptions {
if (!options?.outputSchema) {
return {};
@@ -680,7 +834,11 @@ class CodexAgentSession implements AgentSession {
this.threadId = event.thread_id;
this.pendingLocalId = event.thread_id;
this.persistence = null;
yield { type: "thread_started", provider: "codex", sessionId: event.thread_id };
yield {
type: "thread_started",
provider: "codex",
sessionId: event.thread_id,
};
break;
case "turn.started":
yield { type: "turn_started", provider: "codex" };
@@ -724,7 +882,10 @@ class CodexAgentSession implements AgentSession {
}
private handlePermissionEvent(event: ThreadEvent): AgentStreamEvent[] | null {
const eventType = typeof (event as { type?: string }).type === "string" ? (event as { type?: string }).type : null;
const eventType =
typeof (event as { type?: string }).type === "string"
? (event as { type?: string }).type
: null;
if (!eventType) {
return null;
}
@@ -742,7 +903,9 @@ class CodexAgentSession implements AgentSession {
return null;
}
private enqueuePermissionRequest(request: AgentPermissionRequest | null): AgentStreamEvent[] | null {
private enqueuePermissionRequest(
request: AgentPermissionRequest | null
): AgentStreamEvent[] | null {
if (!request) {
return null;
}
@@ -769,7 +932,9 @@ class CodexAgentSession implements AgentSession {
return events;
}
private buildExecPermissionRequest(rawInput: unknown): AgentPermissionRequest | null {
private buildExecPermissionRequest(
rawInput: unknown
): AgentPermissionRequest | null {
if (!rawInput || typeof rawInput !== "object") {
return null;
}
@@ -778,14 +943,24 @@ class CodexAgentSession implements AgentSession {
const commandParts: string[] = Array.isArray(raw.command)
? raw.command.filter((entry: unknown) => typeof entry === "string")
: typeof raw.command === "string"
? [raw.command]
: [];
? [raw.command]
: [];
const commandText = commandParts.join(" ").trim();
const cwd = typeof raw.cwd === "string" && raw.cwd.length ? raw.cwd : undefined;
const requestId = typeof raw.call_id === "string" && raw.call_id.length ? raw.call_id : randomUUID();
const reason = typeof raw.reason === "string" && raw.reason.length ? raw.reason : undefined;
const risk = raw.risk && typeof raw.risk === "object" ? raw.risk : undefined;
const parsedCommand = Array.isArray(raw.parsed_cmd) ? raw.parsed_cmd : undefined;
const cwd =
typeof raw.cwd === "string" && raw.cwd.length ? raw.cwd : undefined;
const requestId =
typeof raw.call_id === "string" && raw.call_id.length
? raw.call_id
: randomUUID();
const reason =
typeof raw.reason === "string" && raw.reason.length
? raw.reason
: undefined;
const risk =
raw.risk && typeof raw.risk === "object" ? raw.risk : undefined;
const parsedCommand = Array.isArray(raw.parsed_cmd)
? raw.parsed_cmd
: undefined;
const metadata: Record<string, unknown> = {
callId: raw.call_id,
@@ -822,21 +997,35 @@ class CodexAgentSession implements AgentSession {
return request;
}
private buildPatchPermissionRequest(rawInput: unknown): AgentPermissionRequest | null {
private buildPatchPermissionRequest(
rawInput: unknown
): AgentPermissionRequest | null {
if (!rawInput || typeof rawInput !== "object") {
return null;
}
const raw = rawInput as PatchPermissionRequestPayload;
const requestId = typeof raw.call_id === "string" && raw.call_id.length ? raw.call_id : randomUUID();
const changes = raw.changes && typeof raw.changes === "object" ? raw.changes : undefined;
const requestId =
typeof raw.call_id === "string" && raw.call_id.length
? raw.call_id
: randomUUID();
const changes =
raw.changes && typeof raw.changes === "object" ? raw.changes : undefined;
const filePaths = changes ? Object.keys(changes) : [];
const grantRoot = typeof raw.grant_root === "string" && raw.grant_root.length ? raw.grant_root : undefined;
const reason = typeof raw.reason === "string" && raw.reason.length ? raw.reason : undefined;
const grantRoot =
typeof raw.grant_root === "string" && raw.grant_root.length
? raw.grant_root
: undefined;
const reason =
typeof raw.reason === "string" && raw.reason.length
? raw.reason
: undefined;
const title =
filePaths.length > 0
? `Apply patch to ${filePaths.length} file${filePaths.length === 1 ? "" : "s"}`
? `Apply patch to ${filePaths.length} file${
filePaths.length === 1 ? "" : "s"
}`
: "Apply patch";
const metadata: Record<string, unknown> = {
@@ -881,17 +1070,24 @@ class CodexAgentSession implements AgentSession {
const exitCode = (item as any)?.exit_code;
const cwd = (item as any)?.cwd;
const commandValue = item.command;
const command = typeof commandValue === "string" ? commandValue :
Array.isArray(commandValue) ? (commandValue as string[]).join(" ") : "command";
const command =
typeof commandValue === "string"
? commandValue
: Array.isArray(commandValue)
? (commandValue as string[]).join(" ")
: "command";
// Build structured command result matching StructuredToolResult type
const structuredOutput = typeof aggregatedOutput === "string" ? {
type: "command" as const,
command,
output: aggregatedOutput,
exitCode: typeof exitCode === "number" ? exitCode : undefined,
cwd,
} : undefined;
const structuredOutput =
typeof aggregatedOutput === "string"
? {
type: "command" as const,
command,
output: aggregatedOutput,
exitCode: typeof exitCode === "number" ? exitCode : undefined,
cwd,
}
: undefined;
return createToolCallTimelineItem({
server: "command",
@@ -906,7 +1102,10 @@ class CodexAgentSession implements AgentSession {
});
}
case "file_change": {
const files = item.changes.map((change) => ({ path: change.path, kind: change.kind }));
const files = item.changes.map((change) => ({
path: change.path,
kind: change.kind,
}));
return createToolCallTimelineItem({
server: "file_change",
tool: "apply_patch",
@@ -968,7 +1167,10 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
async function findRolloutFile(threadId: string, root: string): Promise<string | null> {
async function findRolloutFile(
threadId: string,
root: string
): Promise<string | null> {
const stack: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
while (stack.length > 0) {
const { dir, depth } = stack.pop()!;
@@ -983,7 +1185,8 @@ async function findRolloutFile(threadId: string, root: string): Promise<string |
if (entry.isFile()) {
const matchesThread = entry.name.includes(threadId);
const matchesPrefix = entry.name.startsWith("rollout-");
const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
const matchesExtension =
entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
if (matchesThread && matchesPrefix && matchesExtension) {
return entryPath;
}
@@ -995,10 +1198,18 @@ async function findRolloutFile(threadId: string, root: string): Promise<string |
return null;
}
async function parseRolloutFile(filePath: string, threadId: string): Promise<AgentStreamEvent[]> {
async function parseRolloutFile(
filePath: string,
threadId: string
): Promise<AgentStreamEvent[]> {
const content = await fs.readFile(filePath, "utf8");
const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const events: AgentStreamEvent[] = [{ type: "thread_started", provider: "codex", sessionId: threadId }];
const lines = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const events: AgentStreamEvent[] = [
{ type: "thread_started", provider: "codex", sessionId: threadId },
];
const commandCalls = new Map<string, { command: string; cwd?: string }>();
for (const line of lines) {
@@ -1015,6 +1226,40 @@ async function parseRolloutFile(filePath: string, threadId: string): Promise<Age
return events;
}
export async function readLatestTurnContextModel(
filePath: string
): Promise<string | null> {
let content: string;
try {
content = await fs.readFile(filePath, "utf8");
} catch {
return null;
}
const lines = content.split(/\r?\n/).filter(Boolean);
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i];
try {
const parsed = JSON.parse(line);
if (
parsed &&
typeof parsed === "object" &&
(parsed as { type?: string }).type === "turn_context"
) {
const payload = (parsed as { payload?: unknown }).payload;
if (payload && typeof payload === "object") {
const model = (payload as Record<string, unknown>).model;
if (typeof model === "string" && model.trim().length > 0) {
return model.trim();
}
}
}
} catch {
continue;
}
}
return null;
}
function parseRolloutEntry(line: string): RolloutEntry | null {
if (!line) {
return null;
@@ -1024,7 +1269,11 @@ function parseRolloutEntry(line: string): RolloutEntry | null {
if (isRolloutEntry(parsed)) {
return parsed;
}
if (parsed && typeof parsed === "object" && typeof (parsed as { output?: unknown }).output === "string") {
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as { output?: unknown }).output === "string"
) {
return parseRolloutEntry((parsed as { output: string }).output);
}
} catch {
@@ -1042,7 +1291,11 @@ function isRolloutEntry(value: unknown): value is RolloutEntry {
}
function isSessionMetaEntry(value: unknown): value is SessionMetaEntry {
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "session_meta");
return Boolean(
value &&
typeof value === "object" &&
(value as { type?: unknown }).type === "session_meta"
);
}
function handleRolloutResponseItem(
@@ -1059,12 +1312,20 @@ function handleRolloutResponseItem(
const text = extractMessageText(payload.content);
if (text) {
if (payload.role === "assistant") {
events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text } });
events.push({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text },
});
} else if (payload.role === "user") {
if (isSyntheticRolloutUserMessage(text)) {
break;
}
events.push({ type: "timeline", provider: "codex", item: { type: "user_message", text } });
events.push({
type: "timeline",
provider: "codex",
item: { type: "user_message", text },
});
}
}
break;
@@ -1072,7 +1333,11 @@ function handleRolloutResponseItem(
case "reasoning": {
const text = extractReasoningText(payload);
if (text) {
events.push({ type: "timeline", provider: "codex", item: { type: "reasoning", text } });
events.push({
type: "timeline",
provider: "codex",
item: { type: "reasoning", text },
});
}
break;
}
@@ -1093,12 +1358,19 @@ function handleRolloutResponseItem(
}
}
function handleRolloutEventMessage(payload: RolloutEventPayload | undefined, events: AgentStreamEvent[]): void {
function handleRolloutEventMessage(
payload: RolloutEventPayload | undefined,
events: AgentStreamEvent[]
): void {
if (!payload || typeof payload !== "object") {
return;
}
if (payload.type === "agent_reasoning" && typeof payload.text === "string") {
events.push({ type: "timeline", provider: "codex", item: { type: "reasoning", text: payload.text } });
events.push({
type: "timeline",
provider: "codex",
item: { type: "reasoning", text: payload.text },
});
}
}
@@ -1115,9 +1387,12 @@ function handleRolloutFunctionCall(
if (SHELL_FUNCTION_NAMES.has(name)) {
const args = safeJsonParse<Record<string, unknown>>(payload.arguments);
const command = formatCommand(args);
const cwd = args && typeof args === "object" && typeof (args as { workdir?: unknown }).workdir === "string"
? ((args as { workdir?: unknown }).workdir as string)
: undefined;
const cwd =
args &&
typeof args === "object" &&
typeof (args as { workdir?: unknown }).workdir === "string"
? ((args as { workdir?: unknown }).workdir as string)
: undefined;
if (command && typeof payload.call_id === "string") {
commandCalls.set(payload.call_id, { command, cwd });
events.push({
@@ -1141,7 +1416,11 @@ function handleRolloutFunctionCall(
const args = safeJsonParse<{ plan?: unknown }>(payload.arguments);
const planItems = parsePlanItems(args);
if (planItems.length) {
events.push({ type: "timeline", provider: "codex", item: { type: "todo", items: planItems } });
events.push({
type: "timeline",
provider: "codex",
item: { type: "todo", items: planItems },
});
}
return;
}
@@ -1175,16 +1454,19 @@ function finalizeRolloutFunctionCall(
}
const result = safeJsonParse<CommandExecutionResult>(payload.output);
const exitCode = result?.metadata?.exit_code;
const status = exitCode === undefined || exitCode === 0 ? "completed" : "failed";
const status =
exitCode === undefined || exitCode === 0 ? "completed" : "failed";
// Build structured command output
const output = result?.stdout ? {
type: "command" as const,
command: command.command,
output: result.stdout,
exitCode,
cwd: command.cwd,
} : result;
const output = result?.stdout
? {
type: "command" as const,
command: command.command,
output: result.stdout,
exitCode,
cwd: command.cwd,
}
: result;
events.push({
type: "timeline",
@@ -1203,7 +1485,10 @@ function finalizeRolloutFunctionCall(
commandCalls.delete(payload.call_id);
}
function handleRolloutCustomToolCall(payload: RolloutCustomToolCallPayload, events: AgentStreamEvent[]): void {
function handleRolloutCustomToolCall(
payload: RolloutCustomToolCallPayload,
events: AgentStreamEvent[]
): void {
if (payload?.name === "apply_patch" && typeof payload.input === "string") {
const patchText = payload.input;
const files = parsePatchFiles(patchText);
@@ -1339,7 +1624,10 @@ type SessionMetaEntry = {
payload?: SessionMetaPayload;
};
async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> {
async function collectRecentRolloutFiles(
rootDir: string,
limit: number
): Promise<RolloutCandidate[]> {
const candidates: RolloutCandidate[] = [];
async function traverse(dir: string): Promise<void> {
let entries: Dirent[];
@@ -1402,8 +1690,12 @@ async function parseRolloutDescriptor(
continue;
}
if (!sessionId && isSessionMetaEntry(entry) && entry.payload) {
sessionId = typeof entry.payload.id === "string" ? entry.payload.id : null;
if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) {
sessionId =
typeof entry.payload.id === "string" ? entry.payload.id : null;
if (
typeof entry.payload.cwd === "string" &&
entry.payload.cwd.length > 0
) {
cwd = entry.payload.cwd;
}
}
@@ -1467,7 +1759,8 @@ function extractMessageText(content: unknown): string {
parts.push(text.trim());
continue;
}
const message = typeof record.message === "string" ? record.message : undefined;
const message =
typeof record.message === "string" ? record.message : undefined;
if (message && message.trim()) {
parts.push(message.trim());
}
@@ -1482,7 +1775,10 @@ export function isSyntheticRolloutUserMessage(text: string): boolean {
}
const lower = normalized.toLowerCase();
if (lower.startsWith("# agents.md instructions for") && lower.includes("<instructions>")) {
if (
lower.startsWith("# agents.md instructions for") &&
lower.includes("<instructions>")
) {
return true;
}
@@ -1510,7 +1806,10 @@ function extractReasoningText(payload: RolloutReasoningPayload): string {
return "";
}
async function loadCodexPersistedTimeline(filePath: string, threadId: string): Promise<AgentTimelineItem[]> {
async function loadCodexPersistedTimeline(
filePath: string,
threadId: string
): Promise<AgentTimelineItem[]> {
try {
const events = await parseRolloutFile(filePath, threadId);
const timeline: AgentTimelineItem[] = [];
@@ -1525,13 +1824,19 @@ async function loadCodexPersistedTimeline(filePath: string, threadId: string): P
}
return timeline;
} catch (error) {
console.warn(`[CodexAgentSession] Failed to load persisted timeline for ${threadId}:`, error);
console.warn(
`[CodexAgentSession] Failed to load persisted timeline for ${threadId}:`,
error
);
return [];
}
}
function parsePlanItems(args: unknown): { text: string; completed: boolean }[] {
const planValue = typeof args === "object" && args ? (args as { plan?: unknown }).plan : undefined;
const planValue =
typeof args === "object" && args
? (args as { plan?: unknown }).plan
: undefined;
const plan = Array.isArray(planValue) ? planValue : [];
const items: { text: string; completed: boolean }[] = [];
for (const step of plan) {
@@ -1539,7 +1844,12 @@ function parsePlanItems(args: unknown): { text: string; completed: boolean }[] {
continue;
}
const record = step as { step?: unknown; text?: unknown; status?: unknown };
const text = typeof record.step === "string" ? record.step : typeof record.text === "string" ? record.text : "";
const text =
typeof record.step === "string"
? record.step
: typeof record.text === "string"
? record.text
: "";
if (!text) {
continue;
}
@@ -1600,7 +1910,10 @@ function formatCommand(args: unknown): string | null {
return null;
}
function readCodexExtraString(config: CodexAgentConfig, key: string): string | undefined {
function readCodexExtraString(
config: CodexAgentConfig,
key: string
): string | undefined {
const extras = config.extra?.codex;
if (!extras || typeof extras !== "object") {
return undefined;
@@ -1609,7 +1922,10 @@ function readCodexExtraString(config: CodexAgentConfig, key: string): string | u
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readMetadataString(handle: AgentPersistenceHandle | undefined, key: string): string | undefined {
function readMetadataString(
handle: AgentPersistenceHandle | undefined,
key: string
): string | undefined {
if (!handle?.metadata || typeof handle.metadata !== "object") {
return undefined;
}
@@ -1617,8 +1933,12 @@ function readMetadataString(handle: AgentPersistenceHandle | undefined, key: str
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> | undefined {
const entries = Object.entries(metadata).filter(([_, value]) => value !== undefined && value !== null);
function sanitizeMetadata(
metadata: Record<string, unknown>
): Record<string, unknown> | undefined {
const entries = Object.entries(metadata).filter(
([_, value]) => value !== undefined && value !== null
);
if (!entries.length) {
return undefined;
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { readLatestTurnContextModel } from "./codex-agent.js";
describe("readLatestTurnContextModel", () => {
it("returns the most recent turn_context model from rollout", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-rollout-"));
const file = path.join(dir, "rollout.jsonl");
const lines = [
JSON.stringify({ type: "session_meta", payload: { id: "thread-1" } }),
JSON.stringify({
type: "turn_context",
payload: { model: "gpt-5.1-codex", cwd: "/tmp" },
}),
JSON.stringify({
type: "turn_context",
payload: { model: "gpt-5.2-codex", cwd: "/tmp" },
}),
];
await fs.writeFile(file, lines.join("\n"), "utf8");
const model = await readLatestTurnContextModel(file);
expect(model).toBe("gpt-5.2-codex");
});
});
+10
View File
@@ -12,6 +12,7 @@ import type {
AgentPermissionResponse,
AgentPersistenceHandle,
AgentProvider,
AgentRuntimeInfo,
AgentStreamEvent,
AgentTimelineItem,
AgentUsage,
@@ -214,6 +215,14 @@ const AgentPersistenceHandleSchema: z.ZodType<AgentPersistenceHandle | null> =
})
.nullable();
const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
provider: AgentProviderSchema,
sessionId: z.string().nullable(),
model: z.string().nullable().optional(),
modeId: z.string().nullable().optional(),
extra: z.record(z.unknown()).optional(),
});
export const AgentSnapshotPayloadSchema = z.object({
id: z.string(),
provider: AgentProviderSchema,
@@ -229,6 +238,7 @@ export const AgentSnapshotPayloadSchema = z.object({
availableModes: z.array(AgentModeSchema),
pendingPermissions: z.array(AgentPermissionRequestPayloadSchema),
persistence: AgentPersistenceHandleSchema.nullable(),
runtimeInfo: AgentRuntimeInfoSchema.optional(),
lastUsage: AgentUsageSchema.optional(),
lastError: z.string().optional(),
title: z.string().nullable(),
+34
View File
@@ -0,0 +1,34 @@
import { CodexAgentClient } from "@paseo/server/src/server/agent/providers/codex-agent.js";
async function test() {
console.log("Creating CodexAgentClient...");
const client = new CodexAgentClient();
console.log("Creating session...");
const session = await client.createSession({
provider: "codex",
cwd: process.cwd(),
modeId: "full-access",
});
console.log("Session created:", session.id);
console.log("\nNow check ps to see which codex binary is running!\n");
// Run a simple prompt using stream to see all events
console.log("Running test prompt...");
console.log("Sleeping 30s - check ps for the args now!");
await new Promise((resolve) => setTimeout(resolve, 30000));
const events = session.stream('echo "test"');
for await (const event of events) {
if (event.type === "turn_completed") {
console.log("Turn completed");
}
}
await session.close();
console.log("Session closed");
}
test().catch(console.error);
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
import { Codex } from '@openai/codex-sdk';
console.log('Testing codexPathOverride option...\n');
// Test 1: Default (embedded binary)
console.log('Test 1: Using default embedded binary');
const defaultCodex = new Codex();
console.log('✓ Created Codex instance with default settings');
// Test 2: System binary via codexPathOverride
console.log('\nTest 2: Using system binary via codexPathOverride');
const systemCodex = new Codex({
codexPathOverride: '/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/codex'
});
console.log('✓ Created Codex instance with codexPathOverride');
// Test 3: Using which codex
console.log('\nTest 3: Using which codex result');
import { execSync } from 'child_process';
const whichCodex = execSync('which codex', { encoding: 'utf8' }).trim();
console.log('System codex location:', whichCodex);
const whichCodexInstance = new Codex({
codexPathOverride: whichCodex
});
console.log('✓ Created Codex instance with system codex from PATH');
console.log('\n✅ All tests passed! codexPathOverride option is available and working.');
console.log('\nConclusion: You can use the system-installed codex by passing:');
console.log(' new Codex({ codexPathOverride: "/path/to/system/codex" })');
+36
View File
@@ -0,0 +1,36 @@
import {
fetchClaudeModelCatalog,
fetchCodexModelCatalog,
} from "./packages/server/dist/server/agent/model-catalog.js";
console.log("=== Testing Claude Model Catalog ===\n");
try {
const claudeModels = await fetchClaudeModelCatalog();
console.log(`Found ${claudeModels.length} Claude models:\n`);
claudeModels.forEach((model, idx) => {
console.log(`${idx + 1}. ${model.label} (${model.id})`);
console.log(` Description: ${model.description || "N/A"}`);
console.log(` Is Default: ${model.isDefault || false}`);
console.log("");
});
} catch (error) {
console.error("Error fetching Claude models:", error.message);
}
console.log("\n=== Testing Codex Model Catalog ===\n");
try {
const codexModels = await fetchCodexModelCatalog();
console.log(`Found ${codexModels.length} Codex models:\n`);
codexModels.forEach((model, idx) => {
console.log(`${idx + 1}. ${model.label} (${model.id})`);
console.log(` Description: ${model.description || "N/A"}`);
console.log(` Is Default: ${model.isDefault || false}`);
if (model.metadata) {
console.log(` Model: ${model.metadata.model || "N/A"}`);
console.log(` Default Reasoning Effort: ${model.metadata.defaultReasoningEffort || "N/A"}`);
}
console.log("");
});
} catch (error) {
console.error("Error fetching Codex models:", error.message);
}
+40
View File
@@ -0,0 +1,40 @@
import {
fetchClaudeModelCatalog,
fetchCodexModelCatalog,
} from "./packages/server/src/server/agent/model-catalog.js";
async function main() {
console.log("=== Testing Claude Model Catalog ===\n");
try {
const claudeModels = await fetchClaudeModelCatalog();
console.log(`Found ${claudeModels.length} Claude models:\n`);
claudeModels.forEach((model, idx) => {
console.log(`${idx + 1}. ${model.label} (${model.id})`);
console.log(` Description: ${model.description || "N/A"}`);
console.log(` Is Default: ${model.isDefault || false}`);
console.log("");
});
} catch (error: any) {
console.error("Error fetching Claude models:", error.message);
}
console.log("\n=== Testing Codex Model Catalog ===\n");
try {
const codexModels = await fetchCodexModelCatalog();
console.log(`Found ${codexModels.length} Codex models:\n`);
codexModels.forEach((model, idx) => {
console.log(`${idx + 1}. ${model.label} (${model.id})`);
console.log(` Description: ${model.description || "N/A"}`);
console.log(` Is Default: ${model.isDefault || false}`);
if (model.metadata) {
console.log(` Model: ${model.metadata.model || "N/A"}`);
console.log(` Default Reasoning Effort: ${model.metadata.defaultReasoningEffort || "N/A"}`);
}
console.log("");
});
} catch (error: any) {
console.error("Error fetching Codex models:", error.message);
}
}
main();
+34
View File
@@ -0,0 +1,34 @@
import { CodexAgentClient } from "@paseo/server/src/server/agent/providers/codex-agent.js";
async function test() {
console.log("Creating CodexAgentClient...");
const client = new CodexAgentClient();
console.log("Creating session...");
const session = await client.createSession({
provider: "codex",
cwd: process.cwd(),
modeId: "full-access",
});
console.log("Session created:", session.id);
console.log("\nNow check ps to see which codex binary is running!\n");
// Run a simple prompt using stream to see all events
console.log("Running test prompt...");
console.log("Sleeping 30s - check ps for the args now!");
await new Promise((resolve) => setTimeout(resolve, 30000));
const events = session.stream('echo "test"');
for await (const event of events) {
if (event.type === "turn_completed") {
console.log("Turn completed");
}
}
await session.close();
console.log("Session closed");
}
test().catch(console.error);