@tanstack/ai-claude-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,539 @@
1
+ import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
2
+ import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
3
+ import { BaseTextAdapter } from '@tanstack/ai/adapters'
4
+ import {
5
+ SandboxCapability,
6
+ approvalId,
7
+ buildApprovalRequestedEvent,
8
+ createBridgeEventChannel,
9
+ getSandbox,
10
+ getSandboxPolicy,
11
+ getToolBridgeProvisioner,
12
+ getWorkspaceProjection,
13
+ mergeChunkStreams,
14
+ nodeHttpBridgeProvisioner,
15
+ resolveApproval,
16
+ spawnNdjson,
17
+ } from '@tanstack/ai-sandbox'
18
+ import { buildPrompt } from '../messages/prompt'
19
+ import { translateSdkStream } from '../stream/translate'
20
+ import { mapPolicyToClaudeFlags } from './policy-map'
21
+ import { projectClaudeWorkspace } from './projection'
22
+ import type { ClaudePolicyFlags } from './policy-map'
23
+ import type {
24
+ BridgeEventChannel,
25
+ HostToolBridge,
26
+ PermissionToolResult,
27
+ SandboxHandle,
28
+ SandboxPolicy,
29
+ } from '@tanstack/ai-sandbox'
30
+ import type {
31
+ StructuredOutputOptions,
32
+ StructuredOutputResult,
33
+ } from '@tanstack/ai/adapters'
34
+ import type {
35
+ DefaultMessageMetadataByModality,
36
+ Modality,
37
+ StreamChunk,
38
+ TextOptions,
39
+ } from '@tanstack/ai'
40
+ import type { ClaudeCodeModel } from '../model-meta'
41
+ import type { ClaudeCodeTextProviderOptions } from '../provider-options'
42
+ import type { AgentSdkMessage } from '../stream/sdk-types'
43
+
44
+ export type ClaudeCodePermissionMode =
45
+ | 'default'
46
+ | 'acceptEdits'
47
+ | 'bypassPermissions'
48
+ | 'plan'
49
+
50
+ const DEFAULT_WORKDIR = '/workspace'
51
+
52
+ export interface ClaudeCodeTextConfig {
53
+ /**
54
+ * Working directory inside the sandbox where `claude` runs. Defaults to
55
+ * `/workspace` (the conventional sandbox workspace root).
56
+ */
57
+ cwd?: string
58
+ /**
59
+ * Claude Code permission mode passed via `--permission-mode`. Defaults to
60
+ * `'bypassPermissions'` — a sandbox is isolated, so the agent is allowed to
61
+ * edit files and run commands without prompting. Tighten via `defineSandboxPolicy`
62
+ * / this option for less autonomy.
63
+ */
64
+ permissionMode?: ClaudeCodePermissionMode
65
+ /** Built-in tools the harness may use (`--allowedTools`). */
66
+ allowedTools?: Array<string>
67
+ /** Built-in tools removed from the harness (`--disallowedTools`). */
68
+ disallowedTools?: Array<string>
69
+ /** Extra directories the agent may access (`--add-dir`). */
70
+ addDirs?: Array<string>
71
+ /** Maximum harness-internal turns (`--max-turns`). */
72
+ maxTurns?: number
73
+ /**
74
+ * How `systemPrompts` from `chat()` are applied:
75
+ * - `'append'` (default): `--append-system-prompt` on top of the preset.
76
+ * - `'replace'`: `--system-prompt` as the entire system prompt.
77
+ */
78
+ systemPromptMode?: 'append' | 'replace'
79
+ /** Path/name of the claude executable inside the sandbox. Defaults to `claude`. */
80
+ claudeExecutable?: string
81
+ /** Emit token-level deltas via `--include-partial-messages` (default true). */
82
+ streamPartials?: boolean
83
+ /** Extra environment variables for the claude process inside the sandbox. */
84
+ env?: Record<string, string>
85
+ /** Emit a `file.changed` CUSTOM event with the git diff after the run (default true). */
86
+ emitDiff?: boolean
87
+ }
88
+
89
+ /** POSIX single-quote escape for embedding values in the `claude …` command. */
90
+ function q(value: string): string {
91
+ return `'${value.replace(/'/g, `'\\''`)}'`
92
+ }
93
+
94
+ /** Format a host tool-bridge as claude's `--mcp-config` JSON. */
95
+ function bridgeToMcpConfig(bridge: HostToolBridge): string {
96
+ return JSON.stringify({
97
+ mcpServers: {
98
+ [bridge.name]: {
99
+ type: 'http',
100
+ url: bridge.url,
101
+ headers: { Authorization: `Bearer ${bridge.token}` },
102
+ },
103
+ },
104
+ })
105
+ }
106
+
107
+ export class ClaudeCodeTextAdapter<
108
+ TModel extends ClaudeCodeModel,
109
+ > extends BaseTextAdapter<
110
+ TModel,
111
+ ClaudeCodeTextProviderOptions,
112
+ ReadonlyArray<Modality> & readonly ['text'],
113
+ DefaultMessageMetadataByModality,
114
+ ReadonlyArray<string>,
115
+ unknown,
116
+ never
117
+ > {
118
+ readonly name = 'claude-code' as const
119
+
120
+ // Harness adapter: requires a sandbox to run the agent CLI inside.
121
+ override readonly requires = [SandboxCapability] as const
122
+
123
+ private readonly adapterConfig: ClaudeCodeTextConfig
124
+
125
+ constructor(config: ClaudeCodeTextConfig, model: TModel) {
126
+ super({}, model)
127
+ this.adapterConfig = config
128
+ }
129
+
130
+ private sandboxFrom(
131
+ options: TextOptions<ClaudeCodeTextProviderOptions>,
132
+ ): SandboxHandle {
133
+ const ctx = options.capabilities
134
+ if (!ctx) {
135
+ throw new Error(
136
+ 'Adapter "claude-code" requires a sandbox. Add withSandbox(defineSandbox({ ... })) ' +
137
+ 'to chat() middleware (e.g. with the local-process or docker provider).',
138
+ )
139
+ }
140
+ return getSandbox(ctx)
141
+ }
142
+
143
+ private workdir(options: TextOptions<ClaudeCodeTextProviderOptions>): string {
144
+ return (
145
+ options.modelOptions?.cwd ?? this.adapterConfig.cwd ?? DEFAULT_WORKDIR
146
+ )
147
+ }
148
+
149
+ /** Build the `claude` command line (prompt goes via stdin, not argv). */
150
+ private buildCommand(
151
+ options: TextOptions<ClaudeCodeTextProviderOptions>,
152
+ resume: string | undefined,
153
+ policyFlags: ClaudePolicyFlags,
154
+ mcpConfigPath: string | undefined,
155
+ permissionPromptTool: string | undefined,
156
+ ): string {
157
+ const config = this.adapterConfig
158
+ const modelOptions = options.modelOptions
159
+ const exe = config.claudeExecutable ?? 'claude'
160
+
161
+ const args: Array<string> = [
162
+ '-p',
163
+ '--output-format',
164
+ 'stream-json',
165
+ '--verbose',
166
+ '--model',
167
+ q(this.model),
168
+ ]
169
+
170
+ if (config.streamPartials !== false) args.push('--include-partial-messages')
171
+ if (resume !== undefined) args.push('--resume', q(resume))
172
+
173
+ // Precedence: per-call modelOptions > adapter config > policy > sandbox default.
174
+ const permissionMode =
175
+ modelOptions?.permissionMode ??
176
+ config.permissionMode ??
177
+ policyFlags.permissionMode ??
178
+ 'bypassPermissions'
179
+ args.push('--permission-mode', q(permissionMode))
180
+
181
+ const maxTurns = modelOptions?.maxTurns ?? config.maxTurns
182
+ if (maxTurns !== undefined) args.push('--max-turns', String(maxTurns))
183
+
184
+ for (const dir of config.addDirs ?? []) args.push('--add-dir', q(dir))
185
+
186
+ const allowedTools = [
187
+ ...(modelOptions?.allowedTools ?? config.allowedTools ?? []),
188
+ ...policyFlags.allowedTools,
189
+ ]
190
+ if (allowedTools.length > 0) {
191
+ args.push('--allowedTools', q([...new Set(allowedTools)].join(',')))
192
+ }
193
+ const disallowedTools = [
194
+ ...(modelOptions?.disallowedTools ?? config.disallowedTools ?? []),
195
+ ...policyFlags.disallowedTools,
196
+ ]
197
+ if (disallowedTools.length > 0) {
198
+ args.push('--disallowedTools', q([...new Set(disallowedTools)].join(',')))
199
+ }
200
+
201
+ const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
202
+ .map((prompt) => prompt.content)
203
+ .filter((content) => content.trim() !== '')
204
+ if (systemPrompts.length > 0) {
205
+ const joined = systemPrompts.join('\n\n')
206
+ const flag =
207
+ config.systemPromptMode === 'replace'
208
+ ? '--system-prompt'
209
+ : '--append-system-prompt'
210
+ args.push(flag, q(joined))
211
+ }
212
+
213
+ if (mcpConfigPath !== undefined) args.push('--mcp-config', q(mcpConfigPath))
214
+ if (permissionPromptTool !== undefined) {
215
+ args.push('--permission-prompt-tool', q(permissionPromptTool))
216
+ }
217
+
218
+ return `${exe} ${args.join(' ')}`
219
+ }
220
+
221
+ /**
222
+ * Build the permission-prompt resolver the host MCP bridge exposes to claude
223
+ * (`--permission-prompt-tool`). Maps claude's permission request onto the
224
+ * sandbox policy + client approvals; on an `ask` action with no decision yet,
225
+ * records an approval-requested event and denies (the client re-runs to grant).
226
+ */
227
+ private buildPermissionResolver(
228
+ policy: SandboxPolicy | undefined,
229
+ approvals: ReadonlyMap<string, boolean> | undefined,
230
+ scripts: Record<string, string> | undefined,
231
+ sink: Array<StreamChunk>,
232
+ threadId: string,
233
+ runId: string,
234
+ ): (input: { tool_name?: string; input?: unknown }) => PermissionToolResult {
235
+ const writeTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
236
+ const networkTools = new Set(['WebFetch', 'WebSearch'])
237
+ return (request) => {
238
+ const toolName = request.tool_name ?? 'tool'
239
+ const cmdInput = request.input
240
+ const command =
241
+ toolName === 'Bash' &&
242
+ cmdInput !== null &&
243
+ typeof cmdInput === 'object' &&
244
+ 'command' in cmdInput &&
245
+ typeof (cmdInput as { command?: unknown }).command === 'string'
246
+ ? (cmdInput as { command: string }).command
247
+ : undefined
248
+ const capability = writeTools.has(toolName)
249
+ ? 'fileWrite'
250
+ : networkTools.has(toolName)
251
+ ? 'network'
252
+ : undefined
253
+ const id = approvalId({
254
+ provider: 'claude-code',
255
+ kind: command !== undefined ? 'command' : (capability ?? 'tool'),
256
+ target: command ?? toolName,
257
+ })
258
+ const outcome = resolveApproval({
259
+ policy,
260
+ approvals,
261
+ id,
262
+ scripts,
263
+ ...(command !== undefined ? { command } : {}),
264
+ ...(capability !== undefined ? { capability } : {}),
265
+ })
266
+ if (outcome.needsApproval) {
267
+ sink.push(
268
+ buildApprovalRequestedEvent({
269
+ approvalId: id,
270
+ title: `Approve ${toolName}${command !== undefined ? `: ${command}` : ''}`,
271
+ threadId,
272
+ runId,
273
+ detail: { provider: 'claude-code', toolName },
274
+ }),
275
+ )
276
+ return {
277
+ behavior: 'deny',
278
+ message:
279
+ 'Awaiting client approval. Approve in the UI and re-run to continue.',
280
+ }
281
+ }
282
+ return outcome.decision === 'allow'
283
+ ? { behavior: 'allow' }
284
+ : { behavior: 'deny', message: 'Denied by sandbox policy.' }
285
+ }
286
+ }
287
+
288
+ async *chatStream(
289
+ options: TextOptions<ClaudeCodeTextProviderOptions>,
290
+ ): AsyncIterable<StreamChunk> {
291
+ const { logger } = options
292
+ let bridge: HostToolBridge | undefined
293
+ let channel: BridgeEventChannel | undefined
294
+ const approvalRequests: Array<StreamChunk> = []
295
+ // Temp files written for the run (bridge MCP config, redirected prompt) that
296
+ // carry the bearer token / prompt; removed in `finally` so they don't linger
297
+ // in the sandbox after the run.
298
+ let cleanupSandbox: SandboxHandle | undefined
299
+ const tempFiles: Array<string> = []
300
+ try {
301
+ const sandbox = this.sandboxFrom(options)
302
+ cleanupSandbox = sandbox
303
+ const cwd = this.workdir(options)
304
+ const runId = options.runId ?? this.generateId()
305
+ const threadId = options.threadId ?? this.generateId()
306
+ // Surfaces custom events from bridged tools (e.g. code mode console logs)
307
+ // on this run's live output stream.
308
+ channel = createBridgeEventChannel({ model: this.model, threadId, runId })
309
+
310
+ // Idempotently project workspace skills/plugins/MCP into the sandbox in
311
+ // claude's native format (guarded by the projection marker file).
312
+ const projection = options.capabilities
313
+ ? getWorkspaceProjection(options.capabilities, { optional: true })
314
+ : undefined
315
+ if (projection) await projectClaudeWorkspace(sandbox, projection)
316
+
317
+ const policy = options.capabilities
318
+ ? getSandboxPolicy(options.capabilities, { optional: true })
319
+ : undefined
320
+
321
+ // A permission-prompt tool gates the agent's native tools when a policy
322
+ // can `ask`/`deny` (interactive approvals).
323
+ const permission =
324
+ policy !== undefined
325
+ ? {
326
+ toolName: 'approval_prompt',
327
+ resolve: this.buildPermissionResolver(
328
+ policy,
329
+ options.approvals,
330
+ projection?.scripts,
331
+ approvalRequests,
332
+ threadId,
333
+ runId,
334
+ ),
335
+ }
336
+ : undefined
337
+
338
+ // Bridge chat()-provided server tools (and/or the permission tool) into
339
+ // the sandbox over MCP.
340
+ const hasTools = options.tools !== undefined && options.tools.length > 0
341
+ if (hasTools || permission !== undefined) {
342
+ const provisioner =
343
+ (options.capabilities
344
+ ? getToolBridgeProvisioner(options.capabilities, { optional: true })
345
+ : undefined) ?? nodeHttpBridgeProvisioner
346
+ bridge = await provisioner.provision(options.tools ?? [], {
347
+ provider: sandbox.provider,
348
+ context: options.context,
349
+ emitCustomEvent: channel.emitCustomEvent,
350
+ ...(permission !== undefined ? { permission } : {}),
351
+ ...(options.abortController?.signal
352
+ ? { signal: options.abortController.signal }
353
+ : {}),
354
+ })
355
+ }
356
+
357
+ const { prompt, resume } = buildPrompt(
358
+ options.messages,
359
+ options.modelOptions?.sessionId,
360
+ )
361
+ // The bridge MCP config carries the per-run bearer token. Write it to a
362
+ // file and pass claude the PATH, so the token never appears in argv (where
363
+ // any process in the sandbox could read it via `ps` / `/proc/<pid>/cmdline`).
364
+ let mcpConfigArg: string | undefined
365
+ if (bridge) {
366
+ // Pass claude a path RELATIVE to its cwd (the real workdir the handle
367
+ // runs the process in). An absolute VIRTUAL path like `/workspace/…` is
368
+ // wrong wherever claude runs outside a sandbox that literally uses
369
+ // `/workspace` — e.g. local-process on Windows, where git-bash resolves
370
+ // `/workspace` to `C:\Program Files\Git\workspace` and the file is "not
371
+ // found". The bare filename resolves correctly on every provider.
372
+ const mcpConfigFile = `.tanstack-mcp-bridge-${runId}.json`
373
+ const mcpConfigPath = `${cwd}/${mcpConfigFile}`
374
+ await sandbox.fs.write(mcpConfigPath, bridgeToMcpConfig(bridge))
375
+ tempFiles.push(mcpConfigPath)
376
+ mcpConfigArg = mcpConfigFile
377
+ }
378
+ const command = this.buildCommand(
379
+ options,
380
+ resume,
381
+ mapPolicyToClaudeFlags(policy),
382
+ mcpConfigArg,
383
+ bridge && permission
384
+ ? `mcp__${bridge.name}__${permission.toolName}`
385
+ : undefined,
386
+ )
387
+
388
+ // Deliver the prompt. The default feeds it over stdin (keeps it out of
389
+ // argv). Providers without a writable host→process stdin (e.g. Cloudflare)
390
+ // can't accept that write, so write the prompt to a file and redirect the
391
+ // CLI's stdin from it in-shell (`claude -p … < file`) — still out of argv.
392
+ let runCommand = command
393
+ let stdinInput: string | undefined = prompt
394
+ if (sandbox.capabilities.writableStdin === false) {
395
+ const promptPath = `/tmp/tanstack-claude-prompt-${runId}`
396
+ await sandbox.fs.write(promptPath, prompt)
397
+ tempFiles.push(promptPath)
398
+ runCommand = `${command} < ${q(promptPath)}`
399
+ stdinInput = undefined
400
+ }
401
+
402
+ logger.request(
403
+ `activity=chat provider=claude-code model=${this.model} sandbox=${sandbox.provider} messages=${options.messages.length} resume=${resume ?? 'none'}`,
404
+ { provider: 'claude-code', model: this.model },
405
+ )
406
+
407
+ const rawEvents = spawnNdjson(sandbox, runCommand, {
408
+ cwd,
409
+ ...(stdinInput !== undefined ? { input: stdinInput } : {}),
410
+ // claude maps `bypassPermissions` to `--dangerously-skip-permissions`,
411
+ // which it refuses to run as root. Sandbox containers routinely run as
412
+ // root (Docker / Cloudflare), so set `IS_SANDBOX=1` — claude's
413
+ // documented escape hatch for skip-permissions in an isolated
414
+ // environment — merged over the sandbox env (a caller-provided value
415
+ // wins). Safe to set unconditionally; it is a no-op for stricter modes.
416
+ env: { IS_SANDBOX: '1', ...this.adapterConfig.env },
417
+ ...(options.abortController?.signal
418
+ ? { signal: options.abortController.signal }
419
+ : options.request?.signal
420
+ ? { signal: options.request.signal }
421
+ : {}),
422
+ onNonJsonLine: (line) =>
423
+ logger.provider(`provider=claude-code non-json line: ${line}`, {
424
+ chunk: line,
425
+ }),
426
+ })
427
+
428
+ async function* asMessages(): AsyncIterable<AgentSdkMessage> {
429
+ for await (const event of rawEvents) yield event as AgentSdkMessage
430
+ }
431
+
432
+ yield* mergeChunkStreams(
433
+ translateSdkStream(asMessages(), {
434
+ model: this.model,
435
+ runId,
436
+ threadId,
437
+ ...(options.parentRunId !== undefined && {
438
+ parentRunId: options.parentRunId,
439
+ }),
440
+ genId: () => this.generateId(),
441
+ onSdkMessage: (message) =>
442
+ logger.provider(`provider=claude-code type=${message.type}`, {
443
+ chunk: message,
444
+ }),
445
+ }),
446
+ channel.stream,
447
+ )
448
+
449
+ // Surface the working-tree diff so UIs can render what the agent changed.
450
+ if (this.adapterConfig.emitDiff !== false) {
451
+ try {
452
+ const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, {
453
+ cwd,
454
+ })
455
+ if (diff.exitCode === 0 && diff.stdout.trim() !== '') {
456
+ yield {
457
+ type: EventType.CUSTOM,
458
+ name: 'file.changed',
459
+ value: { path: '.', diff: diff.stdout },
460
+ timestamp: Date.now(),
461
+ threadId,
462
+ runId,
463
+ }
464
+ }
465
+ } catch {
466
+ // not a git repo / git unavailable — skip the diff event
467
+ }
468
+ }
469
+
470
+ // Surface any pending approval requests (policy `ask` actions awaiting a
471
+ // client decision); the client approves and re-runs to continue.
472
+ for (const event of approvalRequests) yield event
473
+ } catch (error: unknown) {
474
+ const err = error as Error & { code?: string }
475
+ const rawEvent = toRunErrorRawEvent(error)
476
+ logger.errors('claude-code.chatStream fatal', {
477
+ error,
478
+ source: 'claude-code.chatStream',
479
+ })
480
+ yield {
481
+ type: EventType.RUN_ERROR,
482
+ model: options.model,
483
+ timestamp: Date.now(),
484
+ message: err.message || 'Unknown error occurred',
485
+ ...(err.code !== undefined && { code: err.code }),
486
+ ...(rawEvent !== undefined && { rawEvent }),
487
+ error: {
488
+ message: err.message || 'Unknown error occurred',
489
+ ...(err.code !== undefined && { code: err.code }),
490
+ },
491
+ }
492
+ } finally {
493
+ channel?.close()
494
+ if (bridge) await bridge.close()
495
+ // Remove the per-run token/prompt files. Best-effort: a cleanup failure
496
+ // must not mask the run's own outcome.
497
+ if (cleanupSandbox) {
498
+ for (const path of tempFiles) {
499
+ try {
500
+ await cleanupSandbox.fs.remove(path)
501
+ } catch {
502
+ // file already gone / sandbox torn down — nothing to clean up
503
+ }
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ structuredOutput(
510
+ _options: StructuredOutputOptions<ClaudeCodeTextProviderOptions>,
511
+ ): Promise<StructuredOutputResult<unknown>> {
512
+ return Promise.reject(
513
+ new Error(
514
+ 'Structured output is not yet supported by the in-sandbox Claude Code adapter. ' +
515
+ 'Use a model adapter (e.g. anthropic) for structured output, or omit outputSchema.',
516
+ ),
517
+ )
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Creates a Claude Code harness adapter that runs **inside a sandbox**.
523
+ *
524
+ * Unlike HTTP provider adapters, this is a *harness* adapter: it spawns the
525
+ * `claude` CLI inside the sandbox provided by `withSandbox(...)` (the adapter
526
+ * declares `requires: [SandboxCapability]`), streams its `stream-json` stdout
527
+ * back as AG-UI events, and lets Claude Code run its own agent loop and native
528
+ * tools (Bash, file edits, search, …) against the sandbox workspace. The
529
+ * sandbox image must provide the `claude` executable and `ANTHROPIC_API_KEY`
530
+ * in its environment (e.g. via `workspace.secrets`). The session id is
531
+ * surfaced via a CUSTOM `claude-code.session-id` event so follow-up calls can
532
+ * resume through `modelOptions.sessionId`.
533
+ */
534
+ export function claudeCodeText<TModel extends ClaudeCodeModel>(
535
+ model: TModel,
536
+ config: ClaudeCodeTextConfig = {},
537
+ ): ClaudeCodeTextAdapter<TModel> {
538
+ return new ClaudeCodeTextAdapter(config, model)
539
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export { ClaudeCodeTextAdapter, claudeCodeText } from './adapters/text'
2
+ export type {
3
+ ClaudeCodeTextConfig,
4
+ ClaudeCodePermissionMode,
5
+ } from './adapters/text'
6
+ export type { ClaudeCodeTextProviderOptions } from './provider-options'
7
+ export { CLAUDE_CODE_MODELS } from './model-meta'
8
+ export type { ClaudeCodeModel, KnownClaudeCodeModel } from './model-meta'
9
+ export {
10
+ SESSION_ID_EVENT,
11
+ BRIDGED_MCP_SERVER_NAME,
12
+ translateSdkStream,
13
+ stripMcpPrefix,
14
+ } from './stream/translate'
15
+ export type {
16
+ ClaudeCodeProviderUsageDetails,
17
+ TranslateContext,
18
+ } from './stream/translate'
19
+ export type { AgentSdkMessage } from './stream/sdk-types'
20
+ export { buildPrompt } from './messages/prompt'
21
+ export type { BuiltPrompt } from './messages/prompt'
@@ -0,0 +1,68 @@
1
+ import type { ModelMessage } from '@tanstack/ai'
2
+
3
+ export interface BuiltPrompt {
4
+ prompt: string
5
+ /** Claude Code session id to resume, when the caller threaded one through. */
6
+ resume?: string
7
+ }
8
+
9
+ function extractText(content: ModelMessage['content']): string {
10
+ if (content === null) return ''
11
+ if (typeof content === 'string') return content
12
+ return content
13
+ .map((part) =>
14
+ part.type === 'text' && typeof part.content === 'string'
15
+ ? part.content
16
+ : '',
17
+ )
18
+ .join('')
19
+ }
20
+
21
+ /**
22
+ * Convert TanStack chat history into the Agent SDK's `{ prompt, resume }`
23
+ * inputs.
24
+ *
25
+ * With a `sessionId`, the harness already holds the conversation context, so
26
+ * only the trailing user message is sent and the session is resumed. Without
27
+ * one, prior turns are flattened into a plain-text transcript preamble (tool
28
+ * messages and tool-call-only assistant turns are harness-internal noise and
29
+ * are skipped; prompts are text-only in v1).
30
+ */
31
+ export function buildPrompt(
32
+ messages: Array<ModelMessage>,
33
+ sessionId: string | undefined,
34
+ ): BuiltPrompt {
35
+ const lastMessage = messages.at(-1)
36
+ const lastUserText =
37
+ lastMessage?.role === 'user' ? extractText(lastMessage.content).trim() : ''
38
+
39
+ if (!lastUserText) {
40
+ throw new Error(
41
+ 'Claude Code adapter requires a trailing user message with text content.',
42
+ )
43
+ }
44
+
45
+ if (sessionId !== undefined) {
46
+ return { prompt: lastUserText, resume: sessionId }
47
+ }
48
+
49
+ const priorTurns = messages
50
+ .slice(0, -1)
51
+ .filter(
52
+ (message) =>
53
+ (message.role === 'user' || message.role === 'assistant') &&
54
+ extractText(message.content).trim() !== '',
55
+ )
56
+ .map(
57
+ (message) =>
58
+ `${message.role === 'user' ? 'User' : 'Assistant'}: ${extractText(message.content).trim()}`,
59
+ )
60
+
61
+ if (priorTurns.length === 0) {
62
+ return { prompt: lastUserText }
63
+ }
64
+
65
+ return {
66
+ prompt: `Previous conversation:\n${priorTurns.join('\n')}\n\n${lastUserText}`,
67
+ }
68
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Models known to work with Claude Code. The harness accepts any Anthropic
3
+ * model id (and the `opus` / `sonnet` / `haiku` aliases resolved by the CLI),
4
+ * so this list exists for autocomplete — any string is accepted via the
5
+ * `(string & {})` escape hatch in {@link ClaudeCodeModel}.
6
+ */
7
+ export const CLAUDE_CODE_MODELS = [
8
+ 'claude-opus-4-8',
9
+ 'claude-opus-4-7',
10
+ 'claude-opus-4-6',
11
+ 'claude-sonnet-4-6',
12
+ 'claude-haiku-4-5',
13
+ 'opus',
14
+ 'sonnet',
15
+ 'haiku',
16
+ ] as const
17
+
18
+ export type KnownClaudeCodeModel = (typeof CLAUDE_CODE_MODELS)[number]
19
+
20
+ /** Any Claude model id accepted by Claude Code; known ids get autocomplete. */
21
+ export type ClaudeCodeModel = KnownClaudeCodeModel | (string & {})
@@ -0,0 +1,30 @@
1
+ type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
2
+
3
+ /**
4
+ * Per-call provider options for the Claude Code adapter, passed via
5
+ * `modelOptions` on `chat()`.
6
+ */
7
+ export interface ClaudeCodeTextProviderOptions {
8
+ /**
9
+ * Resume an existing Claude Code session. The adapter emits the session id
10
+ * of every run via a CUSTOM `claude-code.session-id` stream event; thread
11
+ * it back here to continue that session (only the latest user message is
12
+ * sent — the harness already holds the prior context).
13
+ */
14
+ sessionId?: string
15
+ /**
16
+ * When resuming, fork to a new session id instead of continuing the
17
+ * original session.
18
+ */
19
+ forkSession?: boolean
20
+ /** Per-call override of the configured max harness turns. */
21
+ maxTurns?: number
22
+ /** Per-call override of the configured permission mode. */
23
+ permissionMode?: PermissionMode
24
+ /** Per-call override of the allowed built-in tool list. */
25
+ allowedTools?: Array<string>
26
+ /** Per-call override of the disallowed built-in tool list. */
27
+ disallowedTools?: Array<string>
28
+ /** Per-call override of the harness working directory. */
29
+ cwd?: string
30
+ }