diff --git a/.changeset/calm-codex-flags.md b/.changeset/calm-codex-flags.md
new file mode 100644
index 00000000..ea350353
--- /dev/null
+++ b/.changeset/calm-codex-flags.md
@@ -0,0 +1,5 @@
+---
+"@stoneforge/smithy": patch
+---
+
+Update Codex interactive sessions to use the documented workspace-write sandbox flag.
diff --git a/.changeset/current-agent-protocols.md b/.changeset/current-agent-protocols.md
new file mode 100644
index 00000000..a6832836
--- /dev/null
+++ b/.changeset/current-agent-protocols.md
@@ -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.
diff --git a/.changeset/quiet-codex-resume.md b/.changeset/quiet-codex-resume.md
new file mode 100644
index 00000000..f4f3c69b
--- /dev/null
+++ b/.changeset/quiet-codex-resume.md
@@ -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.
diff --git a/.changeset/strip-claudecode-from-spawn-env.md b/.changeset/strip-claudecode-from-spawn-env.md
new file mode 100644
index 00000000..6c22fe52
--- /dev/null
+++ b/.changeset/strip-claudecode-from-spawn-env.md
@@ -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.
diff --git a/.changeset/thread-agent-environment.md b/.changeset/thread-agent-environment.md
new file mode 100644
index 00000000..f0220b61
--- /dev/null
+++ b/.changeset/thread-agent-environment.md
@@ -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.
diff --git a/apps/docs/src/content/docs/guides/multi-provider.mdx b/apps/docs/src/content/docs/guides/multi-provider.mdx
index cb574a86..b16adfd5 100644
--- a/apps/docs/src/content/docs/guides/multi-provider.mdx
+++ b/apps/docs/src/content/docs/guides/multi-provider.mdx
@@ -14,7 +14,7 @@ Stoneforge supports multiple LLM providers. You can assign different providers t
|----------|-------|----------|---------|
| Claude Code | | `claude` | Yes |
| OpenCode | | `opencode` | No |
-| OpenAI Codex | | Built-in (JSON-RPC over stdio) | No |
+| OpenAI Codex | | `codex` (app-server over stdio for headless sessions) | No |
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.
@@ -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 --model claude-opus-4-6
+sf agent start --model opus
```
**In the dashboard:** The Create Agent dialog has a model dropdown that dynamically loads available models from the selected provider.
@@ -97,9 +97,63 @@ sf agent start --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
@@ -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
```
diff --git a/apps/smithy-server/src/routes/sessions.ts b/apps/smithy-server/src/routes/sessions.ts
index 1371674b..0131a04d 100644
--- a/apps/smithy-server/src/routes/sessions.ts
+++ b/apps/smithy-server/src/routes/sessions.ts
@@ -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,
@@ -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,
diff --git a/apps/smithy-web/src/api/hooks/useAgents.ts b/apps/smithy-web/src/api/hooks/useAgents.ts
index eeace80c..fb45295f 100644
--- a/apps/smithy-web/src/api/hooks/useAgents.ts
+++ b/apps/smithy-web/src/api/hooks/useAgents.ts
@@ -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(path: string, options?: RequestInit): Promise {
const response = await fetch(`${API_BASE}${path}`, {
@@ -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,
diff --git a/packages/smithy/package.json b/packages/smithy/package.json
index c2f05561..06a803d0 100644
--- a/packages/smithy/package.json
+++ b/packages/smithy/package.json
@@ -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:*",
diff --git a/packages/smithy/src/providers/claude/env.bun.test.ts b/packages/smithy/src/providers/claude/env.bun.test.ts
new file mode 100644
index 00000000..fb42046c
--- /dev/null
+++ b/packages/smithy/src/providers/claude/env.bun.test.ts
@@ -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');
+ });
+});
diff --git a/packages/smithy/src/providers/claude/env.ts b/packages/smithy/src/providers/claude/env.ts
new file mode 100644
index 00000000..6770e8d9
--- /dev/null
+++ b/packages/smithy/src/providers/claude/env.ts
@@ -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;
+ /** 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 {
+ const env: Record = {
+ ...(baseEnv as Record),
+ ...options.overrides,
+ };
+ delete env.CLAUDECODE;
+ if (options.stoneforgeRoot) {
+ env.STONEFORGE_ROOT = options.stoneforgeRoot;
+ }
+ return env;
+}
diff --git a/packages/smithy/src/providers/claude/headless.bun.test.ts b/packages/smithy/src/providers/claude/headless.bun.test.ts
index 4d09476a..979071b3 100644
--- a/packages/smithy/src/providers/claude/headless.bun.test.ts
+++ b/packages/smithy/src/providers/claude/headless.bun.test.ts
@@ -20,6 +20,11 @@ import type { Options as SDKOptions, SpawnOptions } from '@anthropic-ai/claude-a
* We mock the SDK to capture these instead of actually spawning.
*/
let capturedSdkOptions: SDKOptions | undefined;
+const closeQuery = mock(() => {});
+const interruptQuery = mock(async () => {});
+const supportedModels = mock(async () => [
+ { value: 'sonnet', displayName: 'Sonnet', description: 'Sonnet · Current model' },
+]);
/**
* Captured child_process.spawn calls: [command, args, options].
@@ -57,8 +62,9 @@ mock.module('@anthropic-ai/claude-agent-sdk', () => ({
})();
// Add required Query methods
Object.assign(gen, {
- interrupt: mock(async () => {}),
- close: mock(() => {}),
+ interrupt: interruptQuery,
+ close: closeQuery,
+ supportedModels,
streamInput: mock(async () => {}),
});
return gen;
@@ -73,9 +79,37 @@ describe('ClaudeHeadlessProvider', () => {
beforeEach(() => {
capturedSdkOptions = undefined;
capturedSpawnCalls = [];
+ closeQuery.mockClear();
+ interruptQuery.mockClear();
});
describe('spawn() model passthrough', () => {
+ it('closes the SDK process exactly once instead of only interrupting its turn', async () => {
+ const { ClaudeHeadlessProvider } = await import('./headless.js');
+ const session = await new ClaudeHeadlessProvider().spawn({ workingDirectory: '/test/dir' });
+ await session.interrupt();
+ expect(interruptQuery).toHaveBeenCalledTimes(1);
+ expect(closeQuery).not.toHaveBeenCalled();
+ session.close();
+ session.close();
+ expect(closeQuery).toHaveBeenCalledTimes(1);
+ expect(interruptQuery).toHaveBeenCalledTimes(1);
+ });
+
+ it('discovers models with a sanitized environment and closes the temporary query', async () => {
+ const { ClaudeAgentProvider } = await import('./index.js');
+ const previous = process.env.CLAUDECODE;
+ process.env.CLAUDECODE = '1';
+ try {
+ const models = await new ClaudeAgentProvider().listModels();
+ expect(models).toMatchObject([{ id: 'sonnet', displayName: 'Sonnet', isDefault: true }]);
+ expect(capturedSdkOptions?.env?.CLAUDECODE).toBeUndefined();
+ expect(closeQuery).toHaveBeenCalledTimes(1);
+ } finally {
+ if (previous === undefined) delete process.env.CLAUDECODE;
+ else process.env.CLAUDECODE = previous;
+ }
+ });
it('should include model in SDK options when provided', async () => {
const { ClaudeHeadlessProvider } = await import('./headless.js');
const provider = new ClaudeHeadlessProvider();
@@ -351,4 +385,50 @@ describe('ClaudeHeadlessProvider', () => {
expect(provider.name).toBe('claude-headless');
});
});
+
+ describe('CLAUDECODE env handling', () => {
+ // Regression test for the nested-session refusal: if CLAUDECODE is set
+ // in the parent process env (e.g., stoneforge is invoked from inside
+ // an existing Claude Code session), it must NOT be propagated to the
+ // spawned claude subprocess. Modern claude versions read CLAUDECODE
+ // and refuse to start, exiting 1, which the spawner surfaces as the
+ // cryptic "Session exited before init" error.
+ it('strips CLAUDECODE from the spawned env even when present in process.env', async () => {
+ const { ClaudeHeadlessProvider } = await import('./headless.js');
+ const provider = new ClaudeHeadlessProvider();
+
+ const previous = process.env.CLAUDECODE;
+ process.env.CLAUDECODE = '1';
+ try {
+ const session = await provider.spawn({ workingDirectory: '/test/dir' });
+ session.close();
+
+ expect(capturedSdkOptions).toBeDefined();
+ const env = capturedSdkOptions!.env as Record | undefined;
+ expect(env).toBeDefined();
+ expect(env!.CLAUDECODE).toBeUndefined();
+ } finally {
+ if (previous === undefined) {
+ delete process.env.CLAUDECODE;
+ } else {
+ process.env.CLAUDECODE = previous;
+ }
+ }
+ });
+
+ it('strips CLAUDECODE even when caller passes it via environmentVariables', async () => {
+ const { ClaudeHeadlessProvider } = await import('./headless.js');
+ const provider = new ClaudeHeadlessProvider();
+
+ const session = await provider.spawn({
+ workingDirectory: '/test/dir',
+ environmentVariables: { CLAUDECODE: '1', OTHER_VAR: 'keep-me' },
+ });
+ session.close();
+
+ const env = capturedSdkOptions!.env as Record | undefined;
+ expect(env!.CLAUDECODE).toBeUndefined();
+ expect(env!.OTHER_VAR).toBe('keep-me');
+ });
+ });
});
diff --git a/packages/smithy/src/providers/claude/headless.ts b/packages/smithy/src/providers/claude/headless.ts
index 922eb7b4..57be5441 100644
--- a/packages/smithy/src/providers/claude/headless.ts
+++ b/packages/smithy/src/providers/claude/headless.ts
@@ -9,6 +9,7 @@
import { spawn as cpSpawn } from 'node:child_process';
import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
+import { buildClaudeSpawnEnv } from './env.js';
import type { Query as SDKQuery, SDKMessage, SDKUserMessage, Options as SDKOptions, SpawnOptions, SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
import type {
HeadlessProvider,
@@ -123,12 +124,9 @@ class ClaudeHeadlessSession implements HeadlessSession {
if (this.closed) return;
this.closed = true;
this.inputQueue.close();
- // Interrupt the SDK query to abort the running API request and break the
- // async iterator in createMessageIterator(). Without this, the underlying
- // agent process continues executing even after the session is "closed".
- this.sdkQuery.interrupt().catch(() => {
- // Ignore errors — the query may already be finished
- });
+ // interrupt() only cancels the current turn; close() also releases the
+ // subprocess and MCP transports, including when the session is idle.
+ this.sdkQuery.close();
}
getSessionId(): ProviderSessionId | undefined {
@@ -328,15 +326,12 @@ export class ClaudeHeadlessProvider implements HeadlessProvider {
async spawn(options: HeadlessSpawnOptions): Promise {
const initialPrompt = options.initialPrompt ?? 'You are an AI agent. Await further instructions.';
- // Build environment
- const env: Record = {
- ...(process.env as Record),
- CLAUDECODE: '1',
- ...options.environmentVariables,
- };
- if (options.stoneforgeRoot) {
- env.STONEFORGE_ROOT = options.stoneforgeRoot;
- }
+ // Build environment via shared helper that strips CLAUDECODE
+ // (see env.ts for why this matters).
+ const env = buildClaudeSpawnEnv(process.env, {
+ overrides: options.environmentVariables,
+ stoneforgeRoot: options.stoneforgeRoot,
+ });
// Create input queue for streaming input mode
const inputQueue = new SDKInputQueue();
diff --git a/packages/smithy/src/providers/claude/index.ts b/packages/smithy/src/providers/claude/index.ts
index f2fb836e..c49ac67a 100644
--- a/packages/smithy/src/providers/claude/index.ts
+++ b/packages/smithy/src/providers/claude/index.ts
@@ -12,6 +12,7 @@ import type { ModelInfo as SDKModelInfo } from '@anthropic-ai/claude-agent-sdk';
import { ProviderError, type AgentProvider, type HeadlessProvider, type InteractiveProvider, type ModelInfo } from '../types.js';
import { ClaudeHeadlessProvider } from './headless.js';
import { ClaudeInteractiveProvider } from './interactive.js';
+import { buildClaudeSpawnEnv } from './env.js';
export { ClaudeHeadlessProvider } from './headless.js';
export { ClaudeInteractiveProvider } from './interactive.js';
@@ -47,6 +48,7 @@ export class ClaudeAgentProvider implements AgentProvider {
queryInstance = sdkQuery({
prompt: '',
options: {
+ env: buildClaudeSpawnEnv(process.env),
// Use bypassPermissions to avoid permission prompts
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
diff --git a/packages/smithy/src/providers/claude/interactive.ts b/packages/smithy/src/providers/claude/interactive.ts
index bcf687c4..64c76443 100644
--- a/packages/smithy/src/providers/claude/interactive.ts
+++ b/packages/smithy/src/providers/claude/interactive.ts
@@ -13,6 +13,7 @@ import { dirname, join } from 'node:path';
import { createRequire } from 'node:module';
import * as pty from 'node-pty';
import type { IPty } from 'node-pty';
+import { buildClaudeSpawnEnv } from './env.js';
import type {
InteractiveProvider,
InteractiveSession,
@@ -117,15 +118,12 @@ export class ClaudeInteractiveProvider implements InteractiveProvider {
const sessionId = options.resumeSessionId ?? randomUUID();
const args = this.buildArgs(options, sessionId);
- // Build environment
- const env: Record = {
- ...(process.env as Record),
- CLAUDECODE: '1',
- ...options.environmentVariables,
- };
- if (options.stoneforgeRoot) {
- env.STONEFORGE_ROOT = options.stoneforgeRoot;
- }
+ // Build environment via shared helper that strips CLAUDECODE
+ // (see env.ts for why this matters).
+ const env = buildClaudeSpawnEnv(process.env, {
+ overrides: options.environmentVariables,
+ stoneforgeRoot: options.stoneforgeRoot,
+ });
const cols = options.cols ?? 120;
const rows = options.rows ?? 30;
diff --git a/packages/smithy/src/providers/codex/compatibility.bun.test.ts b/packages/smithy/src/providers/codex/compatibility.bun.test.ts
new file mode 100644
index 00000000..613338cb
--- /dev/null
+++ b/packages/smithy/src/providers/codex/compatibility.bun.test.ts
@@ -0,0 +1,162 @@
+import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
+import { CodexAgentProvider } from './index.js';
+import { CodexHeadlessProvider } from './headless.js';
+import { CodexEventMapper } from './event-mapper.js';
+import { serverManager, type CodexClient, type CodexModelList } from './server-manager.js';
+
+// Fixtures follow the protocol generated by codex-cli 0.154.0. Exercise the
+// actual providers so protocol drift cannot hide behind copied implementations.
+describe('Codex app-server compatibility', () => {
+ const threadId = '019e1277-6206-74e1-bb66-bbd8e9534628';
+ let notify: (method: string, params: unknown) => void;
+ let client: CodexClient;
+ let acquire: ReturnType;
+ let release: ReturnType;
+
+ beforeEach(() => {
+ client = {
+ model: { list: mock(async (): Promise => ({ data: [], nextCursor: null })) },
+ thread: {
+ start: mock(async () => ({ thread: { id: threadId } })),
+ resume: mock(async () => ({ thread: { id: threadId } })),
+ read: mock(async () => ({ thread: { id: threadId } })),
+ },
+ turn: {
+ start: mock(async () => ({ turn: { id: 'turn-1', status: 'inProgress' } })),
+ interrupt: mock(async () => {}),
+ },
+ onNotification: handler => { notify = handler; return () => {}; },
+ respondToServer: () => {},
+ close: () => {},
+ };
+ acquire = spyOn(serverManager, 'acquire').mockResolvedValue(client);
+ release = spyOn(serverManager, 'release').mockImplementation(() => {});
+ });
+
+ afterEach(() => { acquire.mockRestore(); release.mockRestore(); });
+
+ it('uses wire IDs, display names, pagination and the server-selected default', async () => {
+ client.model.list = mock(async ({ cursor } = {}) => cursor
+ ? { data: [{ id: 'picker-b', model: 'model-b', displayName: 'Model B', isDefault: true }], nextCursor: null }
+ : { data: [{ id: 'picker-a', model: 'model-a', displayName: 'Model A', isDefault: false }], nextCursor: 'page-2' });
+ const models = await new CodexAgentProvider().listModels();
+ expect(models.map(m => [m.id, m.displayName, m.isDefault])).toEqual([
+ ['model-a', 'Model A', undefined], ['model-b', 'Model B', true],
+ ]);
+ expect(client.model.list).toHaveBeenCalledWith({ limit: 50, cursor: 'page-2' });
+ expect(release).toHaveBeenCalledTimes(1);
+ });
+
+ it('still supports legacy model responses', async () => {
+ client.model.list = mock(async () => ({ models: [{ id: 'legacy', name: 'Legacy' }] }));
+ expect(await new CodexAgentProvider().listModels()).toEqual([
+ { id: 'legacy', displayName: 'Legacy', description: undefined, isDefault: true },
+ ]);
+ });
+
+ it('releases the server if model discovery fails', async () => {
+ client.model.list = mock(async () => { throw new Error('catalog unavailable'); });
+ await expect(new CodexAgentProvider().listModels()).rejects.toThrow('catalog unavailable');
+ expect(release).toHaveBeenCalledTimes(1);
+ });
+
+ it('cancels with the turn ID even when stopped before turn/start responds', async () => {
+ let resolveStart!: (value: { turn: { id: string } }) => void;
+ client.turn.start = mock(() => new Promise(resolve => { resolveStart = resolve; }));
+ const session = await new CodexHeadlessProvider().spawn({ workingDirectory: '/workspace', initialPrompt: 'test' });
+ const interrupted = session.interrupt();
+ expect(client.turn.interrupt).not.toHaveBeenCalled();
+ resolveStart({ turn: { id: 'turn-pending' } });
+ await interrupted;
+ expect(client.turn.interrupt).toHaveBeenCalledWith({ threadId, turnId: 'turn-pending' });
+ session.close();
+ await Bun.sleep(0);
+ });
+
+ it('keeps the server until close cancellation is acknowledged', async () => {
+ let finishInterrupt!: () => void;
+ client.turn.interrupt = mock(() => new Promise(resolve => { finishInterrupt = resolve; }));
+ const session = await new CodexHeadlessProvider().spawn({ workingDirectory: '/workspace' });
+ session.close();
+ session.close();
+ await Bun.sleep(0);
+ expect(release).not.toHaveBeenCalled();
+ finishInterrupt();
+ await Bun.sleep(0);
+ expect(release).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not cancel completed turns or turns belonging to another thread', async () => {
+ const session = await new CodexHeadlessProvider().spawn({ workingDirectory: '/workspace' });
+ notify('turn/completed', { threadId, turn: { id: 'turn-1', status: 'completed' } });
+ notify('turn/started', { threadId: 'other', turn: { id: 'other-turn' } });
+ await session.interrupt();
+ expect(client.turn.interrupt).not.toHaveBeenCalled();
+ session.close();
+ await Bun.sleep(0);
+ });
+
+ it('resumes the requested thread and forwards the selected model and prompt', async () => {
+ const session = await new CodexHeadlessProvider().spawn({
+ workingDirectory: '/workspace', resumeSessionId: threadId, model: 'selected-model', initialPrompt: 'continue',
+ });
+ expect(client.thread.resume).toHaveBeenCalledWith({
+ threadId, model: 'selected-model', cwd: '/workspace', approvalPolicy: 'never', sandbox: 'danger-full-access',
+ });
+ expect(client.turn.start).toHaveBeenCalledWith({ threadId, input: [{ type: 'text', text: 'continue' }] });
+ session.close();
+ await Bun.sleep(0);
+ });
+
+ it('reports nested turn failures and interruptions instead of success', () => {
+ const mapper = new CodexEventMapper();
+ expect(mapper.mapNotification({ method: 'turn/completed', params: {
+ threadId, turn: { id: 'turn-1', status: 'failed', error: { message: 'rate limit' } },
+ } }, threadId)).toMatchObject([{ type: 'error', content: 'rate limit' }]);
+ expect(mapper.mapNotification({ method: 'turn/completed', params: {
+ threadId, turn: { id: 'turn-2', status: 'interrupted', error: null },
+ } }, threadId)).toMatchObject([{ type: 'result', subtype: 'interrupted' }]);
+ });
+
+ it('isolates agent tool environments on a shared server, including resume', async () => {
+ const provider = new CodexHeadlessProvider();
+ const worker = await provider.spawn({
+ workingDirectory: '/worktrees/worker', stoneforgeRoot: '/project-a',
+ environmentVariables: { SF_ENTITY_ID: 'worker-a', PATH: '/custom/bin', STONEFORGE_ROOT: '/wrong' },
+ });
+ const steward = await provider.spawn({
+ workingDirectory: '/worktrees/steward', stoneforgeRoot: '/project-b', resumeSessionId: threadId,
+ environmentVariables: { SF_ENTITY_ID: 'steward-b' },
+ });
+ expect(client.thread.start).toHaveBeenCalledWith(expect.objectContaining({
+ config: { 'shell_environment_policy.set': {
+ SF_ENTITY_ID: 'worker-a', PATH: '/custom/bin', STONEFORGE_ROOT: '/project-a',
+ } },
+ }));
+ expect(client.thread.resume).toHaveBeenCalledWith(expect.objectContaining({
+ config: { 'shell_environment_policy.set': { SF_ENTITY_ID: 'steward-b', STONEFORGE_ROOT: '/project-b' } },
+ }));
+ worker.close();
+ steward.close();
+ await Bun.sleep(0);
+ });
+
+ it('distinguishes retry notifications from terminal errors', () => {
+ const mapper = new CodexEventMapper();
+ const params = { threadId, error: { message: 'connection lost' }, willRetry: true };
+ expect(mapper.mapNotification({ method: 'error', params }, threadId)).toEqual([]);
+ expect(mapper.mapNotification({ method: 'error', params: { ...params, willRetry: false } }, threadId))
+ .toMatchObject([{ type: 'error', content: 'connection lost' }]);
+ });
+
+ it('uses complete command output when only part of it was streamed', () => {
+ const mapper = new CodexEventMapper();
+ mapper.mapNotification({ method: 'item/commandExecution/outputDelta', params: {
+ threadId, itemId: 'cmd-1', delta: 'partial',
+ } }, threadId);
+ const messages = mapper.mapNotification({ method: 'item/completed', params: {
+ threadId, item: { type: 'commandExecution', id: 'cmd-1', aggregatedOutput: 'complete output', exitCode: 0 },
+ } }, threadId);
+ expect(messages.find(message => message.type === 'tool_result')?.content).toBe('complete output');
+ });
+});
diff --git a/packages/smithy/src/providers/codex/event-mapper.ts b/packages/smithy/src/providers/codex/event-mapper.ts
index 15ad4522..c39216e7 100644
--- a/packages/smithy/src/providers/codex/event-mapper.ts
+++ b/packages/smithy/src/providers/codex/event-mapper.ts
@@ -21,6 +21,7 @@ interface CodexCommandExecution {
command?: string;
cwd?: string;
stdout?: string;
+ aggregatedOutput?: string | null;
exitCode?: number;
status?: string;
}
@@ -51,6 +52,8 @@ type CodexItem = CodexCommandExecution | CodexFileChange | CodexMcpToolCall | Co
/** Codex notification params shape */
export interface CodexNotificationParams {
threadId?: string;
+ turn?: { id?: string; status?: string; error?: { message?: string } | null };
+ willRetry?: boolean;
item?: CodexItem;
delta?: string;
itemId?: string;
@@ -113,6 +116,10 @@ export class CodexEventMapper {
case 'item/fileChange/outputDelta':
return this.handleOutputDelta(notification);
+ case 'error':
+ // Retriable errors are intermediate server state, not task failures.
+ return notification.params?.willRetry ? [] : this.handleServerError(notification);
+
case 'codex/event/error':
return this.handleServerError(notification);
@@ -149,7 +156,8 @@ export class CodexEventMapper {
private handleServerError(notification: CodexNotification): AgentMessage[] {
const params = notification.params as Record | undefined;
const msg = params?.msg as Record | undefined;
- const errorMessage = (msg?.message as string)
+ const errorMessage = (params?.error ? this.extractErrorMessage(params.error) : undefined)
+ ?? (msg?.message as string)
?? (params?.message as string)
?? 'Unknown codex server error';
@@ -307,7 +315,7 @@ export class CodexEventMapper {
let content = '';
if (item.type === 'commandExecution') {
- content = buffered ?? item.stdout ?? `exit code: ${item.exitCode ?? 0}`;
+ content = item.aggregatedOutput ?? buffered ?? item.stdout ?? `exit code: ${item.exitCode ?? 0}`;
} else if (item.type === 'fileChange') {
content = buffered ?? item.content ?? '';
} else if (item.type === 'mcpToolCall') {
@@ -327,10 +335,10 @@ export class CodexEventMapper {
private handleTurnCompleted(notification: CodexNotification): AgentMessage[] {
const messages = this.flushPendingText();
- const status = notification.params?.status;
+ const status = notification.params?.turn?.status ?? notification.params?.status;
if (status === 'failed') {
- const errorMsg = this.extractErrorMessage(notification.params?.error);
+ const errorMsg = this.extractErrorMessage(notification.params?.turn?.error ?? notification.params?.error);
messages.push({
type: 'error',
content: errorMsg,
diff --git a/packages/smithy/src/providers/codex/headless.ts b/packages/smithy/src/providers/codex/headless.ts
index 0ed30bad..9d64b032 100644
--- a/packages/smithy/src/providers/codex/headless.ts
+++ b/packages/smithy/src/providers/codex/headless.ts
@@ -30,6 +30,9 @@ class CodexHeadlessSession implements HeadlessSession {
private messageQueue: AsyncQueue;
private unsubscribe: (() => void) | null;
private closed = false;
+ private activeTurnId: string | undefined;
+ private completedTurnId: string | undefined;
+ private pendingTurnStart: Promise | undefined;
constructor(client: CodexClient, threadId: string) {
this.client = client;
@@ -39,6 +42,14 @@ class CodexHeadlessSession implements HeadlessSession {
// Subscribe to notifications and filter/map by thread ID
this.unsubscribe = client.onNotification((method, params) => {
+ const turnParams = params as { threadId?: string; turn?: { id?: string } } | undefined;
+ if (turnParams?.threadId === this.threadId && turnParams.turn?.id) {
+ if (method === 'turn/started') this.activeTurnId = turnParams.turn.id;
+ if (method === 'turn/completed') {
+ this.completedTurnId = turnParams.turn.id;
+ if (this.activeTurnId === turnParams.turn.id) this.activeTurnId = undefined;
+ }
+ }
if (this.closed) return;
const notification = { method, params: params as any };
@@ -54,11 +65,17 @@ class CodexHeadlessSession implements HeadlessSession {
if (this.closed) return;
// Fire-and-forget: start a new turn on the thread
- this.client.turn
+ const pending = this.client.turn
.start({
threadId: this.threadId,
input: [{ type: 'text' as const, text: content }],
})
+ .then(({ turn }) => {
+ // Completion can arrive before the turn/start response.
+ if (turn.id !== this.completedTurnId && (!turn.status || turn.status === 'inProgress')) {
+ this.activeTurnId = turn.id;
+ }
+ })
.catch((error) => {
this.messageQueue.push({
type: 'error',
@@ -66,6 +83,10 @@ class CodexHeadlessSession implements HeadlessSession {
raw: error,
});
});
+ this.pendingTurnStart = pending;
+ void pending.finally(() => {
+ if (this.pendingTurnStart === pending) this.pendingTurnStart = undefined;
+ });
}
[Symbol.asyncIterator](): AsyncIterator {
@@ -73,7 +94,10 @@ class CodexHeadlessSession implements HeadlessSession {
}
async interrupt(): Promise {
- await this.client.turn.interrupt({ threadId: this.threadId });
+ await this.pendingTurnStart;
+ if (this.activeTurnId) {
+ await this.client.turn.interrupt({ threadId: this.threadId, turnId: this.activeTurnId });
+ }
}
close(): void {
@@ -84,6 +108,9 @@ class CodexHeadlessSession implements HeadlessSession {
// Without this, the agent continues executing even after close().
this.interrupt().catch(() => {
// Ignore errors — the turn may already be finished
+ }).finally(() => {
+ // Keep the shared server alive until cancellation has been acknowledged.
+ serverManager.release();
});
// Flush any remaining buffered text
@@ -94,7 +121,6 @@ class CodexHeadlessSession implements HeadlessSession {
this.messageQueue.close();
this.unsubscribe?.();
this.unsubscribe = null;
- serverManager.release();
}
/** Injects a synthetic system init message */
@@ -142,6 +168,15 @@ export class CodexHeadlessProvider implements HeadlessProvider {
});
let threadId: string;
+ // The app-server is shared, but tool environments belong to each thread.
+ // In particular SF_ENTITY_ID must never come from another agent's session.
+ const environment = {
+ ...options.environmentVariables,
+ ...(options.stoneforgeRoot ? { STONEFORGE_ROOT: options.stoneforgeRoot } : {}),
+ };
+ const threadConfig = Object.keys(environment).length
+ ? { config: { 'shell_environment_policy.set': environment } }
+ : {};
try {
if (options.resumeSessionId) {
@@ -152,6 +187,7 @@ export class CodexHeadlessProvider implements HeadlessProvider {
cwd: options.workingDirectory,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
+ ...threadConfig,
});
threadId = result.thread.id;
@@ -174,6 +210,7 @@ export class CodexHeadlessProvider implements HeadlessProvider {
cwd: options.workingDirectory,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
+ ...threadConfig,
});
if (!result?.thread?.id) {
diff --git a/packages/smithy/src/providers/codex/index.ts b/packages/smithy/src/providers/codex/index.ts
index 2774782c..0abb9386 100644
--- a/packages/smithy/src/providers/codex/index.ts
+++ b/packages/smithy/src/providers/codex/index.ts
@@ -10,7 +10,7 @@
import type { AgentProvider, HeadlessProvider, InteractiveProvider, ModelInfo } from '../types.js';
import { CodexHeadlessProvider } from './headless.js';
import { CodexInteractiveProvider } from './interactive.js';
-import { serverManager } from './server-manager.js';
+import { serverManager, type CodexModelInfo } from './server-manager.js';
export { CodexHeadlessProvider } from './headless.js';
export { CodexInteractiveProvider } from './interactive.js';
@@ -49,15 +49,22 @@ export class CodexAgentProvider implements AgentProvider {
// Acquire a temporary server client
const client = await serverManager.acquire();
try {
- const response = await client.model.list({ limit: 50 });
- // Handle both { models: [...] } and { data: [...] } response formats
- const rawModels = response.models ?? (response as { data?: typeof response.models }).data ?? [];
- // Map Codex model info to our ModelInfo type; mark the first model as default
+ const rawModels: CodexModelInfo[] = [];
+ let cursor: string | undefined;
+ do {
+ const response = await client.model.list({ limit: 50, ...(cursor ? { cursor } : {}) });
+ rawModels.push(...(response.data ?? response.models ?? []));
+ cursor = response.nextCursor ?? undefined;
+ } while (cursor);
+
+ // Modern servers supply the wire model ID, display name and default flag.
+ // Keep the legacy fallback for older app-server versions.
+ const hasDefaultMetadata = rawModels.some(model => typeof model.isDefault === 'boolean');
return rawModels.map((model, index) => ({
- id: model.id,
- displayName: model.name ?? model.id,
+ id: model.model ?? model.id,
+ displayName: model.displayName ?? model.name ?? model.id,
description: model.description,
- ...(index === 0 ? { isDefault: true } : {}),
+ ...((hasDefaultMetadata ? model.isDefault : index === 0) ? { isDefault: true } : {}),
}));
} finally {
serverManager.release();
diff --git a/packages/smithy/src/providers/codex/interactive.ts b/packages/smithy/src/providers/codex/interactive.ts
index f2d28c9a..d9ff68a3 100644
--- a/packages/smithy/src/providers/codex/interactive.ts
+++ b/packages/smithy/src/providers/codex/interactive.ts
@@ -21,6 +21,70 @@ import { shellQuote } from '../shell-quote.js';
// Helpers
// ============================================================================
+type CodexInteractiveArgOptions = Pick<
+ InteractiveSpawnOptions,
+ 'resumeSessionId' | 'workingDirectory' | 'model'
+>;
+
+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;
+const CODEX_CONTINUE_SESSION_PATTERN =
+ /To continue this session,\s+run\s+codex\s+resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
+const ANSI_ESCAPE_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
+const MAX_CODEX_OUTPUT_BUFFER = 4096;
+
+export function isCodexResumeSessionId(value: string): value is ProviderSessionId {
+ return CODEX_RESUME_SESSION_ID_PATTERN.test(value);
+}
+
+export function extractCodexResumeSessionId(output: string): ProviderSessionId | undefined {
+ const normalizedOutput = output.replace(ANSI_ESCAPE_PATTERN, '');
+ return normalizedOutput.match(CODEX_CONTINUE_SESSION_PATTERN)?.[1];
+}
+
+export function createCodexResumeSessionIdDetector(
+ maxBufferLength = MAX_CODEX_OUTPUT_BUFFER,
+): (chunk: string) => ProviderSessionId | undefined {
+ let buffer = '';
+
+ return (chunk: string) => {
+ buffer = (buffer + chunk).slice(-maxBufferLength);
+ return extractCodexResumeSessionId(buffer);
+ };
+}
+
+export function writeCodexExitCommand(
+ write: (data: string) => void,
+ scheduleEnter: (callback: () => void) => void = (callback) => {
+ setTimeout(callback, 50);
+ },
+): void {
+ write('/exit');
+ scheduleEnter(() => write('\r'));
+}
+
+export function buildCodexInteractiveArgs(
+ options: CodexInteractiveArgOptions,
+ platform: NodeJS.Platform = process.platform,
+): string[] {
+ const quote = (value: string) => shellQuote(value, platform);
+ const args: string[] = [];
+
+ if (options.resumeSessionId) {
+ if (!isCodexResumeSessionId(options.resumeSessionId)) {
+ throw new Error(`Invalid Codex resume session ID: ${options.resumeSessionId}`);
+ }
+ args.push('resume', quote(options.resumeSessionId), '--sandbox', 'workspace-write');
+ } else {
+ args.push('--sandbox', 'workspace-write', '--cd', quote(options.workingDirectory));
+ }
+
+ if (options.model) {
+ args.push('--model', quote(options.model));
+ }
+
+ return args;
+}
+
// ============================================================================
// Codex Interactive Session
// ============================================================================
@@ -28,14 +92,16 @@ import { shellQuote } from '../shell-quote.js';
class CodexInteractiveSession implements InteractiveSession {
private ptyProcess: IPty;
private sessionId: ProviderSessionId | undefined;
+ private readonly detectResumeSessionId = createCodexResumeSessionIdDetector();
readonly pid?: number;
- constructor(ptyProcess: IPty) {
+ constructor(ptyProcess: IPty, resumeSessionId?: ProviderSessionId) {
this.ptyProcess = ptyProcess;
this.pid = ptyProcess.pid;
+ this.sessionId = resumeSessionId;
- // Listen for thread/session ID in output and auto-respond to terminal queries.
+ // Listen for Codex's continuation footer and auto-respond to terminal queries.
// Codex sends DSR (Device Status Report) \x1b[6n on startup to query cursor
// position. When no terminal emulator (e.g. xterm.js) is connected to respond,
// codex times out and exits. We auto-respond with a default cursor position
@@ -46,9 +112,9 @@ class CodexInteractiveSession implements InteractiveSession {
}
if (!this.sessionId) {
- const match = data.match(/(?:Thread|Session|thr_)[:=\s]*([a-z0-9_-]+)/i);
- if (match) {
- this.sessionId = match[1];
+ const detectedSessionId = this.detectResumeSessionId(data);
+ if (detectedSessionId) {
+ this.sessionId = detectedSessionId;
}
}
});
@@ -58,6 +124,10 @@ class CodexInteractiveSession implements InteractiveSession {
this.ptyProcess.write(data);
}
+ requestExit(): void {
+ writeCodexExitCommand((data) => this.ptyProcess.write(data));
+ }
+
resize(cols: number, rows: number): void {
this.ptyProcess.resize(cols, rows);
}
@@ -95,7 +165,7 @@ export class CodexInteractiveProvider implements InteractiveProvider {
}
async spawn(options: InteractiveSpawnOptions): Promise {
- const args = this.buildArgs(options);
+ const args = buildCodexInteractiveArgs(options);
const env: Record = {
...(process.env as Record),
@@ -131,7 +201,10 @@ export class CodexInteractiveProvider implements InteractiveProvider {
env,
});
- const session = new CodexInteractiveSession(ptyProcess);
+ const resumeSessionId = options.resumeSessionId && isCodexResumeSessionId(options.resumeSessionId)
+ ? options.resumeSessionId
+ : undefined;
+ const session = new CodexInteractiveSession(ptyProcess, resumeSessionId);
// On Windows, write the command to cmd.exe stdin (bash -c handles this on Unix)
if (process.platform === 'win32') {
@@ -156,20 +229,4 @@ export class CodexInteractiveProvider implements InteractiveProvider {
}
}
- private buildArgs(options: InteractiveSpawnOptions): string[] {
- const args: string[] = [];
-
- if (options.resumeSessionId) {
- args.push('resume', shellQuote(options.resumeSessionId), '--full-auto');
- } else {
- args.push('--full-auto', '--cd', shellQuote(options.workingDirectory));
- }
-
- // Add model flag if provided
- if (options.model) {
- args.push('--model', shellQuote(options.model));
- }
-
- return args;
- }
}
diff --git a/packages/smithy/src/providers/codex/provider.bun.test.ts b/packages/smithy/src/providers/codex/provider.bun.test.ts
index 02ccf979..019842a2 100644
--- a/packages/smithy/src/providers/codex/provider.bun.test.ts
+++ b/packages/smithy/src/providers/codex/provider.bun.test.ts
@@ -6,6 +6,13 @@
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
import type { CodexClient, CodexModelInfo } from './server-manager.js';
+import {
+ buildCodexInteractiveArgs,
+ createCodexResumeSessionIdDetector,
+ extractCodexResumeSessionId,
+ isCodexResumeSessionId,
+ writeCodexExitCommand,
+} from './interactive.js';
import { posixShellQuote, shellQuote } from '../shell-quote.js';
// ---------------------------------------------------------------------------
@@ -55,7 +62,7 @@ function createMockClient(options?: {
read: mock(async () => ({ thread: { id: threadId } })),
},
turn: {
- start: mock(async () => {}),
+ start: mock(async () => ({ turn: { id: 'turn-test', status: 'inProgress' } })),
interrupt: mock(async () => {}),
},
onNotification(handler: NotificationHandler) {
@@ -204,74 +211,124 @@ describe('CodexHeadlessProvider model passthrough', () => {
// ---------------------------------------------------------------------------
describe('CodexInteractiveProvider model flag', () => {
- // Use the POSIX form directly so assertions are deterministic regardless of
- // the host OS the tests run on. A separate suite below covers the Windows
- // quoter.
- function buildArgs(options: {
- resumeSessionId?: string;
- workingDirectory: string;
- model?: string;
- }): string[] {
- const shellQuote = posixShellQuote;
- const args: string[] = [];
-
- if (options.resumeSessionId) {
- args.push('resume', shellQuote(options.resumeSessionId), '--full-auto');
- } else {
- args.push('--full-auto', '--cd', shellQuote(options.workingDirectory));
- }
-
- // Add model flag if provided
- if (options.model) {
- args.push('--model', shellQuote(options.model));
- }
-
- return args;
- }
+ const codexSessionId = '019e1277-6206-74e1-bb66-bbd8e9534628';
+
+ it('builds interactive spawn args with the documented workspace-write sandbox', () => {
+ const args = buildCodexInteractiveArgs({
+ workingDirectory: '/workspace',
+ model: 'gpt-4o',
+ }, 'linux');
+
+ expect(args).toEqual(['--sandbox', 'workspace-write', '--cd', "'/workspace'", '--model', "'gpt-4o'"]);
+ });
it('should include --model flag when model is provided', () => {
- const args = buildArgs({
+ const args = buildCodexInteractiveArgs({
workingDirectory: '/workspace',
model: 'gpt-4o',
- });
+ }, 'linux');
expect(args).toContain('--model');
expect(args).toContain("'gpt-4o'");
- expect(args).toEqual(['--full-auto', '--cd', "'/workspace'", '--model', "'gpt-4o'"]);
+ expect(args).toEqual(['--sandbox', 'workspace-write', '--cd', "'/workspace'", '--model', "'gpt-4o'"]);
});
it('should not include --model flag when model is undefined', () => {
- const args = buildArgs({
+ const args = buildCodexInteractiveArgs({
workingDirectory: '/workspace',
- });
+ }, 'linux');
expect(args).not.toContain('--model');
- expect(args).toEqual(['--full-auto', '--cd', "'/workspace'"]);
+ expect(args).toEqual(['--sandbox', 'workspace-write', '--cd', "'/workspace'"]);
});
it('should include --model flag when resuming with model', () => {
- const args = buildArgs({
- resumeSessionId: 'thr_abc123',
+ const args = buildCodexInteractiveArgs({
+ resumeSessionId: codexSessionId,
workingDirectory: '/workspace',
model: 'o3-mini',
- });
+ }, 'linux');
expect(args).toContain('resume');
expect(args).toContain('--model');
expect(args).toContain("'o3-mini'");
+ expect(args).toEqual(['resume', `'${codexSessionId}'`, '--sandbox', 'workspace-write', '--model', "'o3-mini'"]);
+ });
+
+ it('should reject non-UUID resume session IDs', () => {
+ expect(() => buildCodexInteractiveArgs({
+ resumeSessionId: 'thr_abc123',
+ workingDirectory: '/workspace',
+ }, 'linux')).toThrow('Invalid Codex resume session ID');
});
it('should properly quote model names with special characters', () => {
- const args = buildArgs({
+ const args = buildCodexInteractiveArgs({
workingDirectory: '/workspace',
model: "model's-name",
- });
+ }, 'linux');
expect(args).toContain("--model");
expect(args).toContain("'model'\\''s-name'");
});
});
+describe('Codex interactive resume session ID parsing', () => {
+ const codexSessionId = '019e1277-6206-74e1-bb66-bbd8e9534628';
+
+ it('extracts UUID from Codex continuation footer', () => {
+ expect(extractCodexResumeSessionId(
+ `Token usage: total=19,927\nTo continue this session, run codex resume ${codexSessionId}\n`,
+ )).toBe(codexSessionId);
+ });
+
+ it('ignores ordinary session management text', () => {
+ expect(extractCodexResumeSessionId('Agent Session Management')).toBeUndefined();
+ });
+
+ it('ignores Codex session-not-found errors', () => {
+ expect(extractCodexResumeSessionId(
+ 'No saved session found with ID management. Run `codex resume` without an ID to choose from existing sessions.',
+ )).toBeUndefined();
+ });
+
+ it('ignores non-UUID continuation footer values', () => {
+ expect(extractCodexResumeSessionId('To continue this session, run codex resume management')).toBeUndefined();
+ });
+
+ it('detects footer split across PTY chunks', () => {
+ const detect = createCodexResumeSessionIdDetector();
+
+ expect(detect('Token usage: total=19,927\nTo continue this session, ')).toBeUndefined();
+ expect(detect(`run codex resume ${codexSessionId}\n`)).toBe(codexSessionId);
+ });
+
+ it('validates Codex resume IDs as UUIDs', () => {
+ expect(isCodexResumeSessionId(codexSessionId)).toBe(true);
+ expect(isCodexResumeSessionId('management')).toBe(false);
+ expect(isCodexResumeSessionId('thr_abc123')).toBe(false);
+ });
+});
+
+describe('Codex interactive graceful exit', () => {
+ it('writes slash exit and schedules Enter as separate PTY writes', () => {
+ const writes: string[] = [];
+ const scheduled: (() => void)[] = [];
+
+ writeCodexExitCommand(
+ (data) => writes.push(data),
+ (callback) => scheduled.push(callback),
+ );
+
+ expect(writes).toEqual(['/exit']);
+ expect(scheduled).toHaveLength(1);
+
+ scheduled[0]();
+
+ expect(writes).toEqual(['/exit', '\r']);
+ });
+});
+
describe('CodexInteractiveProvider Windows quoting (issue #51)', () => {
it('quotes the executable with double quotes on win32, not single quotes', () => {
// Regression guard: on Windows, shellQuote must never wrap the command
diff --git a/packages/smithy/src/providers/codex/server-manager.bun.test.ts b/packages/smithy/src/providers/codex/server-manager.bun.test.ts
new file mode 100644
index 00000000..3f60413a
--- /dev/null
+++ b/packages/smithy/src/providers/codex/server-manager.bun.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
+import * as childProcess from 'node:child_process';
+import { EventEmitter } from 'node:events';
+import { PassThrough } from 'node:stream';
+import { serverManager } from './server-manager.js';
+
+function fakeServer(failInitialize: boolean) {
+ const child = Object.assign(new EventEmitter(), {
+ stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(),
+ kill: mock(() => true),
+ });
+ child.stdin.on('data', data => {
+ const request = JSON.parse(data.toString());
+ if (request.id === undefined) return;
+ const response = request.method === 'initialize' && failInitialize
+ ? { id: request.id, error: { message: 'unsupported protocol' } }
+ : { id: request.id, result: request.method === 'model/list' ? { data: [] } : {} };
+ child.stdout.write(JSON.stringify(response) + '\n');
+ });
+ queueMicrotask(() => child.emit('spawn'));
+ return child;
+}
+
+describe('Codex shared server lifecycle', () => {
+ let spawn: ReturnType;
+ let children: ReturnType[];
+ let failInitialize: boolean;
+
+ beforeEach(() => {
+ serverManager.shutdown();
+ children = [];
+ failInitialize = false;
+ spawn = spyOn(childProcess, 'spawn').mockImplementation(() => {
+ const child = fakeServer(failInitialize);
+ children.push(child);
+ return child as unknown as childProcess.ChildProcess;
+ });
+ });
+ afterEach(() => { serverManager.shutdown(); spawn.mockRestore(); });
+
+ it('shares startup and stops only after the last session releases it', async () => {
+ const [first, second] = await Promise.all([serverManager.acquire(), serverManager.acquire()]);
+ expect(first).toBe(second);
+ expect(children).toHaveLength(1);
+ serverManager.release();
+ expect(children[0].kill).not.toHaveBeenCalled();
+ serverManager.release();
+ expect(children[0].kill).toHaveBeenCalledTimes(1);
+ });
+
+ it('cleans up failed initialization and both concurrent references', async () => {
+ failInitialize = true;
+ const results = await Promise.allSettled([serverManager.acquire(), serverManager.acquire()]);
+ expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']);
+ expect(children[0].kill).toHaveBeenCalledTimes(1);
+ failInitialize = false;
+ await serverManager.acquire();
+ serverManager.release();
+ expect(children[1].kill).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not lose a new server when the previous process exits late', async () => {
+ await serverManager.acquire();
+ serverManager.release();
+ const current = await serverManager.acquire();
+ children[0].emit('exit', 0);
+ expect(await serverManager.acquire()).toBe(current);
+ expect(children).toHaveLength(2);
+ expect(await current.model.list()).toEqual({ data: [] });
+ serverManager.release();
+ serverManager.release();
+ expect(children[1].kill).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/smithy/src/providers/codex/server-manager.ts b/packages/smithy/src/providers/codex/server-manager.ts
index d5423ba3..e6026fa0 100644
--- a/packages/smithy/src/providers/codex/server-manager.ts
+++ b/packages/smithy/src/providers/codex/server-manager.ts
@@ -22,14 +22,23 @@ import type { NotificationHandler } from './jsonrpc-client.js';
/** Model information returned by the Codex app-server */
export interface CodexModelInfo {
id: string;
+ model?: string;
+ displayName?: string;
+ isDefault?: boolean;
name?: string;
description?: string;
}
+export interface CodexModelList {
+ models?: CodexModelInfo[];
+ data?: CodexModelInfo[];
+ nextCursor?: string | null;
+}
+
/** Typed facade over the JSON-RPC client for Codex app-server operations */
export interface CodexClient {
model: {
- list(params?: { limit?: number }): Promise<{ models?: CodexModelInfo[]; data?: CodexModelInfo[] }>;
+ list(params?: { limit?: number; cursor?: string }): Promise;
};
thread: {
start(params: {
@@ -37,6 +46,7 @@ export interface CodexClient {
cwd?: string;
approvalPolicy?: string;
sandbox?: string;
+ config?: Record;
}): Promise<{ thread: { id: string } }>;
resume(params: {
threadId: string;
@@ -44,6 +54,7 @@ export interface CodexClient {
cwd?: string;
approvalPolicy?: string;
sandbox?: string;
+ config?: Record;
}): Promise<{ thread: { id: string } }>;
read(params: { threadId: string }): Promise<{ thread: { id: string } }>;
};
@@ -51,8 +62,8 @@ export interface CodexClient {
start(params: {
threadId: string;
input: Array<{ type: 'text'; text: string }>;
- }): Promise;
- interrupt(params: { threadId: string }): Promise;
+ }): Promise<{ turn: { id: string; status?: string } }>;
+ interrupt(params: { threadId: string; turnId: string }): Promise;
};
onNotification(handler: NotificationHandler): () => void;
respondToServer(id: number, result: unknown): void;
@@ -89,20 +100,15 @@ class CodexServerManager {
return this.client;
}
- // Coalesce concurrent startup requests
- if (this.startPromise) {
- return this.startPromise;
- }
-
- this.startPromise = this.startServer(config);
-
+ // Each caller owns a reference, including callers sharing startup.
+ const startup = this.startPromise ??= this.startServer(config);
try {
- const client = await this.startPromise;
- return client;
+ return await startup;
} catch (error) {
- this.refCount--;
- this.startPromise = null;
+ this.refCount = Math.max(0, this.refCount - 1);
throw error;
+ } finally {
+ if (this.startPromise === startup) this.startPromise = null;
}
}
@@ -139,7 +145,7 @@ class CodexServerManager {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: config?.cwd,
env,
- shell: true, // Required for Windows: resolves codex.cmd/.bat shims via cmd.exe
+ shell: process.platform === 'win32', // Resolve .cmd/.bat shims only on Windows.
});
// Wait for the process to confirm stdio is ready, or fail with a
@@ -159,6 +165,8 @@ class CodexServerManager {
}
const rpcClient = new CodexJsonRpcClient(child.stdin, child.stdout);
+ this.process = child;
+ this.rpcClient = rpcClient;
// Pipe stderr to debug logging
child.stderr?.on('data', () => {
@@ -168,16 +176,29 @@ class CodexServerManager {
// Monitor process exit
child.on('exit', () => {
rpcClient.close();
- this.process = null;
- this.rpcClient = null;
- this.client = null;
- this.startPromise = null;
+ // An old server may exit after a new one has already been acquired.
+ if (this.process === child) {
+ this.process = null;
+ this.rpcClient = null;
+ this.client = null;
+ this.startPromise = null;
+ }
});
// Send initialize handshake
- await rpcClient.request('initialize', {
- clientInfo: { name: 'stoneforge', title: 'Stoneforge', version: '0.1.0' },
- });
+ try {
+ await rpcClient.request('initialize', {
+ clientInfo: { name: 'stoneforge', title: 'Stoneforge', version: '0.1.0' },
+ });
+ } catch (error) {
+ rpcClient.close();
+ child.kill();
+ if (this.process === child) {
+ this.process = null;
+ this.rpcClient = null;
+ }
+ throw error;
+ }
// Send initialized notification
rpcClient.notify('initialized');
@@ -189,9 +210,6 @@ class CodexServerManager {
}
});
- this.process = child;
- this.rpcClient = rpcClient;
-
// Build typed client facade
const notificationHandlers = new Set();
@@ -203,7 +221,7 @@ class CodexServerManager {
const client: CodexClient = {
model: {
- list: (params) => rpcClient.request('model/list', params ?? {}) as Promise<{ models?: CodexModelInfo[]; data?: CodexModelInfo[] }>,
+ list: (params) => rpcClient.request('model/list', params ?? {}) as Promise,
},
thread: {
start: (params) => rpcClient.request('thread/start', params) as Promise<{ thread: { id: string } }>,
@@ -211,7 +229,7 @@ class CodexServerManager {
read: (params) => rpcClient.request('thread/read', params) as Promise<{ thread: { id: string } }>,
},
turn: {
- start: (params) => rpcClient.request('turn/start', params) as Promise,
+ start: (params) => rpcClient.request('turn/start', params) as ReturnType,
interrupt: (params) => rpcClient.request('turn/interrupt', params) as Promise,
},
onNotification: (handler) => {
@@ -223,7 +241,6 @@ class CodexServerManager {
};
this.client = client;
- this.startPromise = null;
return client;
}
diff --git a/packages/smithy/src/providers/types.ts b/packages/smithy/src/providers/types.ts
index 5c228d34..3ac9ffab 100644
--- a/packages/smithy/src/providers/types.ts
+++ b/packages/smithy/src/providers/types.ts
@@ -124,6 +124,8 @@ export interface InteractiveSession {
readonly pid?: number;
/** Write data to the PTY */
write(data: string): void;
+ /** Request a graceful provider-specific exit, if different from shell exit */
+ requestExit?(): void;
/** Resize the terminal */
resize(cols: number, rows: number): void;
/** Kill the PTY */
diff --git a/packages/smithy/src/runtime/session-manager.bun.test.ts b/packages/smithy/src/runtime/session-manager.bun.test.ts
index 1210504d..5d6c22b4 100644
--- a/packages/smithy/src/runtime/session-manager.bun.test.ts
+++ b/packages/smithy/src/runtime/session-manager.bun.test.ts
@@ -531,6 +531,29 @@ describe('SessionManager', () => {
});
describe('stopSession', () => {
+ test('preserves the resume ID discovered during graceful shutdown in session history', async () => {
+ const originalSpawn = spawner.spawn.bind(spawner);
+ spawner.spawn = async (...args) => {
+ const result = await originalSpawn(...args);
+ return { ...result, session: { ...result.session, providerSessionId: undefined } };
+ };
+ const originalTerminate = spawner.terminate.bind(spawner);
+ const resumeId = '019e1277-6206-74e1-bb66-bbd8e9534628';
+ spawner.terminate = async (id, graceful) => {
+ spawner._mockEmitters.get(id)?.emit('provider-session-id', resumeId);
+ await originalTerminate(id, graceful);
+ };
+ const { session } = await sessionManager.startSession(testAgentId, { mode: 'interactive' });
+ expect(session.providerSessionId).toBeUndefined();
+ await sessionManager.stopSession(session.id);
+ const history = await sessionManager.getSessionHistory(testAgentId);
+ expect(history).toHaveLength(1);
+ expect(history[0].providerSessionId).toBe(resumeId);
+ expect(history[0].status).toBe('terminated');
+ const agent = await registry.getAgent(testAgentId);
+ expect((agent?.metadata?.agent as AgentMetadata).sessionStatus).toBe('idle');
+ });
+
test('stops a running session', async () => {
const { session } = await sessionManager.startSession(testAgentId);
diff --git a/packages/smithy/src/runtime/session-manager.ts b/packages/smithy/src/runtime/session-manager.ts
index bc235466..378e4d5c 100644
--- a/packages/smithy/src/runtime/session-manager.ts
+++ b/packages/smithy/src/runtime/session-manager.ts
@@ -746,9 +746,6 @@ export class SessionManagerImpl implements SessionManager {
throw new Error(`Session not found: ${sessionId}`);
}
- // Clean up event listeners to prevent leaks
- this.cleanupSessionEventListeners(sessionId);
-
// Update session state BEFORE terminating to prevent race with exit event handler
const updatedSession: InternalSessionState = {
...session,
@@ -764,12 +761,19 @@ export class SessionManagerImpl implements SessionManager {
this.agentSessions.delete(session.agentId);
}
- // Add to history (do this before terminate to avoid race with exit handler)
- this.addToHistory(session.agentId, updatedSession);
-
- // Now terminate via spawner (may trigger exit event, but status already terminated)
+ // Now terminate via spawner (may trigger exit/provider-session-id events, but status already terminated).
+ // Keep listeners attached until this completes so provider-specific graceful shutdown output
+ // can still update providerSessionId before the session is added to history.
await this.spawner.terminate(sessionId, options?.graceful ?? true);
+ const finalSession = this.sessions.get(sessionId) ?? updatedSession;
+
+ // Clean up event listeners after graceful termination output has been observed.
+ this.cleanupSessionEventListeners(sessionId);
+
+ // Add to history after terminate so providerSessionId captured during shutdown is included.
+ this.addToHistory(session.agentId, finalSession);
+
// Update agent's session status in database
await this.registry.updateAgentSession(session.agentId, undefined, 'idle');
@@ -780,7 +784,7 @@ export class SessionManagerImpl implements SessionManager {
this.scheduleTerminatedSessionCleanup(sessionId);
// Emit status change
- session.events.emit('status', 'terminated');
+ finalSession.events.emit('status', 'terminated');
// Log session termination
this.operationLog?.write('info', 'session', `Session terminated for agent ${session.agentId}${options?.reason ? `: ${options.reason}` : ''}`, { agentId: session.agentId, sessionId });
@@ -1539,9 +1543,13 @@ export class SessionManagerImpl implements SessionManager {
persisted: false,
});
// Persist session so providerSessionId survives a server restart
- this.registry.updateAgentSession(session.agentId, providerSessionId, 'running').catch((err) => {
- console.error(`[session-manager] Failed to persist providerSessionId for ${session.id}:`, err);
- });
+ // Codex can reveal its resume ID during graceful shutdown. Do not mark
+ // an already stopped agent as running while recording that final ID.
+ if (current.status === 'running' || current.status === 'starting') {
+ this.registry.updateAgentSession(session.agentId, providerSessionId, 'running').catch((err) => {
+ console.error(`[session-manager] Failed to persist providerSessionId for ${session.id}:`, err);
+ });
+ }
this.persistSession(session.id).catch((err) => {
console.error(`[session-manager] Failed to persist session after providerSessionId for ${session.id}:`, err);
});
diff --git a/packages/smithy/src/runtime/spawner.bun.test.ts b/packages/smithy/src/runtime/spawner.bun.test.ts
index a923c4f4..d96585e4 100644
--- a/packages/smithy/src/runtime/spawner.bun.test.ts
+++ b/packages/smithy/src/runtime/spawner.bun.test.ts
@@ -11,6 +11,7 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { EventEmitter } from 'node:events';
+import { AsyncQueue } from '../providers/opencode/async-queue.js';
import type { EntityId, Timestamp } from '@stoneforge/core';
import { createTimestamp } from '@stoneforge/core';
import {
@@ -97,11 +98,93 @@ describe('SpawnerService', () => {
});
describe('terminate', () => {
+ test('closes failed headless sessions and reports a non-zero exit', async () => {
+ const queue = new AsyncQueue();
+ let closes = 0;
+ const provider: AgentProvider = {
+ name: 'failed-turn-provider',
+ headless: createMockHeadlessProvider(() => ({
+ sendMessage() {},
+ async interrupt() {},
+ close() { closes++; queue.close(); },
+ [Symbol.asyncIterator]: () => queue[Symbol.asyncIterator](),
+ })),
+ interactive: { name: 'unused', async spawn() { throw new Error('unused'); }, async isAvailable() { return true; } },
+ async isAvailable() { return true; },
+ getInstallInstructions() { return ''; },
+ async listModels() { return []; },
+ };
+ queue.push({ type: 'system', subtype: 'init', sessionId: 'provider-session', raw: {} });
+ const service = new SpawnerServiceImpl({ provider, workingDirectory: '/tmp', timeout: 1000 });
+ const { session, events } = await service.spawn(testAgentId, 'worker', { mode: 'headless' });
+ const exited = new Promise(resolve => events.once('exit', resolve));
+ queue.push({ type: 'error', content: 'Codex turn failed', raw: {} });
+ expect(await exited).toBe(1);
+ expect(closes).toBe(1);
+ expect(service.getSession(session.id)?.status).toBe('terminated');
+ expect(service.listActiveSessions()).toHaveLength(0);
+ });
+
test('throws error for non-existent session', async () => {
await expect(spawnerService.terminate('nonexistent-session')).rejects.toThrow(
'Session not found'
);
});
+
+ test('uses provider-specific requestExit for graceful interactive termination', async () => {
+ let exitCallback: ((code: number, signal?: number) => void) | undefined;
+ const writes: string[] = [];
+
+ const interactiveProvider: InteractiveProvider = {
+ name: 'mock-interactive',
+ spawn: async (): Promise => ({
+ pid: 12345,
+ write: (data: string) => {
+ writes.push(data);
+ },
+ requestExit: () => {
+ writes.push('requestExit');
+ exitCallback?.(0);
+ },
+ resize: () => {},
+ kill: () => {
+ writes.push('kill');
+ },
+ onData: () => {},
+ onExit: (callback: (code: number, signal?: number) => void) => {
+ exitCallback = callback;
+ },
+ getSessionId: () => undefined,
+ }),
+ isAvailable: async () => true,
+ };
+
+ const provider: AgentProvider = {
+ name: 'mock-provider',
+ headless: createMockHeadlessProvider(createImmediateExitHeadlessSession),
+ interactive: interactiveProvider,
+ isAvailable: async () => true,
+ getInstallInstructions: () => 'No install needed for mock',
+ listModels: async (): Promise => [],
+ };
+
+ const service = new SpawnerServiceImpl({
+ provider,
+ workingDirectory: '/tmp',
+ timeout: 1000,
+ });
+
+ const result = await service.spawn(testAgentId, 'director', {
+ mode: 'interactive',
+ });
+
+ await service.terminate(result.session.id, true);
+
+ expect(writes).toContain('requestExit');
+ expect(writes).not.toContain('exit\r');
+ expect(writes).not.toContain('kill');
+ expect(service.getSession(result.session.id)?.status).toBe('terminated');
+ });
});
describe('suspend', () => {
diff --git a/packages/smithy/src/runtime/spawner.ts b/packages/smithy/src/runtime/spawner.ts
index 891c840c..53b163b6 100644
--- a/packages/smithy/src/runtime/spawner.ts
+++ b/packages/smithy/src/runtime/spawner.ts
@@ -584,7 +584,11 @@ export class SpawnerServiceImpl implements SpawnerService {
// Handle provider interactive sessions
if (session.interactiveSession) {
if (graceful) {
- session.interactiveSession.write('exit\r');
+ if (session.interactiveSession.requestExit) {
+ session.interactiveSession.requestExit();
+ } else {
+ session.interactiveSession.write('exit\r');
+ }
await new Promise((resolve) => {
const timeout = setTimeout(() => {
@@ -968,6 +972,7 @@ export class SpawnerServiceImpl implements SpawnerService {
headlessSession: HeadlessSession
): Promise {
let resumeErrorDetected = false;
+ let providerErrorDetected = false;
try {
for await (const message of headlessSession) {
if (session.status === 'terminated') {
@@ -1010,6 +1015,15 @@ export class SpawnerServiceImpl implements SpawnerService {
}
}
+ // Terminal provider errors (including failed Codex turns) must release
+ // the session. The provider's streaming queue can otherwise stay open
+ // forever, leaving the worker occupied after a failed request.
+ if (message.type === 'error') {
+ providerErrorDetected = true;
+ headlessSession.close();
+ break;
+ }
+
// Detect agent completion: when we receive a 'result' message that is
// NOT an error, the agent has finished its work. For headless sessions
// using streaming input mode (stewards, ephemeral workers), the SDK
@@ -1036,7 +1050,7 @@ export class SpawnerServiceImpl implements SpawnerService {
if (!session.endedAt) {
session.endedAt = createTimestamp();
}
- session.events.emit('exit', resumeErrorDetected ? 1 : 0, null);
+ session.events.emit('exit', resumeErrorDetected || providerErrorDetected ? 1 : 0, null);
if (session.status === 'terminated') {
this.scheduleTerminatedSessionCleanup(session.id);
}
diff --git a/packages/smithy/src/server/routes/sessions.ts b/packages/smithy/src/server/routes/sessions.ts
index 68cf017a..30df1a30 100644
--- a/packages/smithy/src/server/routes/sessions.ts
+++ b/packages/smithy/src/server/routes/sessions.ts
@@ -21,6 +21,7 @@ import { notifySSEClientsOfNewSession } from './events.js';
import { createLogger } from '../../utils/logger.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,
@@ -605,6 +606,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,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d02c469e..ea8294fe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -649,8 +649,8 @@ importers:
packages/smithy:
dependencies:
'@anthropic-ai/claude-agent-sdk':
- specifier: ^0.2.45
- version: 0.2.45(zod@4.3.6)
+ specifier: ^0.3.282
+ version: 0.3.282(@anthropic-ai/sdk@0.128.0)(@modelcontextprotocol/sdk@1.30.1)(zod@4.3.6)
'@stoneforge/core':
specifier: workspace:*
version: link:../core
@@ -826,22 +826,104 @@ packages:
engines: {node: '>=10'}
dev: true
- /@anthropic-ai/claude-agent-sdk@0.2.45(zod@4.3.6):
- resolution: {integrity: sha512-AKH2hKoJNyjLf9ThAttKqbmCjUFg7qs/8+LR/UTVX20fCLn359YH9WrQc6dAiAfi8RYNA+mWwrNYCAq+Sdo5Ag==}
+ /@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.282:
+ resolution: {integrity: sha512-NwvIIpEOORp16K7NgjWE2lt2Y+VmddvkkRoOpyKXz2H2bRixa5ToV+byp2gUaKVsCixBuAiQ2lL9e79gy5lbNQ==}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.282:
+ resolution: {integrity: sha512-MjVmeojCOIAdqt90j1B9ENfCwLIzMhSyzQJd6+rBn8KDKklduPLbs//rVhvIwR+MJBF67PDkczOb4VXiYPJRxA==}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.282:
+ resolution: {integrity: sha512-2/3EtJKfNQfjn1MtTRh7EpcqlAI/UCEaiHSdlPH+AZPDnQXiHLW6JITJNdsc3MMuwjnbsiL4NJBnDr35qkIb9A==}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.282:
+ resolution: {integrity: sha512-hL/G7pCQnS9m8RIqxCpljaQsvITKRYdqoNeDsJGIqaFpd79redTwlovRUBtFMeDAtASwtha1wLyh24v+gUb58A==}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.282:
+ resolution: {integrity: sha512-Q96hallUSHCCvmsMC08aO3YtivTbRIzzrgfdVjJAcOrQ+6tSwN0Ni4yMEwhWRnq3DVZlNDYIssd1/k7H4lS7Og==}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /@anthropic-ai/claude-agent-sdk-linux-x64@0.3.282:
+ resolution: {integrity: sha512-NxinW4k1Xh3xTCPKpsw+XXV+O3zwLtLQeJnFucc/LKQQ3p4pXJ7/hW/DIiYdmhgIIaMFkP+2xP8G5+Y8swxB2w==}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.282:
+ resolution: {integrity: sha512-4v9guW9NSDJj+cTP+aND3Wz6gAFV5OU/VP4CFjc79KgdwPpAtwm2fw5BKhneAuoUc21KU9gHWBtLLOdBvONoZw==}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /@anthropic-ai/claude-agent-sdk-win32-x64@0.3.282:
+ resolution: {integrity: sha512-oOMNLTcLGqPBHvrTVRXNL6q8LK7DL3blkuBBrAgsmkgr+pxi0E74eQEk7XQ5dYwhETa2/ZfNIwNevcYB7LAVtg==}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /@anthropic-ai/claude-agent-sdk@0.3.282(@anthropic-ai/sdk@0.128.0)(@modelcontextprotocol/sdk@1.30.1)(zod@4.3.6):
+ resolution: {integrity: sha512-6UAerS1udzndLEx+0XW3gQWiICgfu/a+2fx/aLY3gUy+1JUQESbwYkhR40+D6d+yjueCslkLmvkPlZQFZDph6A==}
engines: {node: '>=18.0.0'}
peerDependencies:
+ '@anthropic-ai/sdk': '>=0.93.0'
+ '@modelcontextprotocol/sdk': ^1.29.0
zod: ^4.0.0
dependencies:
+ '@anthropic-ai/sdk': 0.128.0(zod@4.3.6)
+ '@modelcontextprotocol/sdk': 1.30.1(zod@4.3.6)
zod: 4.3.6
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.33.5
- '@img/sharp-darwin-x64': 0.33.5
- '@img/sharp-linux-arm': 0.33.5
- '@img/sharp-linux-arm64': 0.33.5
- '@img/sharp-linux-x64': 0.33.5
- '@img/sharp-linuxmusl-arm64': 0.33.5
- '@img/sharp-linuxmusl-x64': 0.33.5
- '@img/sharp-win32-x64': 0.33.5
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.282
+ '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.282
+ '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.282
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.282
+ '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.282
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.282
+ '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.282
+ '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.282
+ dev: false
+
+ /@anthropic-ai/sdk@0.128.0(zod@4.3.6):
+ resolution: {integrity: sha512-tl5cBFZC1jVFrTuQyvQObA4WmuNgcYVeXfriqOumgtLAHUm4gPUj18YnszBW60v0PrjDB8nBDOC6wd9CmHQFlA==}
+ hasBin: true
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+ dependencies:
+ json-schema-to-ts: 3.1.1
+ standardwebhooks: 1.1.1
+ zod: 4.3.6
dev: false
/@astrojs/compiler@2.13.1:
@@ -2981,6 +3063,37 @@ packages:
/@mixmark-io/domino@2.2.0:
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
+ /@modelcontextprotocol/sdk@1.30.1(zod@4.3.6):
+ resolution: {integrity: sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+ dependencies:
+ '@hono/node-server': 1.19.9(hono@4.11.6)
+ ajv: 8.20.0
+ ajv-formats: 3.0.1(ajv@8.20.0)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.1.1
+ express: 5.2.1
+ express-rate-limit: 8.7.0(express@5.2.1)
+ hono: 4.11.6
+ jose: 6.2.12
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 4.3.6
+ zod-to-json-schema: 3.25.1(zod@4.3.6)
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -4750,6 +4863,10 @@ packages:
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
dev: false
+ /@stablelib/base64@1.0.1:
+ resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
+ dev: false
+
/@standard-schema/spec@1.1.0:
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -6028,7 +6145,7 @@ packages:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
- vite: 6.4.1(tsx@4.21.0)
+ vite: 6.4.1(@types/node@20.19.30)
dev: true
/@vitest/pretty-format@4.0.18:
@@ -6113,6 +6230,14 @@ packages:
d3-zoom: 3.0.0
dev: false
+ /accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.1.0
+ dev: false
+
/acorn-jsx@5.3.2(acorn@8.15.0):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -6138,6 +6263,26 @@ packages:
hasBin: true
dev: false
+ /ajv-formats@3.0.1(ajv@8.20.0):
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+ dependencies:
+ ajv: 8.20.0
+ dev: false
+
+ /ajv@8.20.0:
+ resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.8
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+ dev: false
+
/ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
dependencies:
@@ -6447,6 +6592,23 @@ packages:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
dev: true
+ /body-parser@2.3.0:
+ resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
+ engines: {node: '>=18'}
+ dependencies:
+ bytes: 3.1.2
+ content-type: 2.1.0
+ debug: 4.4.3
+ http-errors: 2.0.1
+ iconv-lite: 0.7.2
+ on-finished: 2.4.1
+ qs: 6.16.0
+ raw-body: 3.0.2
+ type-is: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -6510,6 +6672,27 @@ packages:
'@types/node': 20.19.30
dev: true
+ /bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
+ /call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ dev: false
+
+ /call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+ dev: false
+
/camelcase@8.0.0:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
@@ -6654,6 +6837,21 @@ packages:
resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==}
dev: false
+ /content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+ engines: {node: '>=18'}
+ dev: false
+
+ /content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+ dev: false
+
+ /content-type@2.1.0:
+ resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
+ engines: {node: '>=18'}
+ dev: false
+
/convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
dev: true
@@ -6665,10 +6863,14 @@ packages:
/cookie-es@2.0.0:
resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
+ /cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+ dev: false
+
/cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
- dev: true
/cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
@@ -6684,6 +6886,14 @@ packages:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
dev: false
+ /cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+ dev: false
+
/crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@@ -6694,7 +6904,6 @@ packages:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
- dev: true
/crossws@0.3.5:
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
@@ -6916,6 +7125,11 @@ packages:
/defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
+ /depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
/dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -7017,6 +7231,19 @@ packages:
engines: {node: '>=4'}
dev: false
+ /dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+ dev: false
+
+ /ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+ dev: false
+
/electron-to-chromium@1.5.279:
resolution: {integrity: sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==}
dev: true
@@ -7043,6 +7270,11 @@ packages:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: false
+ /encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
/end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
requiresBuild: true
@@ -7076,9 +7308,26 @@ packages:
engines: {node: '>=0.12'}
dev: false
+ /es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+ dev: false
+
+ /es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+ dev: false
+
/es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ /es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ es-errors: 1.3.0
+ dev: false
+
/es-toolkit@1.44.0:
resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==}
dev: false
@@ -7237,6 +7486,10 @@ packages:
engines: {node: '>=6'}
dev: true
+ /escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+ dev: false
+
/escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -7305,6 +7558,11 @@ packages:
dependencies:
'@types/estree': 1.0.8
+ /etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+ dev: false
+
/eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
dev: false
@@ -7313,6 +7571,18 @@ packages:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
dev: false
+ /eventsource-parser@3.1.1:
+ resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
+ engines: {node: '>=18.0.0'}
+ dev: false
+
+ /eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+ dependencies:
+ eventsource-parser: 3.1.1
+ dev: false
+
/exit-hook@2.2.1:
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
engines: {node: '>=6'}
@@ -7330,6 +7600,55 @@ packages:
engines: {node: '>=12.0.0'}
dev: true
+ /express-rate-limit@8.7.0(express@5.2.1):
+ resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+ dependencies:
+ debug: 4.4.3
+ express: 5.2.1
+ ip-address: 10.7.2
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
+ /express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.3.0
+ content-disposition: 1.1.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.8
+ qs: 6.16.0
+ range-parser: 1.3.0
+ router: 2.2.0
+ send: 1.2.1
+ serve-static: 2.2.1
+ statuses: 2.0.2
+ type-is: 2.1.0
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/expressive-code@0.40.2:
resolution: {integrity: sha512-1zIda2rB0qiDZACawzw2rbdBQiWHBT56uBctS+ezFe5XMAaFaHLnnSYND/Kd+dVzO9HfCXRDpzH3d+3fvOWRcw==}
dependencies:
@@ -7370,6 +7689,14 @@ packages:
micromatch: 4.0.8
dev: true
+ /fast-sha256@1.3.0:
+ resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
+ dev: false
+
+ /fast-uri@3.1.8:
+ resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
+ dev: false
+
/fast-xml-builder@1.0.0:
resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==}
dev: false
@@ -7412,6 +7739,20 @@ packages:
to-regex-range: 5.0.1
dev: true
+ /finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
@@ -7442,6 +7783,11 @@ packages:
tiny-inflate: 1.0.3
dev: false
+ /forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+ dev: false
+
/fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
dev: true
@@ -7451,6 +7797,11 @@ packages:
engines: {node: ^14.13.1 || >=16.0.0}
dev: false
+ /fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
/fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
requiresBuild: true
@@ -7490,6 +7841,10 @@ packages:
requiresBuild: true
optional: true
+ /function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+ dev: false
+
/gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -7500,11 +7855,35 @@ packages:
engines: {node: '>=18'}
dev: false
+ /get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+ dev: false
+
/get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
dev: false
+ /get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+ dev: false
+
/get-source@2.0.12:
resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==}
dependencies:
@@ -7551,6 +7930,11 @@ packages:
slash: 3.0.0
dev: true
+ /gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+ dev: false
+
/graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
dev: true
@@ -7575,6 +7959,18 @@ packages:
uncrypto: 0.1.3
dev: false
+ /has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+ dev: false
+
+ /hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ function-bind: 1.1.2
+ dev: false
+
/hast-util-embedded@3.0.0:
resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==}
dependencies:
@@ -7844,6 +8240,17 @@ packages:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
dev: false
+ /http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+ dev: false
+
/human-id@4.1.3:
resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==}
hasBin: true
@@ -7860,7 +8267,6 @@ packages:
engines: {node: '>=0.10.0'}
dependencies:
safer-buffer: 2.1.2
- dev: true
/idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -7917,6 +8323,16 @@ packages:
engines: {node: '>=12'}
dev: false
+ /ip-address@10.7.2:
+ resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==}
+ engines: {node: '>= 12'}
+ dev: false
+
+ /ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+ dev: false
+
/iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
dev: false
@@ -7990,6 +8406,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
+ /is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+ dev: false
+
/is-subdir@1.2.0:
resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==}
engines: {node: '>=4'}
@@ -8019,7 +8439,6 @@ packages:
/isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
- dev: true
/jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
@@ -8033,6 +8452,10 @@ packages:
fractional-indexing: 3.2.0
dev: false
+ /jose@6.2.12:
+ resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
+ dev: false
+
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -8056,6 +8479,22 @@ packages:
hasBin: true
dev: true
+ /json-schema-to-ts@3.1.1:
+ resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
+ engines: {node: '>=16'}
+ dependencies:
+ '@babel/runtime': 7.28.6
+ ts-algebra: 2.0.0
+ dev: false
+
+ /json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ dev: false
+
+ /json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+ dev: false
+
/json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -8341,6 +8780,11 @@ packages:
engines: {node: '>= 20'}
hasBin: true
+ /math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+ dev: false
+
/mdast-util-definitions@6.0.0:
resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
dependencies:
@@ -8571,10 +9015,20 @@ packages:
/mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+ /media-typer@1.1.1:
+ resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
/memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
dev: false
+ /merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+ dev: false
+
/merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -8934,6 +9388,18 @@ packages:
picomatch: 2.3.1
dev: true
+ /mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+ dev: false
+
+ /mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+ dependencies:
+ mime-db: 1.54.0
+ dev: false
+
/mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
@@ -9013,6 +9479,13 @@ packages:
dev: false
optional: true
+ /negotiator@1.1.0:
+ resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
+ engines: {node: '>=18'}
+ dependencies:
+ content-type: 2.1.0
+ dev: false
+
/neotraverse@0.6.18:
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
engines: {node: '>= 10'}
@@ -9078,6 +9551,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
+ /object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+ dev: false
+
/obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
dev: true
@@ -9093,13 +9571,19 @@ packages:
/ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
+ /on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ ee-first: 1.1.1
+ dev: false
+
/once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
requiresBuild: true
dependencies:
wrappy: 1.0.2
dev: false
- optional: true
/oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
@@ -9234,6 +9718,11 @@ packages:
entities: 6.0.1
dev: false
+ /parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
/path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -9242,12 +9731,15 @@ packages:
/path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
- dev: true
/path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
dev: true
+ /path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+ dev: false
+
/path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -9281,6 +9773,11 @@ packages:
engines: {node: '>=6'}
dev: true
+ /pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+ dev: false
+
/playwright-core@1.58.0:
resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==}
engines: {node: '>=18'}
@@ -9518,6 +10015,14 @@ packages:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
+ /proxy-addr@2.0.8:
+ resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
+ engines: {node: '>= 0.10'}
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+ dev: false
+
/pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
requiresBuild: true
@@ -9531,6 +10036,14 @@ packages:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
+ /qs@6.16.0:
+ resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
+ engines: {node: '>=0.6'}
+ dependencies:
+ es-define-property: 1.0.1
+ side-channel: 1.1.1
+ dev: false
+
/quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
dev: true
@@ -9621,6 +10134,21 @@ packages:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
dev: false
+ /range-parser@1.3.0:
+ resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
+ engines: {node: '>= 0.6'}
+ dev: false
+
+ /raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.2
+ unpipe: 1.0.0
+ dev: false
+
/rbush@3.0.1:
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
dependencies:
@@ -10160,6 +10688,11 @@ packages:
resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==}
dev: true
+ /require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+ dev: false
+
/reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
dev: false
@@ -10269,6 +10802,19 @@ packages:
/rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
+ /router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+ dependencies:
+ debug: 4.4.3
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.4.2
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
@@ -10287,7 +10833,6 @@ packages:
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
- dev: true
/sax@1.4.4:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
@@ -10308,6 +10853,25 @@ packages:
hasBin: true
requiresBuild: true
+ /send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.3.0
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/seroval-plugins@1.5.0(seroval@1.5.0):
resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==}
engines: {node: '>=10'}
@@ -10320,10 +10884,26 @@ packages:
resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==}
engines: {node: '>=10'}
+ /serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
dev: false
+ /setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+ dev: false
+
/sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -10394,12 +10974,10 @@ packages:
engines: {node: '>=8'}
dependencies:
shebang-regex: 3.0.0
- dev: true
/shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
- dev: true
/shiki@1.29.2:
resolution: {integrity: sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==}
@@ -10427,6 +11005,46 @@ packages:
'@types/hast': 3.0.4
dev: false
+ /side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ dev: false
+
+ /side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ dev: false
+
+ /side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+ dev: false
+
+ /side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+ dev: false
+
/siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
dev: true
@@ -10543,6 +11161,18 @@ packages:
get-source: 2.0.12
dev: true
+ /standardwebhooks@1.1.1:
+ resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
+ dependencies:
+ '@stablelib/base64': 1.0.1
+ fast-sha256: 1.3.0
+ dev: false
+
+ /statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
/std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
dev: true
@@ -10762,6 +11392,11 @@ packages:
is-number: 7.0.0
dev: true
+ /toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+ dev: false
+
/trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
dev: false
@@ -10770,6 +11405,10 @@ packages:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
dev: false
+ /ts-algebra@2.0.0:
+ resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
+ dev: false
+
/tsconfck@3.1.6(typescript@5.9.3):
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
@@ -10875,6 +11514,15 @@ packages:
engines: {node: '>=16'}
dev: false
+ /type-is@2.1.0:
+ resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+ engines: {node: '>= 18'}
+ dependencies:
+ content-type: 2.1.0
+ media-typer: 1.1.1
+ mime-types: 3.0.2
+ dev: false
+
/typescript-language-server@5.1.3:
resolution: {integrity: sha512-r+pAcYtWdN8tKlYZPwiiHNA2QPjXnI02NrW5Sf2cVM3TRtuQ3V9EKKwOxqwaQ0krsaEXk/CbN90I5erBuf84Vg==}
engines: {node: '>=20'}
@@ -11020,6 +11668,11 @@ packages:
engines: {node: '>= 4.0.0'}
dev: true
+ /unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
/unstorage@1.17.4:
resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==}
peerDependencies:
@@ -11146,6 +11799,11 @@ packages:
requiresBuild: true
dev: false
+ /vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+ dev: false
+
/vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
dependencies:
@@ -11837,7 +12495,6 @@ packages:
hasBin: true
dependencies:
isexe: 2.0.0
- dev: true
/why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
@@ -11909,7 +12566,6 @@ packages:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
requiresBuild: true
dev: false
- optional: true
/ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
@@ -11988,6 +12644,14 @@ packages:
zod: 3.25.76
dev: false
+ /zod-to-json-schema@3.25.1(zod@4.3.6):
+ resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
+ peerDependencies:
+ zod: ^3.25 || ^4
+ dependencies:
+ zod: 4.3.6
+ dev: false
+
/zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76):
resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==}
peerDependencies:
diff --git a/scripts/check-agent-compatibility.ts b/scripts/check-agent-compatibility.ts
new file mode 100644
index 00000000..d648f8ca
--- /dev/null
+++ b/scripts/check-agent-compatibility.ts
@@ -0,0 +1,76 @@
+/**
+ * Opt-in live provider check. Uses existing CLI authentication and consumes
+ * model quota. Run: bun scripts/check-agent-compatibility.ts codex|claude-code
+ * Creates only a temporary workspace; does not dispatch Stoneforge tasks.
+ */
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { randomUUID } from 'node:crypto';
+import { CodexAgentProvider } from '../packages/smithy/src/providers/codex/index.js';
+import { ClaudeAgentProvider } from '../packages/smithy/src/providers/claude/index.js';
+import type { HeadlessSession } from '../packages/smithy/src/providers/types.js';
+
+const name = process.argv[2];
+if (name !== 'codex' && name !== 'claude-code') {
+ throw new Error('Usage: bun scripts/check-agent-compatibility.ts codex|claude-code');
+}
+const provider = name === 'codex' ? new CodexAgentProvider() : new ClaudeAgentProvider();
+const workspace = await mkdtemp(join(tmpdir(), 'stoneforge-compat-'));
+const originalDirectory = process.cwd();
+process.chdir(workspace);
+const marker = `sf-${randomUUID()}`;
+let activeSession: HeadlessSession | undefined;
+
+async function collect(session: HeadlessSession): Promise<{ sessionId: string; text: string }> {
+ let timer: ReturnType;
+ try {
+ return await Promise.race([
+ (async () => {
+ let sessionId = '';
+ let text = '';
+ for await (const message of session) {
+ if (message.sessionId) sessionId = message.sessionId;
+ if (message.type === 'assistant' && message.content) text += message.content;
+ if (message.type === 'error') throw new Error(message.content ?? 'Provider error');
+ if (message.type === 'result') {
+ if (message.subtype !== 'success') throw new Error(`Turn ended with ${message.subtype}`);
+ return { sessionId, text: text || message.content || '' };
+ }
+ }
+ throw new Error('Session ended without a result');
+ })(),
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error('Provider check timed out after 90 seconds')), 90_000);
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer!);
+ session.close();
+ }
+}
+
+try {
+ const models = await provider.listModels();
+ if (!models.length) throw new Error('Model catalog is empty');
+ activeSession = await provider.headless.spawn({
+ workingDirectory: workspace,
+ initialPrompt: `Do not use tools or change files. Remember the code ${marker}. Reply with exactly READY.`,
+ });
+ const first = await collect(activeSession);
+ if (!first.sessionId || !first.text.includes('READY')) throw new Error('Initial turn did not initialize successfully');
+ activeSession = await provider.headless.spawn({
+ workingDirectory: workspace,
+ resumeSessionId: first.sessionId,
+ initialPrompt: 'Do not use tools. Reply with only the code I asked you to remember.',
+ });
+ const resumed = await collect(activeSession);
+ if (resumed.sessionId !== first.sessionId || !resumed.text.includes(marker)) {
+ throw new Error('Resumed session did not preserve conversation history');
+ }
+ console.log(JSON.stringify({ provider: name, models: models.length, start: 'passed', resume: 'passed', close: 'requested' }));
+} finally {
+ activeSession?.close();
+ process.chdir(originalDirectory);
+ await rm(workspace, { recursive: true, force: true });
+}
diff --git a/scripts/check-agent-orchestration.ts b/scripts/check-agent-orchestration.ts
new file mode 100644
index 00000000..b85efaee
--- /dev/null
+++ b/scripts/check-agent-orchestration.ts
@@ -0,0 +1,208 @@
+/**
+ * Opt-in live orchestration check; uses CLI authentication and model quota.
+ * bun scripts/check-agent-orchestration.ts codex|claude-code
+ * The argument selects the worker; the other provider directs and reviews.
+ * All Git operations use a temporary repository and a LOCAL bare origin.
+ */
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
+import { existsSync, appendFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve, delimiter } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { asEntityId, createEntity, EntityTypeValue, type Task } from '../packages/core/src/index.js';
+import { getOrchestratorTaskMeta } from '../packages/smithy/src/types/task-meta.js';
+import type { Services } from '../packages/smithy/src/server/services.js';
+import type { SpawnedSessionEvent } from '../packages/smithy/src/runtime/spawner.js';
+import { serverManager } from '../packages/smithy/src/providers/codex/server-manager.js';
+
+const workerProvider = process.argv[2];
+if (workerProvider !== 'codex' && workerProvider !== 'claude-code') {
+ throw new Error('Usage: bun scripts/check-agent-orchestration.ts codex|claude-code');
+}
+const reviewerProvider = workerProvider === 'codex' ? 'claude-code' : 'codex';
+const cli = fileURLToPath(new URL('../packages/smithy/src/bin/sf.ts', import.meta.url));
+const originalDirectory = process.cwd();
+const originalPath = process.env.PATH;
+const npmPath = Bun.which('npm');
+assert(npmPath, 'npm must be installed');
+const workspace = await mkdtemp(join(tmpdir(), `stoneforge-orchestration-${workerProvider}-`));
+const auditPath = join(workspace, '.stoneforge/cli-audit.jsonl');
+const eventsPath = join(workspace, '.stoneforge/session-events.jsonl');
+let services: Services | undefined;
+let passed = false;
+const failures: string[] = [];
+const observedSessions = new Set();
+const deadline = Date.now() + 6 * 60_000;
+
+function run(command: string, args: string[], cwd = workspace): string {
+ return execFileSync(command, args, { cwd, encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
+}
+function git(...args: string[]): string { return run('git', args); }
+function log(message: string): void { console.log(`[live ${workerProvider}] ${message}`); }
+async function waitUntil(predicate: () => Promise, label: string): Promise {
+ while (Date.now() < deadline) {
+ if (failures.length) throw new Error(failures.join('\n'));
+ if (await predicate()) return;
+ await Bun.sleep(500);
+ }
+ throw new Error(`Timed out waiting for ${label}`);
+}
+function observeSessions(): void {
+ for (const session of services!.sessionManager.listSessions()) {
+ if (observedSessions.has(session.id)) continue;
+ observedSessions.add(session.id);
+ log(`session ${session.agentRole} ${session.agentId} ${session.id}`);
+ services!.sessionManager.getEventEmitter(session.id)?.on('event', (event: SpawnedSessionEvent) => {
+ appendFileSync(eventsPath, JSON.stringify({ sessionId: session.id, agentId: session.agentId, event }) + '\n');
+ if (event.type === 'error' || (event.type === 'result' && event.subtype && event.subtype !== 'success')) {
+ failures.push(`${session.agentId}: ${JSON.stringify(event)}`);
+ }
+ });
+ }
+}
+
+try {
+ await mkdir(join(workspace, '.stoneforge'), { recursive: true });
+ await mkdir(join(workspace, '.test-bin'));
+ await writeFile(join(workspace, '.stoneforge/config.yaml'),
+ 'name: live-orchestration\nbase_branch: main\nmerge:\n require_approval: false\nagents:\n permission_model: unrestricted\nsync:\n auto_export: false\n');
+ // Audit the actual CLI calls from agents, without replacing task operations.
+ // A private executable avoids depending on a globally installed sf version.
+ const runner = join(workspace, '.test-bin/runner.ts');
+ await writeFile(runner, `import { appendFileSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+const [tool, ...args] = process.argv.slice(2);
+const cwd = process.cwd();
+const result = tool === 'sf'
+ ? spawnSync(${JSON.stringify(process.execPath)}, [${JSON.stringify(cli)}, ...args], { stdio: 'inherit', env: process.env })
+ : spawnSync(${JSON.stringify(npmPath)}, args, { stdio: 'inherit', env: process.env });
+appendFileSync(${JSON.stringify(auditPath)}, JSON.stringify({ tool, actor: process.env.SF_ENTITY_ID, cwd, args, exitCode: result.status }) + '\\n');
+process.exit(result.status ?? 1);
+`);
+ const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
+ for (const tool of ['sf', 'npm']) {
+ await writeFile(join(workspace, '.test-bin', tool), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(runner)} ${tool} "$@"\n`, { mode: 0o755 });
+ }
+ process.env.PATH = `${join(workspace, '.test-bin')}${delimiter}${originalPath ?? ''}`;
+ process.chdir(workspace);
+ await writeFile(join(workspace, '.gitignore'), '.stoneforge/\n.test-bin/\n.test-origin.git/\n.a5c/\nnode_modules/\npnpm-lock.yaml\n');
+ await writeFile(join(workspace, 'package.json'), JSON.stringify({ name: 'stoneforge-live-fixture', private: true, type: 'module', scripts: { test: 'node --test' } }, null, 2) + '\n');
+ await writeFile(join(workspace, 'math.js'), 'export function add(a, b) { return a - b; }\n');
+ const tests = `import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { add } from './math.js';
+test('adds positive numbers', () => assert.equal(add(2, 3), 5));
+test('adds negative numbers', () => assert.equal(add(-2, -3), -5));
+test('adds zero', () => assert.equal(add(7, 0), 7));
+`;
+ await writeFile(join(workspace, 'math.test.js'), tests);
+ const guidance = `This is an isolated Stoneforge integration fixture. Work only in this project.
+Only a local bare Git origin is configured. Do not use GitHub, other remote services, or external messaging.
+For EVERY sf command use this exact executable: ${join(workspace, '.test-bin/sf')}
+For EVERY npm command use this exact executable: ${join(workspace, '.test-bin/npm')}
+Always use these absolute paths, because login shells can reset PATH to an older global installation.
+Use your inherited SF_ENTITY_ID identity. Do not pass --actor or set SF_ENTITY_ID.
+Never edit .stoneforge database or status metadata directly.
+The acceptance tests in math.test.js are immutable. Fix implementation only. Run npm test using the absolute executable above.
+Worker: commit the fix on your assigned branch, then sf task complete TASK_ID.
+Merge steward: inspect the diff, run npm test yourself, then sf task merge TASK_ID and stop.
+The main checkout and CLI infrastructure are managed by the test runner; do not change them.
+This fixture has no workspace documentation library to update.
+`;
+ await writeFile(join(workspace, 'AGENTS.md'), guidance);
+ await writeFile(join(workspace, 'CLAUDE.md'), guidance);
+ git('init', '-b', 'main');
+ git('config', 'user.name', 'Stoneforge Integration Test');
+ git('config', 'user.email', 'integration@example.invalid');
+ git('config', 'commit.gpgsign', 'false');
+ git('add', '.');
+ git('commit', '-m', 'test: add failing arithmetic fixture');
+ git('init', '--bare', '.test-origin.git');
+ git('remote', 'add', 'origin', join(workspace, '.test-origin.git'));
+ git('push', '-u', 'origin', 'main');
+ git('remote', 'set-head', 'origin', 'main');
+ const initialCommit = git('rev-parse', 'HEAD');
+ assert.throws(() => run('npm', ['test']), 'Fixture must fail before the worker fixes it');
+ log(`workspace ${workspace}; baseline tests fail as expected`);
+
+ const { initializeServices } = await import('../packages/smithy/src/server/services.js');
+ services = await initializeServices({ projectRoot: workspace, dbPath: join(workspace, '.stoneforge/stoneforge.db') });
+ const entity = await createEntity({ name: 'test-operator', entityType: EntityTypeValue.SYSTEM, createdBy: asEntityId('system:test') });
+ const operator = await services.api.create({ ...entity });
+ const createdBy = asEntityId(operator.id);
+ const director = await services.agentRegistry.registerDirector({ name: 'live-director', createdBy, provider: reviewerProvider });
+ const worker = await services.agentRegistry.registerWorker({ name: 'live-worker', createdBy, provider: workerProvider, workerMode: 'ephemeral', reportsTo: asEntityId(director.id) });
+ const steward = await services.agentRegistry.registerSteward({ name: 'live-steward', createdBy, provider: reviewerProvider, stewardFocus: 'merge', triggers: [] });
+ assert(services.dispatchDaemon, 'Production dispatch daemon must be initialized');
+ services.dispatchDaemon.updateConfig({ pollIntervalMs: 1000, inboxPollEnabled: false, stewardTriggerPollEnabled: false,
+ orphanRecoveryEnabled: false, stuckMergeRecoveryEnabled: false, maxSessionDurationMs: 240_000 });
+
+ const { session, events } = await services.sessionManager.startSession(asEntityId(director.id), {
+ workingDirectory: workspace, interactive: false,
+ initialPrompt: `Act as the Director in this isolated test project. Read AGENTS.md. Create exactly ONE task using ${join(workspace, '.test-bin/sf')} task create (use --help if needed).
+Title: Fix arithmetic addition. Description must ask the worker to fix add(a,b) in math.js so all immutable tests in math.test.js pass, run npm test, commit the fix and finish using sf task complete.
+Use your inherited SF_ENTITY_ID identity; do not pass --actor or change environment variables. Do not implement the fix, assign the task, start other agents, or alter its status. The daemon will do the dispatch. After creating the task report its ID and stop.`,
+ });
+ let directorFinished = false;
+ events.on('event', (event: SpawnedSessionEvent) => {
+ if (event.type === 'result') directorFinished = true;
+ });
+ observeSessions();
+ await waitUntil(async () => directorFinished, 'Director to create a task');
+ await services.sessionManager.stopSession(session.id);
+ const tasks = await services.api.list({ type: 'task' });
+ assert.equal(tasks.length, 1, 'Director must create exactly one task');
+ assert.equal(tasks[0].createdBy, director.id, 'Director CLI must preserve its agent identity');
+ const taskId = tasks[0].id;
+ log(`Director created ${taskId}; starting production dispatch`);
+ await services.dispatchDaemon.start();
+ let lastState = '';
+ await waitUntil(async () => {
+ observeSessions();
+ const task = (await services!.api.get(taskId))!;
+ const meta = getOrchestratorTaskMeta(task.metadata);
+ const state = `${task.status}/${meta?.mergeStatus ?? '-'}/${task.assignee ?? '-'}`;
+ if (state !== lastState) { log(`task ${state}`); lastState = state; }
+ return task.status === 'closed' && meta?.mergeStatus === 'merged';
+ }, 'Worker completion and Steward merge');
+ await services.dispatchDaemon.stop();
+ const finalTask = (await services.api.get(taskId))!;
+ const finalMeta = getOrchestratorTaskMeta(finalTask.metadata)!;
+ const mergedCommit = git('rev-parse', 'refs/remotes/origin/main');
+ assert.notEqual(mergedCommit, initialCommit, 'Origin must receive a new commit');
+ assert.equal(finalMeta.mergeCommitHash, mergedCommit);
+ assert.equal(git('rev-parse', 'main'), mergedCommit, 'Main checkout must be synchronized');
+ assert.equal(await readFile(join(workspace, 'math.test.js'), 'utf8'), tests, 'Agents must not weaken acceptance tests');
+ assert.deepEqual(git('diff', '--name-only', initialCommit, mergedCommit).split('\n'), ['math.js']);
+ run('npm', ['test']);
+ await waitUntil(async () => {
+ observeSessions();
+ return services!.sessionManager.listSessions().every(s => s.status === 'terminated');
+ }, 'all agent sessions to finish');
+ const audit = (await readFile(auditPath, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
+ for (const [verb, agent] of [['create', director], ['complete', worker], ['merge', steward]] as const) {
+ assert(audit.some(call => call.tool === 'sf' && call.actor === agent.id && call.exitCode === 0 && call.args[0] === 'task' && call.args[1] === verb), `${verb} must succeed under the correct agent identity`);
+ }
+ for (const agent of [worker, steward]) {
+ assert(audit.some(call => call.tool === 'npm' && call.actor === agent.id && call.args[0] === 'test' && call.exitCode === 0), `${agent.name} must run passing tests`);
+ }
+ if (finalMeta.worktree) assert(!existsSync(resolve(workspace, finalMeta.worktree)), 'Merged worktree must be removed');
+ log(JSON.stringify({ director: reviewerProvider, worker: workerProvider, steward: reviewerProvider,
+ taskId, mergedCommit, tests: 'passed', identities: 'passed', lifecycle: 'passed' }));
+ passed = true;
+} finally {
+ await services?.dispatchDaemon?.stop();
+ await services?.stewardScheduler.stop();
+ services?.autoExportService.stop();
+ for (const session of services?.sessionManager.listSessions() ?? []) {
+ if (session.status !== 'terminated') await services!.sessionManager.stopSession(session.id, { graceful: false }).catch(() => {});
+ }
+ serverManager.shutdown();
+ services?.storageBackend.close();
+ process.chdir(originalDirectory);
+ if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath;
+ if (passed && !process.env.SF_KEEP_TEST_WORKSPACE) await rm(workspace, { recursive: true, force: true });
+ else console.log(`Diagnostics retained at ${workspace}`);
+}