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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/sdk/src/adapters/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* First-class headless adapter contract (flows#141).
*
* Every supported agent CLI ships one implementation. `worker-cli.ts` and
* `cli/check.ts` dispatch through the registry in `./index.ts` — never string
* formatting against per-CLI knowledge inline. Adding a new CLI is one new
* file that implements this interface.
*
* Behavior-preserving migration: the exported functions in `cli-adapter.ts`
* (`agentExecution`, `llmExecution`, `authenticationProbe`,
* `modelReadinessProbe`, `adapterIdentification`) now delegate to the
* registered adapter for the resolved kind, so callers don't change.
*/

export interface CliInvocation {
args: string[];
timeoutMs: number;
/** Set only for wrapper readiness probes; raw providers receive a model flag. */
modelEnv?: string;
}

export interface CliAdapterIdentification {
invocation: CliInvocation;
expectedStdout?: string;
}

/**
* The declared, per-CLI headless contract. Members intentionally mirror the
* function-scoped predecessors in `cli-adapter.ts` so migration is mechanical.
*/
export interface HeadlessAdapter {
/** Identity of this adapter — matches CliAdapterKind for registry keys. */
readonly kind: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: kind is typed string but must exactly match the registry key in adapters/index.ts for legacy dispatch to work, and nothing enforces that. resolveAdapterKind() (index.ts:27) returns resolveAdapter(executable).kind as CliAdapterKind, and every legacy helper in cli-adapter.ts (agentExecution, authenticationProbe, …) indexes registeredAdapters()[kind] with that value. A typo in a new adapter's kind passes typecheck, then mislabels the kind and throws (registeredAdapters()[kind] returns undefined) at runtime. Since CliAdapterKind is a type-only import, type kind: CliAdapterKind (imported from ./index.js) to let the compiler enforce that the adapter's declared identity is a valid registry key; alternatively derive the kind from the record key instead of duplicating it on the adapter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/src/adapters/base.ts, line 33:

<comment>`kind` is typed `string` but must exactly match the registry key in `adapters/index.ts` for legacy dispatch to work, and nothing enforces that. `resolveAdapterKind()` (index.ts:27) returns `resolveAdapter(executable).kind as CliAdapterKind`, and every legacy helper in `cli-adapter.ts` (`agentExecution`, `authenticationProbe`, …) indexes `registeredAdapters()[kind]` with that value. A typo in a new adapter's `kind` passes typecheck, then mislabels the kind and throws (`registeredAdapters()[kind]` returns undefined) at runtime. Since `CliAdapterKind` is a type-only import, type `kind: CliAdapterKind` (imported from `./index.js`) to let the compiler enforce that the adapter's declared identity is a valid registry key; alternatively derive the kind from the record key instead of duplicating it on the adapter.</comment>

<file context>
@@ -0,0 +1,53 @@
+ */
+export interface HeadlessAdapter {
+  /** Identity of this adapter — matches CliAdapterKind for registry keys. */
+  readonly kind: string;
+
+  /** Shape-check invocation before classifying an auth failure. */
</file context>


/** Shape-check invocation before classifying an auth failure. */
buildIdentification(): CliAdapterIdentification;

/** Non-interactive auth-status probe. */
buildAuthProbe(): CliInvocation;

/**
* Real, noninteractive model round-trip. Providers hand the model via a
* native flag; wrappers hand it via the explicitly identified environment
* contract on `modelEnv`.
*/
buildModelReadinessProbe(model: string): CliInvocation;

/** Agent-step worker argv for an instruction under an optional model. */
buildAgentInvocation(instruction: string, model?: string): CliInvocation;

/** LLM-step worker argv for a prompt under an optional model. */
buildLlmInvocation(prompt: string, model?: string): CliInvocation;
}
55 changes: 55 additions & 0 deletions packages/sdk/src/adapters/claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type {
CliAdapterIdentification,
CliInvocation,
HeadlessAdapter,
} from './base.js';

const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.';

/** Claude Code CLI headless contract. Wire and behavior identical to the
* pre-#141 inline shape in `cli-adapter.ts` — only the packaging changed. */
export const claudeAdapter: HeadlessAdapter = {
kind: 'claude',

buildIdentification(): CliAdapterIdentification {
return { invocation: { args: ['auth', 'status', '--help'], timeoutMs: 10_000 } };
},

buildAuthProbe(): CliInvocation {
return { args: ['auth', 'status'], timeoutMs: 10_000 };
},

buildModelReadinessProbe(model: string): CliInvocation {
return {
args: [
'-p', '--model', model, '--tools', '', '--no-session-persistence',
MODEL_PROBE_PROMPT,
],
timeoutMs: 60_000,
};
},

buildAgentInvocation(instruction: string, model?: string): CliInvocation {
return {
args: [
'-p',
// Symmetric to codex's --dangerously-bypass-approvals-and-sandbox: in
// agent mode the flow explicitly delegates writes. Without this
// claude's headless mode prompts for tool approval, gets no TTY,
// and completes "successfully" without touching files.
'--dangerously-skip-permissions',
...(model === undefined ? [] : ['--model', model]),
instruction,
],
timeoutMs: 0,
};
},

buildLlmInvocation(prompt: string, model?: string): CliInvocation {
return {
args: ['-p', '--tools', '', '--no-session-persistence',
...(model === undefined ? [] : ['--model', model]), prompt],
timeoutMs: 0,
};
},
};
56 changes: 56 additions & 0 deletions packages/sdk/src/adapters/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type {
CliAdapterIdentification,
CliInvocation,
HeadlessAdapter,
} from './base.js';

const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.';

/** OpenAI Codex CLI headless contract. Behavior identical to the pre-#141
* inline shape in `cli-adapter.ts` — only the packaging changed. */
export const codexAdapter: HeadlessAdapter = {
kind: 'codex',

buildIdentification(): CliAdapterIdentification {
return { invocation: { args: ['login', 'status', '--help'], timeoutMs: 10_000 } };
},

buildAuthProbe(): CliInvocation {
return { args: ['login', 'status'], timeoutMs: 10_000 };
},

buildModelReadinessProbe(model: string): CliInvocation {
return {
args: [
'exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check',
'--model', model, MODEL_PROBE_PROMPT,
],
timeoutMs: 60_000,
};
},

buildAgentInvocation(instruction: string, model?: string): CliInvocation {
return {
args: [
'exec', '--ephemeral', '--skip-git-repo-check',
// Agent-mode is where the flow explicitly delegates code changes to
// the CLI. Without this flag codex prompts for approval on every
// write, gets nothing (no TTY), and completes "successfully" without
// touching files — the dogfood no-op failure mode. LLM-mode stays
// read-only and does NOT get the bypass.
'--dangerously-bypass-approvals-and-sandbox',
...(model === undefined ? [] : ['--model', model]),
instruction,
],
timeoutMs: 0,
};
},

buildLlmInvocation(prompt: string, model?: string): CliInvocation {
return {
args: ['exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check',
...(model === undefined ? [] : ['--model', model]), prompt],
timeoutMs: 0,
};
},
};
36 changes: 36 additions & 0 deletions packages/sdk/src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { basename } from 'node:path';

import { claudeAdapter } from './claude.js';
import { codexAdapter } from './codex.js';
import { wrapperAdapter } from './wrapper.js';
import type { HeadlessAdapter } from './base.js';

export type CliAdapterKind = 'claude' | 'codex' | 'relayflows-wrapper-v1';

/** The registered adapter set. Adding a CLI is adding one file + one entry. */
const ADAPTERS: Record<CliAdapterKind, HeadlessAdapter> = {
claude: claudeAdapter,
codex: codexAdapter,
'relayflows-wrapper-v1': wrapperAdapter,
};

/** Select the adapter for the resolved executable basename. */
export function resolveAdapter(executable: string): HeadlessAdapter {
const name = basename(executable).replace(/\.exe$/i, '');
if (name === 'claude') return ADAPTERS.claude;
if (name === 'codex') return ADAPTERS.codex;
return ADAPTERS['relayflows-wrapper-v1'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a new adapter is added to ADAPTERS, resolveAdapter still ignores it unless another hard-coded branch is added here. Any new CLI therefore falls through to the wrapper protocol instead of its registered adapter, contradicting the advertised one-entry extension point; look up the basename in ADAPTERS before using the wrapper fallback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/src/adapters/index.ts, line 22:

<comment>When a new adapter is added to `ADAPTERS`, `resolveAdapter` still ignores it unless another hard-coded branch is added here. Any new CLI therefore falls through to the wrapper protocol instead of its registered adapter, contradicting the advertised one-entry extension point; look up the basename in `ADAPTERS` before using the wrapper fallback.</comment>

<file context>
@@ -0,0 +1,36 @@
+  const name = basename(executable).replace(/\.exe$/i, '');
+  if (name === 'claude') return ADAPTERS.claude;
+  if (name === 'codex') return ADAPTERS.codex;
+  return ADAPTERS['relayflows-wrapper-v1'];
+}
+
</file context>

}

/** Backward-compatible kind lookup for callers still speaking string-tag lang. */
export function resolveAdapterKind(executable: string): CliAdapterKind {
return resolveAdapter(executable).kind as CliAdapterKind;
}

/** Registered adapters by kind (read-only view for tests and diagnostics). */
export function registeredAdapters(): Readonly<Record<CliAdapterKind, HeadlessAdapter>> {
return ADAPTERS;
}

export { claudeAdapter, codexAdapter, wrapperAdapter };
export type { HeadlessAdapter, CliInvocation, CliAdapterIdentification } from './base.js';
42 changes: 42 additions & 0 deletions packages/sdk/src/adapters/wrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type {
CliAdapterIdentification,
CliInvocation,
HeadlessAdapter,
} from './base.js';

/**
* `relayflows-adapter-v1` identifies a per-flow wrapper script — the pre-#141
* per-flow shim shape. The wrapper answers a probe with a known token; agent
* and llm execution flow through `runWrapperSession`, so this adapter refuses
* to construct a direct-argv invocation for them.
*/
export const WRAPPER_IDENTIFY_ARG = '--relayflows-adapter-v1';
export const WRAPPER_IDENTIFY_TOKEN = 'relayflows-agent-cli-v1';
export const WRAPPER_EXECUTE_TOKEN = 'relayflows-agent-cli-v1-execute';

export const wrapperAdapter: HeadlessAdapter = {
kind: 'relayflows-wrapper-v1',

buildIdentification(): CliAdapterIdentification {
return {
invocation: { args: [WRAPPER_IDENTIFY_ARG], timeoutMs: 10_000 },
expectedStdout: WRAPPER_IDENTIFY_TOKEN,
};
},

buildAuthProbe(): CliInvocation {
return { args: ['auth', 'status'], timeoutMs: 10_000 };
},

buildModelReadinessProbe(model: string): CliInvocation {
return { args: ['auth', 'status'], timeoutMs: 60_000, modelEnv: model };
},

buildAgentInvocation(): CliInvocation {
throw new Error('custom wrapper execution requires the runAgentCli same-process session');
},

buildLlmInvocation(): CliInvocation {
throw new Error('custom wrapper execution requires the same-process session');
},
};
Loading
Loading