Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-codex-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stoneforge/smithy": patch
---

Update Codex interactive sessions to use the documented workspace-write sandbox flag.
7 changes: 7 additions & 0 deletions .changeset/current-agent-protocols.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@stoneforge/smithy": patch
---

Update Claude Agent SDK to 0.3.282 and terminate SDK processes when sessions close. Sanitize the environment for Claude model discovery as well as session launches.

Align Codex app-server integration with the current model catalog, paginated model discovery, turn cancellation IDs, nested turn completion status, error notifications and command output. Terminate failed headless sessions with a non-zero exit instead of leaving their message queues open. Preserve resume IDs reported during graceful shutdown without marking stopped agents as running.
5 changes: 5 additions & 0 deletions .changeset/quiet-codex-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stoneforge/smithy": patch
---

Fix Codex interactive resume so only UUID session IDs from the Codex continuation footer are persisted. Stopping an interactive Codex session now requests a clean `/exit` shutdown, allowing Stoneforge to capture the resume ID and avoid offering invalid resume targets from ordinary terminal text.
9 changes: 9 additions & 0 deletions .changeset/strip-claudecode-from-spawn-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@stoneforge/smithy": patch
---

fix: strip CLAUDECODE from spawned-claude env to allow nested spawns

When stoneforge runs from inside a Claude Code session (any user invoking `sf` from Claude Code's bash tool, or a director orchestrating workers from a Claude Code session), the spawned claude subprocess inherits `CLAUDECODE=1` from `process.env`. The previous explicit `CLAUDECODE: '1'` in the env spread re-asserted it. Modern claude versions read `CLAUDECODE` and refuse to start with "Claude Code cannot be launched inside another Claude Code session", which the spawner surfaced as the cryptic "Session exited before init".

Strip `CLAUDECODE` from the env passed to both headless (SDK) and interactive (PTY) provider spawns. Stoneforge IS the parent; the spawned claude is a fresh top-level session, not nested.
7 changes: 7 additions & 0 deletions .changeset/thread-agent-environment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@stoneforge/smithy": patch
---

Pass agent environment overrides and the Stoneforge workspace root to each Codex thread, including resumed threads. This preserves SF_ENTITY_ID for task commands without leaking another agent's identity through the shared app-server.

Add an opt-in live Director → Worker → Merge Steward check using production services, both Codex and Claude Code, immutable acceptance tests and a local Git origin.
66 changes: 60 additions & 6 deletions apps/docs/src/content/docs/guides/multi-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Stoneforge supports multiple LLM providers. You can assign different providers t
|----------|-------|----------|---------|
| Claude Code | <ProviderBadge name="claude-code" color="#60a5fa" /> | `claude` | Yes |
| OpenCode | <ProviderBadge name="opencode" color="#4ade80" /> | `opencode` | No |
| OpenAI Codex | <ProviderBadge name="codex" color="#f59e0b" /> | Built-in (JSON-RPC over stdio) | No |
| OpenAI Codex | <ProviderBadge name="codex" color="#f59e0b" /> | `codex` (app-server over stdio for headless sessions) | No |

<Aside type="tip">
You do **not** configure API keys in Stoneforge. Authentication is handled by the underlying provider CLI. If `claude` works in your terminal, it works with Stoneforge. Same for `opencode` and Codex.
Expand Down Expand Up @@ -82,13 +82,13 @@ You can specify which model an agent uses at several points:
**At registration:**

```bash
sf agent register e-worker-1 --role worker --provider claude-code --model claude-sonnet-4-20250514
sf agent register e-worker-1 --role worker --provider claude-code --model sonnet
```

**At session start** (one-time override):

```bash
sf agent start <id> --model claude-opus-4-6
sf agent start <id> --model opus
```

**In the dashboard:** The Create Agent dialog has a model dropdown that dynamically loads available models from the selected provider.
Expand All @@ -97,9 +97,63 @@ sf agent start <id> --model claude-opus-4-6

| Provider | Example model IDs |
|----------|-------------------|
| Claude Code | `claude-sonnet-4-20250514`, `claude-opus-4-6`, `claude-haiku-4-5-20251001` |
| Claude Code | `sonnet`, `opus`, `haiku` (aliases resolved by Claude) |
| OpenCode | Provider-configured model |
| Codex | `o3`, `o4-mini` |
| Codex | Select an ID returned by the installed CLI's model catalog |

Model availability depends on the installed provider and its authenticated account.
Stoneforge queries the providers rather than maintaining a fixed list of model IDs.
For Codex, it follows catalog pagination and honors the server's default-model flag.

## Checking provider compatibility

From a source checkout, run these opt-in checks after authenticating the CLIs:

```bash
bun scripts/check-agent-compatibility.ts codex
bun scripts/check-agent-compatibility.ts claude-code
```

Each check uses model quota for two short turns in a temporary directory. It lists
models, starts a headless session, closes it, and resumes the same session to verify
that conversation history survived. It does not dispatch tasks or test the merge pipeline.

To check the complete headless Director → Worker → Merge Steward pipeline:

```bash
bun scripts/check-agent-orchestration.ts codex
bun scripts/check-agent-orchestration.ts claude-code
```

The argument selects the worker provider; the other provider creates and reviews
the task. Each run uses production orchestration services in a temporary project
with a local bare Git origin. The agents fix a failing arithmetic implementation,
run immutable acceptance tests, complete the task and merge it. The check verifies
the agent identities used by the CLI, passing tests from both worker and steward,
the merged commit on the local origin, worktree cleanup and session termination.
It uses model quota and requires authenticated Codex and Claude Code providers,
Bun, Node.js, npm and Git. Failed runs retain their temporary workspace and logs;
set `SF_KEEP_TEST_WORKSPACE=1` to retain successful runs too.

Both provider arrangements passed on macOS with Codex CLI 0.157.0 and Claude
Agent SDK 0.3.282. This covers headless local merges; interactive terminal
conversations, GitHub approval workflows and Windows/Linux execution still need
separate live checks. The fixture uses absolute CLI paths to avoid accidentally
testing a different global `sf` installation after a login shell resets `PATH`.

Codex receives `SF_ENTITY_ID`, the workspace root and explicit environment
overrides per thread, including resumed sessions, so agents sharing an app-server
keep their own task identity.

The September 2026 compatibility update was checked on macOS with Codex CLI
0.154.0 and Claude Agent SDK 0.3.282. Claude interactive sessions use the separately
installed `claude` CLI; headless sessions use the SDK's bundled executable unless a
custom executable is configured.

Codex interactive sessions use `--sandbox workspace-write`. Their resume ID is
captured from the continuation footer on graceful exit. Stop them through Stoneforge
to allow that footer to be recorded; force-killing a new session may leave no resume
ID. Invalid IDs saved by older versions are rejected instead of passed to Codex.

## Default model per provider

Expand Down Expand Up @@ -196,7 +250,7 @@ Pool agent type configurations can include `provider` and `model` fields to crea

```bash
sf pool create multi-model --size 6 \
-t worker:ephemeral:100:3:claude-code:claude-sonnet-4-20250514 \
-t worker:ephemeral:100:3:claude-code:sonnet \
-t worker:ephemeral:80:3:opencode
```

Expand Down
12 changes: 12 additions & 0 deletions apps/smithy-server/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { formatSessionRecord } from '../formatters.js';
import { notifySSEClientsOfNewSession } from './events.js';

const logger = createLogger('sessions');
const CODEX_RESUME_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

type NotifyClientsCallback = (
agentId: EntityId,
Expand Down Expand Up @@ -593,6 +594,17 @@ Please begin working on this task. Use \`sf task get ${taskResult.id}\` to see f
providerSessionId = resumable.providerSessionId;
}

const meta = getAgentMetadata(agent);
if ((meta as { provider?: string } | undefined)?.provider === 'codex'
&& !CODEX_RESUME_SESSION_ID_PATTERN.test(providerSessionId)) {
return c.json({
error: {
code: 'INVALID_PROVIDER_SESSION_ID',
message: 'Codex resume requires a valid UUID session ID',
},
}, 400);
}

const { session, events, uwpCheck } = await sessionManager.resumeSession(agentId, {
providerSessionId,
workingDirectory: body.workingDirectory,
Expand Down
13 changes: 12 additions & 1 deletion apps/smithy-web/src/api/hooks/useAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ import type {
// ============================================================================

const API_BASE = '/api';
const CODEX_RESUME_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function hasValidProviderSessionIdForAgent(agent: Agent, session: unknown): session is { providerSessionId: string } {
const providerSessionId = (session as { providerSessionId?: unknown } | null)?.providerSessionId;
if (typeof providerSessionId !== 'string' || providerSessionId.length === 0) {
return false;
}

return agent.metadata?.agent?.provider !== 'codex'
|| CODEX_RESUME_SESSION_ID_PATTERN.test(providerSessionId);
}

async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
Expand Down Expand Up @@ -216,7 +227,7 @@ export function useDirectors(): {
const query = statusQueries[i];
const statusData = query?.data;
const history = statusData?.recentHistory ?? [];
const lastResumableSession = history.find((h) => !!h.providerSessionId) ?? null;
const lastResumableSession = history.find((h) => hasValidProviderSessionIdForAgent(director, h)) ?? null;

return {
director,
Expand Down
2 changes: 1 addition & 1 deletion packages/smithy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
"claude"
],
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.45",
"@anthropic-ai/claude-agent-sdk": "^0.3.282",
"@stoneforge/core": "workspace:*",
"@stoneforge/quarry": "workspace:*",
"@stoneforge/shared-routes": "workspace:*",
Expand Down
58 changes: 58 additions & 0 deletions packages/smithy/src/providers/claude/env.bun.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'bun:test';
import { buildClaudeSpawnEnv } from './env.js';

describe('buildClaudeSpawnEnv', () => {
it('strips CLAUDECODE from inherited env (parent is a Claude Code session)', () => {
const env = buildClaudeSpawnEnv({ CLAUDECODE: '1', PATH: '/usr/bin' });
expect(env.CLAUDECODE).toBeUndefined();
expect(env.PATH).toBe('/usr/bin');
});

it('strips CLAUDECODE from caller overrides too', () => {
const env = buildClaudeSpawnEnv(
{ PATH: '/usr/bin' },
{ overrides: { CLAUDECODE: '1', CUSTOM: 'value' } }
);
expect(env.CLAUDECODE).toBeUndefined();
expect(env.CUSTOM).toBe('value');
expect(env.PATH).toBe('/usr/bin');
});

it('preserves all other env vars', () => {
const env = buildClaudeSpawnEnv({
HOME: '/home/test',
PATH: '/usr/bin:/bin',
ANTHROPIC_API_KEY: 'sk-redacted',
USER: 'tester',
});
expect(env.HOME).toBe('/home/test');
expect(env.PATH).toBe('/usr/bin:/bin');
expect(env.ANTHROPIC_API_KEY).toBe('sk-redacted');
expect(env.USER).toBe('tester');
});

it('caller overrides take precedence over inherited env', () => {
const env = buildClaudeSpawnEnv(
{ CUSTOM: 'inherited' },
{ overrides: { CUSTOM: 'override' } }
);
expect(env.CUSTOM).toBe('override');
});

it('sets STONEFORGE_ROOT when provided', () => {
const env = buildClaudeSpawnEnv({}, { stoneforgeRoot: '/path/to/.stoneforge' });
expect(env.STONEFORGE_ROOT).toBe('/path/to/.stoneforge');
});

it('does not set STONEFORGE_ROOT when omitted', () => {
const env = buildClaudeSpawnEnv({});
expect(env.STONEFORGE_ROOT).toBeUndefined();
});

it('returns a fresh object each call (no mutation of inputs)', () => {
const base = { CLAUDECODE: '1', PATH: '/usr/bin' };
buildClaudeSpawnEnv(base);
expect(base.CLAUDECODE).toBe('1');
expect(base.PATH).toBe('/usr/bin');
});
});
51 changes: 51 additions & 0 deletions packages/smithy/src/providers/claude/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Shared environment construction for Claude provider spawns.
*
* Both the headless (SDK) and interactive (PTY) providers construct the env
* passed to the spawned claude subprocess. The shape is the same across both,
* and one detail in particular is load-bearing: CLAUDECODE must be stripped.
*
* Why: modern versions of the claude binary read CLAUDECODE and refuse to
* start when it is set, with the error "Claude Code cannot be launched
* inside another Claude Code session." If stoneforge is invoked from inside
* an existing Claude Code session (any user running `sf` from Claude Code's
* bash tool, or a director orchestrating from within Claude Code), the
* inherited CLAUDECODE=1 triggers this guard and the spawn dies before init.
* The Anthropic SDK reports it as `Claude Code process exited with code 1`,
* which the spawner surfaces as the cryptic `Session exited before init`.
*
* Stoneforge IS the parent process; the spawned claude is a fresh top-level
* session, not nested. So CLAUDECODE has no business being set in the child.
*/

export interface ClaudeSpawnEnvOptions {
/** Override environment variables (caller-supplied; merged after process.env). */
readonly overrides?: Record<string, string>;
/** Stoneforge project root, exposed to the child as STONEFORGE_ROOT. */
readonly stoneforgeRoot?: string;
}

/**
* Build the environment for a spawned claude subprocess.
*
* Order of precedence (later wins):
* 1. process.env (inherited)
* 2. caller's `overrides`
*
* Then CLAUDECODE is unconditionally stripped (regardless of source), and
* STONEFORGE_ROOT is set if provided.
*/
export function buildClaudeSpawnEnv(
baseEnv: NodeJS.ProcessEnv,
options: ClaudeSpawnEnvOptions = {}
): Record<string, string> {
const env: Record<string, string> = {
...(baseEnv as Record<string, string>),
...options.overrides,
};
delete env.CLAUDECODE;
if (options.stoneforgeRoot) {
env.STONEFORGE_ROOT = options.stoneforgeRoot;
}
return env;
}
Loading