@tanstack/ai-acp 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.
Files changed (54) hide show
  1. package/README.md +428 -0
  2. package/dist/esm/adapters/compatible.d.ts +206 -0
  3. package/dist/esm/adapters/compatible.js +313 -0
  4. package/dist/esm/adapters/compatible.js.map +1 -0
  5. package/dist/esm/adapters/projection.d.ts +29 -0
  6. package/dist/esm/adapters/projection.js +83 -0
  7. package/dist/esm/adapters/projection.js.map +1 -0
  8. package/dist/esm/index.d.ts +21 -0
  9. package/dist/esm/index.js +35 -0
  10. package/dist/esm/index.js.map +1 -0
  11. package/dist/esm/messages/prompt.d.ts +19 -0
  12. package/dist/esm/messages/prompt.js +37 -0
  13. package/dist/esm/messages/prompt.js.map +1 -0
  14. package/dist/esm/permissions.d.ts +8 -0
  15. package/dist/esm/permissions.js +44 -0
  16. package/dist/esm/permissions.js.map +1 -0
  17. package/dist/esm/session/acp-client.d.ts +37 -0
  18. package/dist/esm/session/acp-client.js +140 -0
  19. package/dist/esm/session/acp-client.js.map +1 -0
  20. package/dist/esm/session/sandbox-server.d.ts +46 -0
  21. package/dist/esm/session/sandbox-server.js +114 -0
  22. package/dist/esm/session/sandbox-server.js.map +1 -0
  23. package/dist/esm/stream/queue.d.ts +15 -0
  24. package/dist/esm/stream/queue.js +54 -0
  25. package/dist/esm/stream/queue.js.map +1 -0
  26. package/dist/esm/stream/translate.d.ts +38 -0
  27. package/dist/esm/stream/translate.js +320 -0
  28. package/dist/esm/stream/translate.js.map +1 -0
  29. package/dist/esm/transport/resolve.d.ts +7 -0
  30. package/dist/esm/transport/resolve.js +34 -0
  31. package/dist/esm/transport/resolve.js.map +1 -0
  32. package/dist/esm/transport/stdio.d.ts +3 -0
  33. package/dist/esm/transport/stdio.js +51 -0
  34. package/dist/esm/transport/stdio.js.map +1 -0
  35. package/dist/esm/transport/types.d.ts +29 -0
  36. package/dist/esm/transport/websocket.d.ts +17 -0
  37. package/dist/esm/transport/websocket.js +135 -0
  38. package/dist/esm/transport/websocket.js.map +1 -0
  39. package/dist/esm/types/acp-types.d.ts +75 -0
  40. package/package.json +61 -0
  41. package/src/adapters/compatible.ts +675 -0
  42. package/src/adapters/projection.ts +164 -0
  43. package/src/index.ts +85 -0
  44. package/src/messages/prompt.ts +71 -0
  45. package/src/permissions.ts +71 -0
  46. package/src/session/acp-client.ts +238 -0
  47. package/src/session/sandbox-server.ts +195 -0
  48. package/src/stream/queue.ts +61 -0
  49. package/src/stream/translate.ts +412 -0
  50. package/src/transport/resolve.ts +46 -0
  51. package/src/transport/stdio.ts +63 -0
  52. package/src/transport/types.ts +33 -0
  53. package/src/transport/websocket.ts +192 -0
  54. package/src/types/acp-types.ts +79 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Generic workspace projector for ACP harnesses.
