@zengxingyuan/aamp-acp-bridge 0.1.28-dev.10
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 +136 -0
- package/dist/acpx-client.d.ts +81 -0
- package/dist/acpx-client.js +485 -0
- package/dist/acpx-client.js.map +1 -0
- package/dist/agent-bridge.d.ts +68 -0
- package/dist/agent-bridge.js +963 -0
- package/dist/agent-bridge.js.map +1 -0
- package/dist/agent-bridge.test.d.ts +1 -0
- package/dist/agent-bridge.test.js +22 -0
- package/dist/agent-bridge.test.js.map +1 -0
- package/dist/bridge.d.ts +25 -0
- package/dist/bridge.js +70 -0
- package/dist/bridge.js.map +1 -0
- package/dist/cli/init.d.ts +9 -0
- package/dist/cli/init.js +562 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/config.d.ts +178 -0
- package/dist/config.js +73 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +225 -0
- package/dist/index.js.map +1 -0
- package/dist/pairing.d.ts +39 -0
- package/dist/pairing.js +135 -0
- package/dist/pairing.js.map +1 -0
- package/dist/prompt-builder.d.ts +22 -0
- package/dist/prompt-builder.js +427 -0
- package/dist/prompt-builder.js.map +1 -0
- package/dist/prompt-builder.test.d.ts +1 -0
- package/dist/prompt-builder.test.js +69 -0
- package/dist/prompt-builder.test.js.map +1 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.js +97 -0
- package/dist/storage.js.map +1 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# aamp-acp-bridge
|
|
2
|
+
|
|
3
|
+
Config-driven bridge that connects ACP-compatible agents to the AAMP email network.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install aamp-acp-bridge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Initialize the bridge:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx aamp-acp-bridge init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The init wizard scans installed ACP-capable agents, including Hermes, then lets you select multiple entries with arrow keys, Space, and Enter. For each selected agent, choose one authorization setup method:
|
|
20
|
+
|
|
21
|
+
- Pair with a five-minute terminal QR code plus the matching `aamp://connect?...` URL.
|
|
22
|
+
- Manually enter `senderPolicies`.
|
|
23
|
+
- Reuse existing `senderPolicies`, when any are available.
|
|
24
|
+
- Configure sender authorization later; `task.dispatch` is rejected until pairing or policy setup is complete.
|
|
25
|
+
|
|
26
|
+
If you choose QR pairing, `init` starts the bridge immediately after writing config, so scanning the QR code with AAMP App works right away. The bridge answers each `pair.request` with `pair.respond`; rejected responses include the failure reason.
|
|
27
|
+
|
|
28
|
+
Use `--no-start` only when you need to generate config in a script without
|
|
29
|
+
keeping the bridge process running:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx aamp-acp-bridge init --agent claude --no-start
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
After an agent has been initialized, generate a fresh pairing QR code without
|
|
36
|
+
re-running setup:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx aamp-acp-bridge pair --agent claude
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Start the bridge:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx aamp-acp-bridge start
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
When debugging task routing, add `--debug` to print the exact prompt sent to
|
|
49
|
+
the ACP agent for each `task.dispatch`:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx aamp-acp-bridge start --debug
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
By default, the bridge stores its config under `~/.aamp/acp-bridge/config.json` and agent credentials under `~/.aamp/acp-bridge/credentials/`.
|
|
56
|
+
Legacy `./bridge.json` and `~/.acp-bridge/` data are migrated automatically on first use without deleting the original files.
|
|
57
|
+
|
|
58
|
+
The bridge understands these task lifecycle intents:
|
|
59
|
+
|
|
60
|
+
- `task.dispatch`
|
|
61
|
+
- `task.stream.opened`
|
|
62
|
+
- `task.help_needed`
|
|
63
|
+
- `task.result`
|
|
64
|
+
- `task.cancel`
|
|
65
|
+
|
|
66
|
+
Dispatch tasks can also carry:
|
|
67
|
+
|
|
68
|
+
- `priority`: `urgent | high | normal`
|
|
69
|
+
- `expiresAt`: an ISO-8601 timestamp after which the task should no longer run
|
|
70
|
+
- `promptRules`: an optional complete text block that replaces the default task
|
|
71
|
+
prompt rules
|
|
72
|
+
|
|
73
|
+
If a `task.cancel` arrives before the ACP agent returns a final answer, the bridge suppresses any later result send for that task.
|
|
74
|
+
|
|
75
|
+
When `promptRules` is present on `task.dispatch`, the ACP prompt keeps its
|
|
76
|
+
standard task identity, metadata, dispatch context, description, and thread
|
|
77
|
+
context, then replaces the default task rule block with the provided text.
|
|
78
|
+
|
|
79
|
+
While ACP execution is in progress, the bridge can:
|
|
80
|
+
|
|
81
|
+
- create an AAMP task stream for the task
|
|
82
|
+
- send `task.stream.opened`
|
|
83
|
+
- append `status`, `progress`, and `text.delta` events
|
|
84
|
+
- forward ACP `agent_thought_chunk` / `agent_message_chunk` updates into the AAMP stream in realtime
|
|
85
|
+
- expose tool progress as stream progress labels while the agent is working
|
|
86
|
+
- close the stream before the authoritative `task.result` or `task.help_needed`
|
|
87
|
+
|
|
88
|
+
When `acpx` supports `--format json --json-strict`, the bridge consumes the structured ACP NDJSON stream so reasoning / reply chunks can be forwarded live. Older `acpx` builds automatically fall back to plain-text mode, which preserves compatibility but cannot expose thought chunks incrementally.
|
|
89
|
+
|
|
90
|
+
## Config
|
|
91
|
+
|
|
92
|
+
Minimal example:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"aampHost": "https://meshmail.ai",
|
|
97
|
+
"rejectUnauthorized": false,
|
|
98
|
+
"agents": [
|
|
99
|
+
{
|
|
100
|
+
"name": "claude",
|
|
101
|
+
"acpCommand": "claude",
|
|
102
|
+
"slug": "claude-bridge",
|
|
103
|
+
"taskDispatchConcurrency": 10,
|
|
104
|
+
"credentialsFile": "~/.aamp/acp-bridge/credentials/claude.json",
|
|
105
|
+
"senderPolicies": [
|
|
106
|
+
{
|
|
107
|
+
"sender": "system@aamp.local",
|
|
108
|
+
"dispatchContextRules": {
|
|
109
|
+
"project_key": ["proj_123"]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`senderPolicies` is optional, but omitted policies do not authorize anyone by default. Use QR pairing or configure at least one policy before sending `task.dispatch`; matching policies can also enforce exact-match `X-AAMP-Dispatch-Context` rules.
|
|
119
|
+
Legacy `senderWhitelist` configs still load and are normalized into `senderPolicies`.
|
|
120
|
+
When editing the `senderPoliciesFile` directly, `pairedAt` is optional; the bridge accepts manually added records with just `sender` and optional `dispatchContextRules`.
|
|
121
|
+
`credentialsFile` is optional. If omitted, the bridge uses `~/.aamp/acp-bridge/credentials/<agent>.json`.
|
|
122
|
+
`taskDispatchConcurrency` is optional and defaults to `10`.
|
|
123
|
+
|
|
124
|
+
### Hermes
|
|
125
|
+
|
|
126
|
+
Hermes exposes ACP through `hermes acp`, so its bridge config uses a raw ACP command:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"name": "hermes",
|
|
131
|
+
"acpCommand": "hermes acp",
|
|
132
|
+
"slug": "hermes-bridge"
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`init --agent hermes` writes this command automatically when Hermes is installed.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface AcpEvent {
|
|
2
|
+
eventVersion?: number;
|
|
3
|
+
sessionId?: string;
|
|
4
|
+
requestId?: string;
|
|
5
|
+
seq?: number;
|
|
6
|
+
type?: string;
|
|
7
|
+
messageId?: string;
|
|
8
|
+
content?: unknown;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export type AcpTextChunkChannel = 'assistant' | 'thought';
|
|
12
|
+
export interface AcpTextChunk {
|
|
13
|
+
channel: AcpTextChunkChannel;
|
|
14
|
+
text: string;
|
|
15
|
+
messageId?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface AcpToolUpdate {
|
|
18
|
+
toolCallId?: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
status?: string;
|
|
21
|
+
kind?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
locations?: Array<{
|
|
24
|
+
path: string;
|
|
25
|
+
line?: number;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export interface AcpPlanEntry {
|
|
29
|
+
content: string;
|
|
30
|
+
status?: string;
|
|
31
|
+
priority?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface AcpPromptHandlers {
|
|
34
|
+
onEvent?: (event: AcpEvent) => void;
|
|
35
|
+
onTextChunk?: (chunk: AcpTextChunk) => void;
|
|
36
|
+
onToolUpdate?: (update: AcpToolUpdate) => void;
|
|
37
|
+
onPlanUpdate?: (entries: AcpPlanEntry[]) => void;
|
|
38
|
+
}
|
|
39
|
+
export interface AcpResult {
|
|
40
|
+
output: string;
|
|
41
|
+
events: AcpEvent[];
|
|
42
|
+
stopReason?: string;
|
|
43
|
+
streamedAssistantText: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Wrapper around acpx CLI.
|
|
47
|
+
* Invokes acpx as a subprocess and parses NDJSON output.
|
|
48
|
+
*/
|
|
49
|
+
export declare class AcpxClient {
|
|
50
|
+
private cwd;
|
|
51
|
+
constructor(cwd?: string);
|
|
52
|
+
private isRawAgentCommand;
|
|
53
|
+
private buildAcpxArgs;
|
|
54
|
+
private formatArgForLog;
|
|
55
|
+
private formatFailedCommand;
|
|
56
|
+
private buildSubprocessEnv;
|
|
57
|
+
private formatProcessFailure;
|
|
58
|
+
/**
|
|
59
|
+
* Ensure a named ACP session exists for the given agent.
|
|
60
|
+
*/
|
|
61
|
+
ensureSession(agent: string, sessionName: string): Promise<string>;
|
|
62
|
+
/**
|
|
63
|
+
* Send a prompt to an ACP agent and wait for completion.
|
|
64
|
+
* Collects all stdout + stderr output and extracts the agent's response.
|
|
65
|
+
*/
|
|
66
|
+
prompt(agent: string, sessionName: string, text: string, handlers?: AcpPromptHandlers): Promise<AcpResult>;
|
|
67
|
+
private promptJsonMode;
|
|
68
|
+
private promptTextMode;
|
|
69
|
+
/**
|
|
70
|
+
* Cancel the current operation in a session.
|
|
71
|
+
*/
|
|
72
|
+
cancel(agent: string, sessionName: string): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Close a session.
|
|
75
|
+
*/
|
|
76
|
+
close(agent: string, sessionName: string): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Execute an acpx command and return stdout.
|
|
79
|
+
*/
|
|
80
|
+
private exec;
|
|
81
|
+
}
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
function asRecord(value) {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
6
|
+
return null;
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function asString(value) {
|
|
10
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
function extractContentText(content) {
|
|
13
|
+
if (typeof content === 'string')
|
|
14
|
+
return content;
|
|
15
|
+
if (Array.isArray(content)) {
|
|
16
|
+
return content.map((item) => extractContentText(item)).join('');
|
|
17
|
+
}
|
|
18
|
+
const record = asRecord(content);
|
|
19
|
+
if (!record)
|
|
20
|
+
return '';
|
|
21
|
+
if (typeof record.text === 'string')
|
|
22
|
+
return record.text;
|
|
23
|
+
if (typeof record.thinking === 'string')
|
|
24
|
+
return record.thinking;
|
|
25
|
+
const resource = asRecord(record.resource);
|
|
26
|
+
if (resource && typeof resource.text === 'string')
|
|
27
|
+
return resource.text;
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
function extractToolLocations(value) {
|
|
31
|
+
if (!Array.isArray(value))
|
|
32
|
+
return undefined;
|
|
33
|
+
const locations = value.flatMap((item) => {
|
|
34
|
+
const record = asRecord(item);
|
|
35
|
+
if (!record)
|
|
36
|
+
return [];
|
|
37
|
+
const path = asString(record.path);
|
|
38
|
+
if (!path)
|
|
39
|
+
return [];
|
|
40
|
+
const line = typeof record.line === 'number' && Number.isFinite(record.line)
|
|
41
|
+
? record.line
|
|
42
|
+
: undefined;
|
|
43
|
+
return [{ path, ...(line != null ? { line } : {}) }];
|
|
44
|
+
});
|
|
45
|
+
return locations.length > 0 ? locations : undefined;
|
|
46
|
+
}
|
|
47
|
+
function extractPlanEntries(value) {
|
|
48
|
+
if (!Array.isArray(value))
|
|
49
|
+
return [];
|
|
50
|
+
return value.flatMap((item) => {
|
|
51
|
+
const record = asRecord(item);
|
|
52
|
+
const content = asString(record?.content);
|
|
53
|
+
if (!content)
|
|
54
|
+
return [];
|
|
55
|
+
return [{
|
|
56
|
+
content,
|
|
57
|
+
...(asString(record?.status) ? { status: asString(record?.status) } : {}),
|
|
58
|
+
...(asString(record?.priority) ? { priority: asString(record?.priority) } : {}),
|
|
59
|
+
}];
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function normalizeLegacyEvent(record) {
|
|
63
|
+
const rawType = asString(record.type);
|
|
64
|
+
if (!rawType)
|
|
65
|
+
return null;
|
|
66
|
+
const mappedType = rawType === 'thinking' ? 'agent_thought_chunk' : rawType;
|
|
67
|
+
return {
|
|
68
|
+
...record,
|
|
69
|
+
type: mappedType,
|
|
70
|
+
...(asString(record.sessionId) ? { sessionId: asString(record.sessionId) } : {}),
|
|
71
|
+
...(asString(record.requestId) ? { requestId: asString(record.requestId) } : {}),
|
|
72
|
+
...(typeof record.seq === 'number' ? { seq: record.seq } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function normalizeJsonRpcEvent(record) {
|
|
76
|
+
if (record.method === 'session/update') {
|
|
77
|
+
const params = asRecord(record.params);
|
|
78
|
+
if (!params)
|
|
79
|
+
return null;
|
|
80
|
+
const explicitUpdate = asRecord(params.update);
|
|
81
|
+
const fallbackUpdate = asString(params.sessionUpdate)
|
|
82
|
+
? {
|
|
83
|
+
...params,
|
|
84
|
+
sessionUpdate: params.sessionUpdate,
|
|
85
|
+
}
|
|
86
|
+
: null;
|
|
87
|
+
const update = explicitUpdate ?? fallbackUpdate;
|
|
88
|
+
if (!update)
|
|
89
|
+
return null;
|
|
90
|
+
const type = asString(update.sessionUpdate);
|
|
91
|
+
if (!type)
|
|
92
|
+
return null;
|
|
93
|
+
const normalized = {
|
|
94
|
+
type,
|
|
95
|
+
...(asString(params.sessionId) ? { sessionId: asString(params.sessionId) } : {}),
|
|
96
|
+
};
|
|
97
|
+
for (const [key, value] of Object.entries(update)) {
|
|
98
|
+
if (key === 'sessionUpdate')
|
|
99
|
+
continue;
|
|
100
|
+
normalized[key] = value;
|
|
101
|
+
}
|
|
102
|
+
return normalized;
|
|
103
|
+
}
|
|
104
|
+
if (record.result) {
|
|
105
|
+
const result = asRecord(record.result);
|
|
106
|
+
if (!result)
|
|
107
|
+
return null;
|
|
108
|
+
return {
|
|
109
|
+
type: 'result',
|
|
110
|
+
...(asString(record.id) ? { requestId: asString(record.id) } : {}),
|
|
111
|
+
...result,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (record.error) {
|
|
115
|
+
return {
|
|
116
|
+
type: 'error',
|
|
117
|
+
...(asString(record.id) ? { requestId: asString(record.id) } : {}),
|
|
118
|
+
error: record.error,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function parseAcpLine(line) {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
if (!trimmed)
|
|
126
|
+
return { event: null, isJson: false };
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(trimmed);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { event: null, isJson: false };
|
|
133
|
+
}
|
|
134
|
+
const record = asRecord(parsed);
|
|
135
|
+
if (!record)
|
|
136
|
+
return { event: null, isJson: true };
|
|
137
|
+
if (record.jsonrpc === '2.0') {
|
|
138
|
+
return { event: normalizeJsonRpcEvent(record), isJson: true };
|
|
139
|
+
}
|
|
140
|
+
return { event: normalizeLegacyEvent(record), isJson: true };
|
|
141
|
+
}
|
|
142
|
+
function supportsJsonStreamingFallback(stderr) {
|
|
143
|
+
return /unknown option|unknown argument|unexpected argument|invalid value|--format|--json-strict/i.test(stderr);
|
|
144
|
+
}
|
|
145
|
+
function isCliTranscriptHeader(line) {
|
|
146
|
+
return /^\[(acpx|client|tool|done|error|warning)\](?:\s|$)/i.test(line);
|
|
147
|
+
}
|
|
148
|
+
function extractFinalReplyFromTranscript(output) {
|
|
149
|
+
const trimmed = output.trim();
|
|
150
|
+
if (!trimmed)
|
|
151
|
+
return '';
|
|
152
|
+
const lines = trimmed.replace(/\r\n/g, '\n').split('\n');
|
|
153
|
+
const textBlocks = [];
|
|
154
|
+
let currentBlock = [];
|
|
155
|
+
let skippingTranscriptDetails = false;
|
|
156
|
+
const flushBlock = () => {
|
|
157
|
+
const block = currentBlock.join('\n').trim();
|
|
158
|
+
if (block)
|
|
159
|
+
textBlocks.push(block);
|
|
160
|
+
currentBlock = [];
|
|
161
|
+
};
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
if (isCliTranscriptHeader(line)) {
|
|
164
|
+
flushBlock();
|
|
165
|
+
skippingTranscriptDetails = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (skippingTranscriptDetails) {
|
|
169
|
+
if (!line || /^[ \t]+/.test(line)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
skippingTranscriptDetails = false;
|
|
173
|
+
}
|
|
174
|
+
currentBlock.push(line);
|
|
175
|
+
}
|
|
176
|
+
flushBlock();
|
|
177
|
+
return textBlocks.at(-1) ?? '';
|
|
178
|
+
}
|
|
179
|
+
function sanitizePromptOutput(output) {
|
|
180
|
+
const trimmed = output.trim();
|
|
181
|
+
if (!trimmed)
|
|
182
|
+
return '';
|
|
183
|
+
if (!trimmed.split(/\r?\n/).some((line) => isCliTranscriptHeader(line))) {
|
|
184
|
+
return trimmed;
|
|
185
|
+
}
|
|
186
|
+
return extractFinalReplyFromTranscript(trimmed);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Wrapper around acpx CLI.
|
|
190
|
+
* Invokes acpx as a subprocess and parses NDJSON output.
|
|
191
|
+
*/
|
|
192
|
+
export class AcpxClient {
|
|
193
|
+
cwd;
|
|
194
|
+
constructor(cwd) {
|
|
195
|
+
this.cwd = cwd ?? process.cwd();
|
|
196
|
+
}
|
|
197
|
+
isRawAgentCommand(agent) {
|
|
198
|
+
return /\s/.test(agent.trim());
|
|
199
|
+
}
|
|
200
|
+
buildAcpxArgs(agent, args, globalArgs = []) {
|
|
201
|
+
if (this.isRawAgentCommand(agent)) {
|
|
202
|
+
return ['--approve-all', '--cwd', this.cwd, ...globalArgs, '--agent', agent, ...args];
|
|
203
|
+
}
|
|
204
|
+
return ['--approve-all', '--cwd', this.cwd, ...globalArgs, agent, ...args];
|
|
205
|
+
}
|
|
206
|
+
formatArgForLog(arg) {
|
|
207
|
+
const normalized = arg.replace(/\s+/g, ' ').trim();
|
|
208
|
+
if (normalized.length <= 160)
|
|
209
|
+
return normalized;
|
|
210
|
+
return `${normalized.slice(0, 157)}...`;
|
|
211
|
+
}
|
|
212
|
+
formatFailedCommand(agent, args, globalArgs = []) {
|
|
213
|
+
return ['acpx', ...this.buildAcpxArgs(agent, args, globalArgs)]
|
|
214
|
+
.map((arg) => this.formatArgForLog(arg))
|
|
215
|
+
.join(' ');
|
|
216
|
+
}
|
|
217
|
+
buildSubprocessEnv() {
|
|
218
|
+
const env = { ...process.env };
|
|
219
|
+
const registry = env.npm_config_registry || env.NPM_CONFIG_REGISTRY || 'https://registry.npmjs.org/';
|
|
220
|
+
const cache = env.npm_config_cache || env.NPM_CONFIG_CACHE || `${tmpdir()}/aamp-acpx-npm-cache`;
|
|
221
|
+
for (const key of Object.keys(env)) {
|
|
222
|
+
const lower = key.toLowerCase();
|
|
223
|
+
if (lower.startsWith('npm_config_')
|
|
224
|
+
|| lower.startsWith('npm_package_')
|
|
225
|
+
|| lower.startsWith('npm_lifecycle_')
|
|
226
|
+
|| lower === 'npm_command'
|
|
227
|
+
|| lower === 'npm_execpath'
|
|
228
|
+
|| lower === 'npm_node_execpath'
|
|
229
|
+
|| lower === 'init_cwd') {
|
|
230
|
+
delete env[key];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
mkdirSync(cache, { recursive: true });
|
|
234
|
+
env.npm_config_registry = registry;
|
|
235
|
+
env.NPM_CONFIG_REGISTRY = registry;
|
|
236
|
+
env.npm_config_cache = cache;
|
|
237
|
+
env.NPM_CONFIG_CACHE = cache;
|
|
238
|
+
return env;
|
|
239
|
+
}
|
|
240
|
+
formatProcessFailure(agent, args, code, stdout, stderr, globalArgs = []) {
|
|
241
|
+
const details = [
|
|
242
|
+
stderr.trim() ? `stderr: ${stderr.trim()}` : '',
|
|
243
|
+
stdout.trim() ? `stdout: ${stdout.trim()}` : '',
|
|
244
|
+
].filter(Boolean);
|
|
245
|
+
return `${this.formatFailedCommand(agent, args, globalArgs)} failed (${code ?? 'unknown'}): ${details.join('\n') || 'no output from acpx'}`;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Ensure a named ACP session exists for the given agent.
|
|
249
|
+
*/
|
|
250
|
+
async ensureSession(agent, sessionName) {
|
|
251
|
+
const result = await this.exec(agent, ['sessions', 'ensure', '--name', sessionName]);
|
|
252
|
+
// Try to extract sessionId from the JSON output
|
|
253
|
+
try {
|
|
254
|
+
const data = JSON.parse(result.trim().split('\n').pop() ?? '{}');
|
|
255
|
+
return data.sessionId ?? sessionName;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return sessionName;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Send a prompt to an ACP agent and wait for completion.
|
|
263
|
+
* Collects all stdout + stderr output and extracts the agent's response.
|
|
264
|
+
*/
|
|
265
|
+
async prompt(agent, sessionName, text, handlers) {
|
|
266
|
+
try {
|
|
267
|
+
return await this.promptJsonMode(agent, sessionName, text, handlers);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
if (supportsJsonStreamingFallback(err.message)) {
|
|
271
|
+
return await this.promptTextMode(agent, sessionName, text);
|
|
272
|
+
}
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async promptJsonMode(agent, sessionName, text, handlers) {
|
|
277
|
+
const events = [];
|
|
278
|
+
let stopReason;
|
|
279
|
+
let streamedAssistantText = false;
|
|
280
|
+
const assistantMessages = new Map();
|
|
281
|
+
const assistantMessageOrder = [];
|
|
282
|
+
let lastAssistantMessageKey;
|
|
283
|
+
let lastThoughtMessageKey;
|
|
284
|
+
let thoughtMessageCount = 0;
|
|
285
|
+
let previousEventType;
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
const proc = spawn('acpx', this.buildAcpxArgs(agent, [
|
|
288
|
+
'prompt',
|
|
289
|
+
'-s', sessionName,
|
|
290
|
+
text,
|
|
291
|
+
], ['--format', 'json', '--json-strict']), {
|
|
292
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
293
|
+
cwd: this.cwd,
|
|
294
|
+
env: this.buildSubprocessEnv(),
|
|
295
|
+
});
|
|
296
|
+
let stdoutBuffer = '';
|
|
297
|
+
let rawStdout = '';
|
|
298
|
+
let stderr = '';
|
|
299
|
+
const processLine = (line) => {
|
|
300
|
+
const parsed = parseAcpLine(line);
|
|
301
|
+
const event = parsed.event;
|
|
302
|
+
if (!event) {
|
|
303
|
+
if (!parsed.isJson) {
|
|
304
|
+
rawStdout += `${line}\n`;
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
events.push(event);
|
|
309
|
+
handlers?.onEvent?.(event);
|
|
310
|
+
if (event.type === 'agent_message_chunk') {
|
|
311
|
+
const textChunk = extractContentText(event.content);
|
|
312
|
+
if (textChunk) {
|
|
313
|
+
const explicitMessageId = asString(event.messageId);
|
|
314
|
+
const messageKey = explicitMessageId
|
|
315
|
+
?? (previousEventType === 'agent_message_chunk' && lastAssistantMessageKey
|
|
316
|
+
? lastAssistantMessageKey
|
|
317
|
+
: `anonymous:${assistantMessageOrder.length}`);
|
|
318
|
+
if (!assistantMessages.has(messageKey)) {
|
|
319
|
+
assistantMessages.set(messageKey, '');
|
|
320
|
+
assistantMessageOrder.push(messageKey);
|
|
321
|
+
}
|
|
322
|
+
assistantMessages.set(messageKey, `${assistantMessages.get(messageKey) ?? ''}${textChunk}`);
|
|
323
|
+
lastAssistantMessageKey = messageKey;
|
|
324
|
+
streamedAssistantText = true;
|
|
325
|
+
handlers?.onTextChunk?.({
|
|
326
|
+
channel: 'assistant',
|
|
327
|
+
text: textChunk,
|
|
328
|
+
messageId: messageKey,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
previousEventType = event.type;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (event.type === 'agent_thought_chunk') {
|
|
335
|
+
const textChunk = extractContentText(event.content);
|
|
336
|
+
if (textChunk) {
|
|
337
|
+
const messageId = asString(event.messageId)
|
|
338
|
+
?? (previousEventType === 'agent_thought_chunk' && lastThoughtMessageKey
|
|
339
|
+
? lastThoughtMessageKey
|
|
340
|
+
: `anonymous-thought:${thoughtMessageCount++}`);
|
|
341
|
+
lastThoughtMessageKey = messageId;
|
|
342
|
+
handlers?.onTextChunk?.({
|
|
343
|
+
channel: 'thought',
|
|
344
|
+
text: textChunk,
|
|
345
|
+
messageId,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
previousEventType = event.type;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (event.type === 'tool_call' || event.type === 'tool_call_update') {
|
|
352
|
+
handlers?.onToolUpdate?.({
|
|
353
|
+
toolCallId: asString(event.toolCallId),
|
|
354
|
+
title: asString(event.title),
|
|
355
|
+
status: asString(event.status),
|
|
356
|
+
kind: asString(event.kind),
|
|
357
|
+
text: extractContentText(event.content),
|
|
358
|
+
locations: extractToolLocations(event.locations),
|
|
359
|
+
});
|
|
360
|
+
previousEventType = event.type;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (event.type === 'plan') {
|
|
364
|
+
const entries = extractPlanEntries(event.entries);
|
|
365
|
+
if (entries.length > 0) {
|
|
366
|
+
handlers?.onPlanUpdate?.(entries);
|
|
367
|
+
}
|
|
368
|
+
previousEventType = event.type;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (event.type === 'result') {
|
|
372
|
+
stopReason = asString(event.stopReason);
|
|
373
|
+
}
|
|
374
|
+
previousEventType = event.type;
|
|
375
|
+
};
|
|
376
|
+
const processStdoutChunk = (chunk) => {
|
|
377
|
+
stdoutBuffer += chunk.toString();
|
|
378
|
+
let newlineIndex = stdoutBuffer.indexOf('\n');
|
|
379
|
+
while (newlineIndex >= 0) {
|
|
380
|
+
const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, '');
|
|
381
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
382
|
+
processLine(line);
|
|
383
|
+
newlineIndex = stdoutBuffer.indexOf('\n');
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
proc.stdout.on('data', processStdoutChunk);
|
|
387
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
388
|
+
proc.on('close', (code) => {
|
|
389
|
+
if (stdoutBuffer.trim()) {
|
|
390
|
+
processLine(stdoutBuffer.replace(/\r$/, ''));
|
|
391
|
+
}
|
|
392
|
+
const finalAssistantOutput = [...assistantMessageOrder]
|
|
393
|
+
.reverse()
|
|
394
|
+
.map((messageKey) => assistantMessages.get(messageKey)?.trim() ?? '')
|
|
395
|
+
.find((message) => message.length > 0) ?? '';
|
|
396
|
+
const output = finalAssistantOutput
|
|
397
|
+
|| sanitizePromptOutput(rawStdout)
|
|
398
|
+
|| sanitizePromptOutput(stderr);
|
|
399
|
+
if (code !== 0 && !output) {
|
|
400
|
+
reject(new Error(this.formatProcessFailure(agent, ['prompt', '-s', sessionName, text], code, rawStdout, stderr, ['--format', 'json', '--json-strict'])));
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
resolve({
|
|
404
|
+
output,
|
|
405
|
+
events,
|
|
406
|
+
...(stopReason ? { stopReason } : {}),
|
|
407
|
+
streamedAssistantText,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
proc.on('error', (err) => {
|
|
412
|
+
reject(new Error(`Failed to spawn acpx: ${err.message}. Is acpx installed?`));
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
async promptTextMode(agent, sessionName, text) {
|
|
417
|
+
const events = [];
|
|
418
|
+
return await new Promise((resolve, reject) => {
|
|
419
|
+
// Old acpx builds may not support JSON output yet.
|
|
420
|
+
const proc = spawn('acpx', this.buildAcpxArgs(agent, ['prompt', '-s', sessionName, text]), {
|
|
421
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
422
|
+
cwd: this.cwd,
|
|
423
|
+
env: this.buildSubprocessEnv(),
|
|
424
|
+
});
|
|
425
|
+
let stdout = '';
|
|
426
|
+
let stderr = '';
|
|
427
|
+
proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
428
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
429
|
+
proc.on('close', (code) => {
|
|
430
|
+
const output = sanitizePromptOutput(stdout) || sanitizePromptOutput(stderr);
|
|
431
|
+
if (code !== 0 && !output) {
|
|
432
|
+
reject(new Error(this.formatProcessFailure(agent, ['prompt', '-s', sessionName, text], code, stdout, stderr)));
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
resolve({
|
|
436
|
+
output,
|
|
437
|
+
events,
|
|
438
|
+
streamedAssistantText: false,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
proc.on('error', (err) => {
|
|
443
|
+
reject(new Error(`Failed to spawn acpx: ${err.message}. Is acpx installed?`));
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Cancel the current operation in a session.
|
|
449
|
+
*/
|
|
450
|
+
async cancel(agent, sessionName) {
|
|
451
|
+
await this.exec(agent, ['cancel', '-s', sessionName]);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Close a session.
|
|
455
|
+
*/
|
|
456
|
+
async close(agent, sessionName) {
|
|
457
|
+
await this.exec(agent, ['sessions', 'close', sessionName]);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Execute an acpx command and return stdout.
|
|
461
|
+
*/
|
|
462
|
+
exec(agent, args) {
|
|
463
|
+
return new Promise((resolve, reject) => {
|
|
464
|
+
const proc = spawn('acpx', this.buildAcpxArgs(agent, args), {
|
|
465
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
466
|
+
cwd: this.cwd,
|
|
467
|
+
env: this.buildSubprocessEnv(),
|
|
468
|
+
});
|
|
469
|
+
let stdout = '';
|
|
470
|
+
let stderr = '';
|
|
471
|
+
proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
472
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
473
|
+
proc.on('close', (code) => {
|
|
474
|
+
if (code !== 0)
|
|
475
|
+
reject(new Error(this.formatProcessFailure(agent, args, code, stdout, stderr)));
|
|
476
|
+
else
|
|
477
|
+
resolve(stdout);
|
|
478
|
+
});
|
|
479
|
+
proc.on('error', (err) => {
|
|
480
|
+
reject(new Error(`Failed to spawn acpx: ${err.message}`));
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
//# sourceMappingURL=acpx-client.js.map
|