@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.
- package/README.md +428 -0
- package/dist/esm/adapters/compatible.d.ts +206 -0
- package/dist/esm/adapters/compatible.js +313 -0
- package/dist/esm/adapters/compatible.js.map +1 -0
- package/dist/esm/adapters/projection.d.ts +29 -0
- package/dist/esm/adapters/projection.js +83 -0
- package/dist/esm/adapters/projection.js.map +1 -0
- package/dist/esm/index.d.ts +21 -0
- package/dist/esm/index.js +35 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/messages/prompt.d.ts +19 -0
- package/dist/esm/messages/prompt.js +37 -0
- package/dist/esm/messages/prompt.js.map +1 -0
- package/dist/esm/permissions.d.ts +8 -0
- package/dist/esm/permissions.js +44 -0
- package/dist/esm/permissions.js.map +1 -0
- package/dist/esm/session/acp-client.d.ts +37 -0
- package/dist/esm/session/acp-client.js +140 -0
- package/dist/esm/session/acp-client.js.map +1 -0
- package/dist/esm/session/sandbox-server.d.ts +46 -0
- package/dist/esm/session/sandbox-server.js +114 -0
- package/dist/esm/session/sandbox-server.js.map +1 -0
- package/dist/esm/stream/queue.d.ts +15 -0
- package/dist/esm/stream/queue.js +54 -0
- package/dist/esm/stream/queue.js.map +1 -0
- package/dist/esm/stream/translate.d.ts +38 -0
- package/dist/esm/stream/translate.js +320 -0
- package/dist/esm/stream/translate.js.map +1 -0
- package/dist/esm/transport/resolve.d.ts +7 -0
- package/dist/esm/transport/resolve.js +34 -0
- package/dist/esm/transport/resolve.js.map +1 -0
- package/dist/esm/transport/stdio.d.ts +3 -0
- package/dist/esm/transport/stdio.js +51 -0
- package/dist/esm/transport/stdio.js.map +1 -0
- package/dist/esm/transport/types.d.ts +29 -0
- package/dist/esm/transport/websocket.d.ts +17 -0
- package/dist/esm/transport/websocket.js +135 -0
- package/dist/esm/transport/websocket.js.map +1 -0
- package/dist/esm/types/acp-types.d.ts +75 -0
- package/package.json +61 -0
- package/src/adapters/compatible.ts +675 -0
- package/src/adapters/projection.ts +164 -0
- package/src/index.ts +85 -0
- package/src/messages/prompt.ts +71 -0
- package/src/permissions.ts +71 -0
- package/src/session/acp-client.ts +238 -0
- package/src/session/sandbox-server.ts +195 -0
- package/src/stream/queue.ts +61 -0
- package/src/stream/translate.ts +412 -0
- package/src/transport/resolve.ts +46 -0
- package/src/transport/stdio.ts +63 -0
- package/src/transport/types.ts +33 -0
- package/src/transport/websocket.ts +192 -0
- 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
|
+
}
|