3
+ *
4
+ * `withSandbox` surfaces a portable {@link WorkspaceProjection} (skills, plugins,
5
+ * a secret resolver, a one-time marker path) via a capability. Most of it maps
6
+ * onto ACP natively:
7
+ *
8
+ * - **MCP skills** → passed straight through ACP's `newSession` `mcpServers`
9
+ * (see {@link workspaceMcpServers}); no config file is written, because an
10
+ * ACP agent receives MCP servers over the protocol. This is the key
11
+ * difference from file-based harnesses (Claude Code, Codex) that read MCP
12
+ * from disk.
13
+ * - **gitSkill repos** → linked into the harness's skills directory (when the
14
+ * harness declares one via `skillsDir`, e.g. `.pi/skills`).
15
+ * - **agentSkill / plugins** → no generic ACP primitive, so we warn-and-skip.
16
+ *
17
+ * `fileSkill` and `instructions` are already written by the provider-agnostic
18
+ * bootstrap (into the workspace root + `AGENTS.md`), so they need no projection.
19
+ */
20
+ import { isSecretRef, resolveGitSkillDir } from '@tanstack/ai-sandbox'
21
+ import type {
22
+ BearerRef,
23
+ SandboxHandle,
24
+ SecretRef,
25
+ WorkspaceProjection,
26
+ } from '@tanstack/ai-sandbox'
27
+
28
+ /** ACP `newSession` MCP server descriptor (HTTP transport). */
29
+ export interface AcpMcpServer {
30
+ name: string
31
+ url: string
32
+ headers: Array<{ name: string; value: string }>
33
+ }
34
+
35
+ function shellQuote(value: string): string {
36
+ return `'${value.replace(/'/g, `'\\''`)}'`
37
+ }
38
+
39
+ function basenameOf(path: string): string {
40
+ const segments = path.split('/').filter((segment) => segment !== '')
41
+ return segments[segments.length - 1] ?? path
42
+ }
43
+
44
+ /**
45
+ * Make a sandbox path relative to the workspace root, so shell commands work on
46
+ * every provider. Only `fs.*` remaps the virtual `/workspace`; a raw `/workspace`
47
+ * in a shell command is the real path in a container but a non-existent absolute
48
+ * path on local-process. Running relative to the root (the exec cwd) is correct
49
+ * everywhere.
50
+ */
51
+ function relativeToRoot(root: string, p: string): string {
52
+ return p.startsWith(`${root}/`) ? p.slice(root.length + 1) : p
53
+ }
54
+
55
+ function isBearerMarker(value: unknown): value is BearerRef {
56
+ return (
57
+ typeof value === 'object' &&
58
+ value !== null &&
59
+ isSecretRef((value as { __bearerRef?: unknown }).__bearerRef)
60
+ )
61
+ }
62
+
63
+ function resolveHeaderValue(
64
+ value: string | SecretRef | BearerRef,
65
+ resolveSecret: (ref: SecretRef) => string,
66
+ ): string {
67
+ if (isSecretRef(value)) return resolveSecret(value)
68
+ if (isBearerMarker(value)) return `Bearer ${resolveSecret(value.__bearerRef)}`
69
+ return value
70
+ }
71
+
72
+ /**
73
+ * Build ACP `mcpServers` entries from the workspace's `mcp` skills, resolving
74
+ * every header value (SecretRef / bearer / plain string). Returns `[]` when
75
+ * there are no MCP skills. Pass the result to `startAcpSession({ mcpServers })`.
76
+ */
77
+ export function workspaceMcpServers(
78
+ projection: WorkspaceProjection,
79
+ ): Array<AcpMcpServer> {
80
+ const servers: Array<AcpMcpServer> = []
81
+ for (const skill of projection.skills) {
82
+ if (skill.kind !== 'mcp') continue
83
+ const rawUrl = skill.config['url']
84
+ const url = typeof rawUrl === 'string' ? rawUrl : ''
85
+ const headers = Object.entries(skill.config.headers ?? {}).map(
86
+ ([name, value]) => ({
87
+ name,
88
+ value: resolveHeaderValue(value, projection.resolveSecret),
89
+ }),
90
+ )
91
+ servers.push({ name: skill.name, url, headers })
92
+ }
93
+ return servers
94
+ }
95
+
96
+ /**
97
+ * Project the non-MCP parts of a workspace into an ACP harness. Links each
98
+ * cloned `gitSkill` repo into the harness's skills directory when one is
99
+ * declared; warns and skips `agentSkill`s, `plugin`s, and (when no `skillsDir`
100
+ * is configured) `gitSkill`s. Idempotent — gated by the projection marker.
101
+ *
102
+ * MCP skills are NOT handled here; pass {@link workspaceMcpServers} to the ACP
103
+ * session instead.
104
+ */
105
+ export async function projectAcpWorkspace(
106
+ handle: SandboxHandle,
107
+ projection: WorkspaceProjection,
108
+ options: { skillsDir?: string; harnessName: string },
109
+ ): Promise<void> {
110
+ // Idempotent, non-secret operations only — gate on the one-time marker.
111
+ if (await handle.fs.exists(projection.markerPath)) return
112
+
113
+ const { skillsDir, harnessName } = options
114
+ const gitSkills = projection.skills.filter((skill) => skill.kind === 'git')
115
+
116
+ if (gitSkills.length > 0) {
117
+ if (skillsDir === undefined) {
118
+ for (const skill of gitSkills) {
119
+ console.warn(
120
+ `[${harnessName}] gitSkill "${skill.repo}" cannot be projected: this ` +
121
+ 'harness declares no `skillsDir`. The clone is still available under ' +
122
+ 'the workspace, but the harness will not auto-discover it. Skipping link.',
123
+ )
124
+ }
125
+ } else {
126
+ // Create the skills dir via fs (which remaps the virtual root), then copy
127
+ // each clone in with paths relative to the exec cwd (the workspace root)
128
+ // so the shell command resolves on every provider.
129
+ await handle.fs.mkdir(`${projection.root}/${skillsDir}`)
130
+ for (const skill of gitSkills) {
131
+ const source = skill.into ?? resolveGitSkillDir(projection.root, skill)
132
+ const relSource = relativeToRoot(projection.root, source)
133
+ const relTarget = `${skillsDir}/${basenameOf(source)}`
134
+ const cp = await handle.process.exec(
135
+ `cp -r ${shellQuote(relSource)} ${shellQuote(relTarget)}`,
136
+ { cwd: projection.root },
137
+ )
138
+ if (cp.exitCode !== 0) {
139
+ console.warn(
140
+ `[${harnessName}] failed to copy gitSkill "${skill.repo}" into ${relTarget}: ${cp.stderr.trim()}`,
141
+ )
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ for (const skill of projection.skills) {
148
+ if (skill.kind === 'agent-skill') {
149
+ console.warn(
150
+ `[${harnessName}] agentSkill "${skill.name}" cannot be projected: there is ` +
151
+ 'no generic ACP primitive to install a skill by bare name. Provide it as ' +
152
+ 'a gitSkill instead. Skipping.',
153
+ )
154
+ }
155
+ }
156
+ for (const name of projection.plugins) {
157
+ console.warn(
158
+ `[${harnessName}] plugin "${name}" cannot be projected: ACP has no generic ` +
159
+ 'plugin concept. Provide its functionality as a gitSkill or MCP server. Skipping.',
160
+ )
161
+ }
162
+
163
+ await handle.fs.write(projection.markerPath, '')
164
+ }
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ export type {
2
+ AcpContentBlock,
3
+ AcpPermissionMode,
4
+ AcpPermissionOption,
5
+ AcpPermissionOutcome,
6
+ AcpPermissionRequest,
7
+ AcpSessionUpdate,
8
+ AcpStopReason,
9
+ AcpToolCallStatus,
10
+ AcpToolCallUpdate,
11
+ AcpUsage,
12
+ PermissionHandler,
13
+ } from './types/acp-types'
14
+
15
+ export { AsyncQueue } from './stream/queue'
16
+
17
+ export {
18
+ BRIDGED_MCP_SERVER_NAME,
19
+ matchBridgedToolName,
20
+ translateAcpStream,
21
+ } from './stream/translate'
22
+ export type {
23
+ AcpStreamEvent,
24
+ AcpTranslateLabels,
25
+ TranslateContext,
26
+ } from './stream/translate'
27
+
28
+ export type {
29
+ AcpByteTransport,
30
+ AcpJsonRpcStream,
31
+ AcpMessageFraming,
32
+ AcpSessionTransport,
33
+ AcpTransportPreference,
34
+ } from './transport/types'
35
+
36
+ export { spawnHandleToAcpTransport } from './transport/stdio'
37
+ export type { AcpByteTransport as AcpTransport } from './transport/types'
38
+
39
+ export {
40
+ connectAcpWebSocket,
41
+ httpChannelUrlToWsBase,
42
+ webSocketFrameToAcpStream,
43
+ } from './transport/websocket'
44
+ export type {
45
+ AcpWebSocketConnection,
46
+ ConnectAcpWebSocketOptions,
47
+ } from './transport/websocket'
48
+
49
+ export { resolveAcpTransportMode } from './transport/resolve'
50
+
51
+ export {
52
+ buildGrokServeWebSocketUrl,
53
+ parseWebSocketUrlFromServeOutput,
54
+ startAcpServerInSandbox,
55
+ } from './session/sandbox-server'
56
+ export type {
57
+ AcpSandboxServer,
58
+ StartAcpServerOptions,
59
+ } from './session/sandbox-server'
60
+
61
+ export { startAcpSession } from './session/acp-client'
62
+ export type {
63
+ AcpSessionHandle,
64
+ StartAcpSessionOptions,
65
+ } from './session/acp-client'
66
+
67
+ export { resolveInteractivePermission, resolvePermission } from './permissions'
68
+
69
+ export {
70
+ AcpCompatibleTextAdapter,
71
+ acpCompatible,
72
+ acpCompatibleText,
73
+ } from './adapters/compatible'
74
+ export type {
75
+ AcpCompatibleConfig,
76
+ AcpCompatibleProviderOptions,
77
+ AcpHarnessContext,
78
+ AcpModelNameOf,
79
+ } from './adapters/compatible'
80
+
81
+ export { buildAcpPrompt } from './messages/prompt'
82
+ export type { BuiltAcpPrompt } from './messages/prompt'
83
+
84
+ export { projectAcpWorkspace, workspaceMcpServers } from './adapters/projection'
85
+ export type { AcpMcpServer } from './adapters/projection'
@@ -0,0 +1,71 @@
1
+ import type { ModelMessage } from '@tanstack/ai'
2
+
3
+ export interface BuiltAcpPrompt {
4
+ prompt: string
5
+ /** Harness 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 a harness prompt + resume inputs.
23
+ *
24
+ * With a `sessionId`, the harness already holds the conversation context, so
25
+ * only the trailing user message is sent and the session is resumed. Without
26
+ * one, prior user/assistant turns are flattened into a plain-text transcript
27
+ * preamble (tool messages and tool-call-only assistant turns are
28
+ * harness-internal noise and are skipped; prompts are text-only).
29
+ *
30
+ * Shared default for ACP harness adapters; pass a custom `buildPrompt` to
31
+ * {@link acpCompatible} to override.
32
+ */
33
+ export function buildAcpPrompt(
34
+ messages: Array<ModelMessage>,
35
+ sessionId: string | undefined,
36
+ harnessName = 'ACP harness',
37
+ ): BuiltAcpPrompt {
38
+ const lastMessage = messages.at(-1)
39
+ const lastUserText =
40
+ lastMessage?.role === 'user' ? extractText(lastMessage.content).trim() : ''
41
+
42
+ if (!lastUserText) {
43
+ throw new Error(
44
+ `${harnessName} adapter requires a trailing user message with text content.`,
45
+ )
46
+ }
47
+
48
+ if (sessionId !== undefined) {
49
+ return { prompt: lastUserText, resume: sessionId }
50
+ }
51
+
52
+ const priorTurns = messages
53
+ .slice(0, -1)
54
+ .filter(
55
+ (message) =>
56
+ (message.role === 'user' || message.role === 'assistant') &&
57
+ extractText(message.content).trim() !== '',
58
+ )
59
+ .map(
60
+ (message) =>
61
+ `${message.role === 'user' ? 'User' : 'Assistant'}: ${extractText(message.content).trim()}`,
62
+ )
63
+
64
+ if (priorTurns.length === 0) {
65
+ return { prompt: lastUserText }
66
+ }
67
+
68
+ return {
69
+ prompt: `Previous conversation:\n${priorTurns.join('\n')}\n\n${lastUserText}`,
70
+ }
71
+ }
@@ -0,0 +1,71 @@
1
+ import { approvalId } from '@tanstack/ai-sandbox'
2
+ import { matchBridgedToolName } from './stream/translate'
3
+ import type {
4
+ AcpPermissionMode,
5
+ AcpPermissionOutcome,
6
+ AcpPermissionRequest,
7
+ PermissionHandler,
8
+ } from './types/acp-types'
9
+
10
+ export type { AcpPermissionMode, PermissionHandler }
11
+
12
+ const EDIT_KINDS = new Set(['edit', 'move', 'delete'])
13
+
14
+ function pickOption(
15
+ request: AcpPermissionRequest,
16
+ kinds: Array<string>,
17
+ ): AcpPermissionOutcome {
18
+ for (const kind of kinds) {
19
+ const option = request.options.find((candidate) => candidate.kind === kind)
20
+ if (option) return { outcome: 'selected', optionId: option.optionId }
21
+ }
22
+ return { outcome: 'cancelled' }
23
+ }
24
+
25
+ export function resolvePermission(
26
+ request: AcpPermissionRequest,
27
+ mode: AcpPermissionMode,
28
+ bridgedToolNames: ReadonlySet<string> | undefined,
29
+ ): AcpPermissionOutcome {
30
+ const allow = () => pickOption(request, ['allow_once', 'allow_always'])
31
+ const reject = () => pickOption(request, ['reject_once', 'reject_always'])
32
+
33
+ if (
34
+ matchBridgedToolName(request.toolCall.title, bridgedToolNames) !== undefined
35
+ ) {
36
+ return allow()
37
+ }
38
+ if (mode === 'bypassPermissions') return allow()
39
+ if (mode === 'acceptEdits' && EDIT_KINDS.has(request.toolCall.kind ?? '')) {
40
+ return allow()
41
+ }
42
+ return reject()
43
+ }
44
+
45
+ export function resolveInteractivePermission(
46
+ request: AcpPermissionRequest,
47
+ mode: AcpPermissionMode,
48
+ bridgedToolNames: ReadonlySet<string> | undefined,
49
+ approvals: ReadonlyMap<string, boolean> | undefined,
50
+ provider: string,
51
+ ): { outcome: AcpPermissionOutcome; approvalId?: string; title?: string } {
52
+ const allow = (): AcpPermissionOutcome =>
53
+ pickOption(request, ['allow_once', 'allow_always'])
54
+ const reject = (): AcpPermissionOutcome =>
55
+ pickOption(request, ['reject_once', 'reject_always'])
56
+ const title = request.toolCall.title ?? request.toolCall.toolCallId
57
+
58
+ if (matchBridgedToolName(title, bridgedToolNames) !== undefined) {
59
+ return { outcome: allow() }
60
+ }
61
+ if (mode === 'bypassPermissions') return { outcome: allow() }
62
+ if (mode === 'acceptEdits' && EDIT_KINDS.has(request.toolCall.kind ?? '')) {
63
+ return { outcome: allow() }
64
+ }
65
+
66
+ const id = approvalId({ provider, kind: 'tool', target: title })
67
+ const granted = approvals?.get(id)
68
+ if (granted === true) return { outcome: allow() }
69
+ if (granted === false) return { outcome: reject() }
70
+ return { outcome: reject(), approvalId: id, title }
71
+ }
@@ -0,0 +1,238 @@
1
+ import {
2
+ ClientSideConnection,
3
+ PROTOCOL_VERSION,
4
+ ndJsonStream,
5
+ } from '@agentclientprotocol/sdk'
6
+ import { spawnHandleToAcpTransport } from '../transport/stdio'
7
+ import type {
8
+ Client,
9
+ McpServer,
10
+ RequestPermissionRequest,
11
+ RequestPermissionResponse,
12
+ SessionNotification,
13
+ } from '@agentclientprotocol/sdk'
14
+ import type {
15
+ AcpPermissionOutcome,
16
+ AcpPermissionRequest,
17
+ AcpSessionUpdate,
18
+ AcpStopReason,
19
+ AcpUsage,
20
+ } from '../types/acp-types'
21
+ import type { AcpJsonRpcStream, AcpSessionTransport } from '../transport/types'
22
+
23
+ /**
24
+ * Identifies this client to the agent in the ACP `initialize` handshake. The
25
+ * `version` is informational (sent in `clientInfo`); keep it loosely in step
26
+ * with the package version. `clientInfo` is currently optional in the protocol
27
+ * but will become required, so we always send it.
28
+ * ponytail: hardcoded — clientInfo is diagnostic; a stale patch version is harmless.
29
+ */
30
+ const CLIENT_INFO = {
31
+ name: '@tanstack/ai-acp',
32
+ title: 'TanStack AI',
33
+ version: '0.1.0',
34
+ } as const
35
+
36
+ export interface AcpSessionHandle {
37
+ sessionId: string
38
+ resumed: boolean
39
+ prompt: (
40
+ text: string,
41
+ ) => Promise<{ stopReason: AcpStopReason; usage?: AcpUsage }>
42
+ cancel: () => Promise<void>
43
+ dispose: () => Promise<void>
44
+ }
45
+
46
+ export interface StartAcpSessionOptions {
47
+ transport: AcpSessionTransport
48
+ cwd: string
49
+ authMethodId?: string
50
+ mcpServers?: Array<{
51
+ name: string
52
+ url: string
53
+ headers?: Array<{ name: string; value: string }>
54
+ }>
55
+ resumeSessionId?: string
56
+ onUpdate: (update: AcpSessionUpdate) => void
57
+ onPermissionRequest: (
58
+ request: AcpPermissionRequest,
59
+ ) => Promise<AcpPermissionOutcome> | AcpPermissionOutcome
60
+ /**
61
+ * Harness-specific JSON-RPC notifications (e.g. Grok `_x.ai/session_notification`).
62
+ * Return without throwing — unknown vendor extensions must not tear down the session.
63
+ */
64
+ onExtNotification?: (method: string, params: Record<string, unknown>) => void
65
+ }
66
+
67
+ function streamFromTransport(transport: AcpSessionTransport): {
68
+ stream: AcpJsonRpcStream
69
+ teardown: () => Promise<void>
70
+ exited: Promise<never> | undefined
71
+ stderrTail: () => string
72
+ } {
73
+ if (transport.kind === 'stdio') {
74
+ const byteTransport = spawnHandleToAcpTransport(transport.process)
75
+ return {
76
+ stream: ndJsonStream(byteTransport.writable, byteTransport.readable),
77
+ teardown: () => byteTransport.kill(),
78
+ exited: byteTransport.exited,
79
+ stderrTail: byteTransport.stderrTail,
80
+ }
81
+ }
82
+
83
+ return {
84
+ stream: transport.stream,
85
+ teardown: transport.dispose,
86
+ exited: undefined,
87
+ stderrTail: transport.stderrTail ?? (() => ''),
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Drive an ACP harness over stdio or a pre-connected JSON-RPC stream.
93
+ */
94
+ export async function startAcpSession(
95
+ options: StartAcpSessionOptions,
96
+ ): Promise<AcpSessionHandle> {
97
+ const { stream, teardown, exited, stderrTail } = streamFromTransport(
98
+ options.transport,
99
+ )
100
+
101
+ let replaying = false
102
+
103
+ const client: Client = {
104
+ requestPermission: async (
105
+ params: RequestPermissionRequest,
106
+ ): Promise<RequestPermissionResponse> => {
107
+ const outcome = await options.onPermissionRequest(params)
108
+ return { outcome }
109
+ },
110
+ sessionUpdate: (params: SessionNotification): Promise<void> => {
111
+ if (!replaying) {
112
+ options.onUpdate(params.update as AcpSessionUpdate)
113
+ }
114
+ return Promise.resolve()
115
+ },
116
+ extNotification: (method, params) => {
117
+ options.onExtNotification?.(method, params)
118
+ return Promise.resolve()
119
+ },
120
+ }
121
+
122
+ const race = <T>(work: Promise<T>): Promise<T> =>
123
+ exited !== undefined ? Promise.race([work, exited]) : work
124
+
125
+ try {
126
+ const connection = new ClientSideConnection(() => client, stream)
127
+
128
+ const initResult = await race(
129
+ connection.initialize({
130
+ protocolVersion: PROTOCOL_VERSION,
131
+ clientInfo: CLIENT_INFO,
132
+ clientCapabilities: {
133
+ // The agent runs inside the sandbox with direct filesystem + shell
134
+ // access, so it never needs to delegate file/terminal I/O back to the
135
+ // client. We advertise these as unsupported; per the ACP spec the
136
+ // agent MUST then treat them as unavailable and not call them.
137
+ fs: { readTextFile: false, writeTextFile: false },
138
+ },
139
+ }),
140
+ )
141
+
142
+ // Protocol-version negotiation: the agent echoes the version it will speak.
143
+ // The spec says a client SHOULD close the connection if that version is one
144
+ // it doesn't support. We only implement the current PROTOCOL_VERSION, so a
145
+ // higher number means the agent needs a newer client than this one.
146
+ if (
147
+ typeof initResult.protocolVersion === 'number' &&
148
+ initResult.protocolVersion > PROTOCOL_VERSION
149
+ ) {
150
+ throw new Error(
151
+ `ACP agent negotiated protocol version ${initResult.protocolVersion}, ` +
152
+ `but this client supports up to ${PROTOCOL_VERSION}. Update @tanstack/ai-acp.`,
153
+ )
154
+ }
155
+
156
+ if (options.authMethodId !== undefined) {
157
+ const available = initResult.authMethods ?? []
158
+ if (!available.some((method) => method.id === options.authMethodId)) {
159
+ throw new Error(
160
+ `Harness does not advertise the ACP auth method '${options.authMethodId}'. Available: ${
161
+ available.map((method) => method.id).join(', ') || '(none)'
162
+ }.`,
163
+ )
164
+ }
165
+ await race(connection.authenticate({ methodId: options.authMethodId }))
166
+ }
167
+
168
+ const mcpServers: Array<McpServer> = (options.mcpServers ?? []).map(
169
+ (server) => ({
170
+ type: 'http' as const,
171
+ name: server.name,
172
+ url: server.url,
173
+ headers: server.headers ?? [],
174
+ }),
175
+ )
176
+
177
+ let sessionId: string | undefined
178
+ let resumed = false
179
+ if (
180
+ options.resumeSessionId !== undefined &&
181
+ initResult.agentCapabilities?.loadSession === true
182
+ ) {
183
+ replaying = true
184
+ try {
185
+ await race(
186
+ connection.loadSession({
187
+ sessionId: options.resumeSessionId,
188
+ cwd: options.cwd,
189
+ mcpServers,
190
+ }),
191
+ )
192
+ sessionId = options.resumeSessionId
193
+ resumed = true
194
+ } catch {
195
+ // Session unknown — fall through to a fresh one.
196
+ } finally {
197
+ replaying = false
198
+ }
199
+ }
200
+
201
+ if (sessionId === undefined) {
202
+ const session = await race(
203
+ connection.newSession({ cwd: options.cwd, mcpServers }),
204
+ )
205
+ sessionId = session.sessionId
206
+ }
207
+
208
+ return {
209
+ sessionId,
210
+ resumed,
211
+ prompt: async (text: string) => {
212
+ const response = await race(
213
+ connection.prompt({
214
+ sessionId,
215
+ prompt: [{ type: 'text', text }],
216
+ }),
217
+ )
218
+ return {
219
+ stopReason: response.stopReason,
220
+ ...(response.usage != null && { usage: response.usage }),
221
+ }
222
+ },
223
+ cancel: () => connection.cancel({ sessionId }),
224
+ dispose: teardown,
225
+ }
226
+ } catch (error) {
227
+ await teardown()
228
+ const tail = stderrTail().trim()
229
+ if (
230
+ error instanceof Error &&
231
+ tail !== '' &&
232
+ !error.message.includes(tail)
233
+ ) {
234
+ throw new Error(`${error.message}\nstderr: ${tail}`, { cause: error })
235
+ }
236
+ throw error
237
+ }
238
+ }