@agentsky/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 +71 -0
- package/dist/index.js +555 -0
- package/dist/sse-MBBVHOJ7.js +37 -0
- package/package.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @agentsky/acp
|
|
2
|
+
|
|
3
|
+
`agentsky-acp` runs an [AgentSky](https://agentsky.dev) cloud agent as a local
|
|
4
|
+
**ACP agent** (Agent Client Protocol, JSON-RPC over stdio). Any ACP client can
|
|
5
|
+
spawn it in place of a local harness; it proxies every turn to your always-on
|
|
6
|
+
AgentSky session over the public `/api/v1` API. Built for
|
|
7
|
+
[block/buzz](https://github.com/block/buzz)'s `buzz-acp` harness.
|
|
8
|
+
|
|
9
|
+
Why: buzz's local harnesses die with the laptop and forget on every session
|
|
10
|
+
rotation. An AgentSky agent is a persistent cloud pod — the bridge maps every
|
|
11
|
+
ACP session (buzz makes one per channel, and rotates them freely) onto the same
|
|
12
|
+
durable conversation, so your agent stays online and never loses memory.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
relay ◄─WS─ buzz-acp (ACP client) ─ACP/stdio→ agentsky-acp ─HTTPS→ agentsky.dev/api/v1
|
|
16
|
+
└─ posts replies via `buzz messages send`
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install -g @agentsky/acp
|
|
23
|
+
# get a token: https://agentsky.dev/settings?tab=tokens (or `sky auth login`)
|
|
24
|
+
|
|
25
|
+
export SKY_API_TOKEN=ast_…
|
|
26
|
+
export BUZZ_ACP_AGENT_COMMAND=agentsky-acp
|
|
27
|
+
export BUZZ_ACP_AGENT_ARGS=<your-agent-slug>
|
|
28
|
+
# then run buzz-acp as usual
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or as a buzz desktop custom harness (`<app-data>/custom_harnesses/agentsky.json`):
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"name": "AgentSky",
|
|
36
|
+
"command": "agentsky-acp",
|
|
37
|
+
"args": ["<your-agent-slug>"],
|
|
38
|
+
"env": { "SKY_API_TOKEN": "ast_…" }
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
| env | meaning |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `SKY_API_TOKEN` | AgentSky API token (`ast_…`), required |
|
|
47
|
+
| `SKY_API_URL` | API base, default `https://agentsky.dev` |
|
|
48
|
+
| `SKY_AGENT` | agent spec slug or `sess-…` session id (alternative to argv) |
|
|
49
|
+
| `SKY_UNIVERSE` | universe slug (sent as `X-Universe`) |
|
|
50
|
+
| `SKY_ACP_NO_REPLY_POST=1` | don't post replies to buzz channels via the buzz CLI |
|
|
51
|
+
| `SKY_ACP_BUZZ_BIN` | buzz CLI binary, default `buzz` |
|
|
52
|
+
| `SKY_ACP_TURN_TIMEOUT_MS` | per-turn safety timeout, default 3600000 |
|
|
53
|
+
|
|
54
|
+
## Behavior notes
|
|
55
|
+
|
|
56
|
+
- **Session mapping**: on start the adapter resolves the agent's default
|
|
57
|
+
session (creating one if the spec has none). All ACP sessions map to it —
|
|
58
|
+
channel context and thread history arrive in each prompt, so replies stay
|
|
59
|
+
channel-correct while the agent keeps one continuous memory.
|
|
60
|
+
- **Reply posting**: buzz-acp does not forward the ACP response text to the
|
|
61
|
+
channel; agents are expected to post via the buzz CLI. The cloud pod has no
|
|
62
|
+
buzz CLI or Nostr key, so the adapter posts the completed reply itself with
|
|
63
|
+
`buzz messages send --channel <uuid>` (channel parsed from the prompt's
|
|
64
|
+
`[Context]` section; env `BUZZ_RELAY_URL`/`BUZZ_PRIVATE_KEY` are inherited
|
|
65
|
+
from buzz-acp). A bridge note in each forwarded prompt tells the agent to
|
|
66
|
+
reply in plain text instead of attempting buzz commands.
|
|
67
|
+
- **Reliability**: `Idempotency-Key` on every message post (safe restarts),
|
|
68
|
+
standing SSE with liveness watchdog + reconnect, events-ledger catch-up
|
|
69
|
+
deduped by event id, `session/cancel` → interrupt.
|
|
70
|
+
- `mcpServers` from `session/new` are ignored (local stdio sidecars can't
|
|
71
|
+
reach the cloud pod).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/api.ts
|
|
4
|
+
var ApiError = class extends Error {
|
|
5
|
+
constructor(status, code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
status;
|
|
11
|
+
code;
|
|
12
|
+
};
|
|
13
|
+
var SkyApi = class {
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
}
|
|
17
|
+
config;
|
|
18
|
+
headers() {
|
|
19
|
+
const headers = {
|
|
20
|
+
authorization: `Bearer ${this.config.token}`
|
|
21
|
+
};
|
|
22
|
+
if (this.config.universe) headers["x-universe"] = this.config.universe;
|
|
23
|
+
return headers;
|
|
24
|
+
}
|
|
25
|
+
async request(method, path, body, extraHeaders = {}) {
|
|
26
|
+
const res = await fetch(`${this.config.apiBase}${path}`, {
|
|
27
|
+
method,
|
|
28
|
+
headers: {
|
|
29
|
+
...this.headers(),
|
|
30
|
+
...extraHeaders,
|
|
31
|
+
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
32
|
+
},
|
|
33
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
let code = "http_error";
|
|
37
|
+
let message = `${res.status} ${res.statusText}`;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = await res.json();
|
|
40
|
+
if (parsed.error?.message) {
|
|
41
|
+
code = parsed.error.code ?? code;
|
|
42
|
+
message = parsed.error.message;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
throw new ApiError(res.status, code, message);
|
|
47
|
+
}
|
|
48
|
+
return await res.json();
|
|
49
|
+
}
|
|
50
|
+
/** Resolve the agent ref to the one session this adapter drives: an
|
|
51
|
+
* explicit `sess-…` id, else the spec's default session, else a freshly
|
|
52
|
+
* created session (which is guaranteed the API channel binding). */
|
|
53
|
+
async resolveSession() {
|
|
54
|
+
const ref = this.config.agent;
|
|
55
|
+
if (ref.startsWith("sess-")) return ref;
|
|
56
|
+
const { agent } = await this.request("GET", `/api/v1/agents/${encodeURIComponent(ref)}`);
|
|
57
|
+
if (agent.defaultSessionId) return agent.defaultSessionId;
|
|
58
|
+
const { session } = await this.request(
|
|
59
|
+
"POST",
|
|
60
|
+
"/api/v1/sessions",
|
|
61
|
+
{ agent: ref, title: "buzz (acp bridge)" }
|
|
62
|
+
);
|
|
63
|
+
return session.id;
|
|
64
|
+
}
|
|
65
|
+
async sendMessage(sessionId, text, idempotencyKey) {
|
|
66
|
+
await this.request(
|
|
67
|
+
"POST",
|
|
68
|
+
`/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
|
|
69
|
+
{ parts: [{ type: "text", index: 0, text }] },
|
|
70
|
+
{ "idempotency-key": idempotencyKey }
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
async interrupt(sessionId) {
|
|
74
|
+
await this.request("POST", `/api/v1/sessions/${encodeURIComponent(sessionId)}/interrupt`);
|
|
75
|
+
}
|
|
76
|
+
async listEvents(sessionId, cursor, limit = 500) {
|
|
77
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
78
|
+
if (cursor !== null) params.set("cursor", cursor);
|
|
79
|
+
return this.request(
|
|
80
|
+
"GET",
|
|
81
|
+
`/api/v1/sessions/${encodeURIComponent(sessionId)}/events?${params}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
/** Open the standing SSE stream. Returns the body; caller parses/aborts. */
|
|
85
|
+
async openStream(sessionId, signal) {
|
|
86
|
+
const res = await fetch(
|
|
87
|
+
`${this.config.apiBase}/api/v1/sessions/${encodeURIComponent(sessionId)}/stream`,
|
|
88
|
+
{ headers: { ...this.headers(), accept: "text/event-stream" }, signal }
|
|
89
|
+
);
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new ApiError(res.status, "stream_failed", `stream returned ${res.status}`);
|
|
92
|
+
}
|
|
93
|
+
if (!res.body) throw new ApiError(502, "no_body", "stream returned no body");
|
|
94
|
+
return res.body;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/agent.ts
|
|
99
|
+
import { randomUUID } from "crypto";
|
|
100
|
+
|
|
101
|
+
// src/buzz.ts
|
|
102
|
+
import { execFile } from "child_process";
|
|
103
|
+
|
|
104
|
+
// src/config.ts
|
|
105
|
+
function loadConfig(argv) {
|
|
106
|
+
const agent = argv[0] ?? process.env.SKY_AGENT;
|
|
107
|
+
const token = process.env.SKY_API_TOKEN;
|
|
108
|
+
if (!agent || !token) {
|
|
109
|
+
process.stderr.write(
|
|
110
|
+
"usage: SKY_API_TOKEN=ast_\u2026 agentsky-acp <agent-slug>\n env: SKY_API_URL (default https://agentsky.dev), SKY_AGENT (alternative to argv),\n SKY_UNIVERSE, SKY_ACP_NO_REPLY_POST=1, SKY_ACP_BUZZ_BIN (default buzz),\n SKY_ACP_TURN_TIMEOUT_MS (default 3600000)\n"
|
|
111
|
+
);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
apiBase: (process.env.SKY_API_URL ?? "https://agentsky.dev").replace(/\/$/, ""),
|
|
116
|
+
token,
|
|
117
|
+
agent,
|
|
118
|
+
universe: process.env.SKY_UNIVERSE,
|
|
119
|
+
replyPost: process.env.SKY_ACP_NO_REPLY_POST !== "1",
|
|
120
|
+
buzzBin: process.env.SKY_ACP_BUZZ_BIN ?? "buzz",
|
|
121
|
+
turnTimeoutMs: Number(process.env.SKY_ACP_TURN_TIMEOUT_MS ?? 36e5)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function log(message) {
|
|
125
|
+
process.stderr.write(`[agentsky-acp] ${message}
|
|
126
|
+
`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/buzz.ts
|
|
130
|
+
var UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
131
|
+
function extractChannelId(prompt) {
|
|
132
|
+
const context = prompt.match(/\[(?:Context|Thread Context|Conversation Context)\][^[]*/gi);
|
|
133
|
+
for (const section of context ?? []) {
|
|
134
|
+
const match = section.match(UUID);
|
|
135
|
+
if (match) return match[0];
|
|
136
|
+
}
|
|
137
|
+
const line = prompt.split(/\r?\n/).find((l) => /channel/i.test(l) && UUID.test(l));
|
|
138
|
+
return line?.match(UUID)?.[0] ?? null;
|
|
139
|
+
}
|
|
140
|
+
function postReply(bin, channelId, content) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
execFile(
|
|
143
|
+
bin,
|
|
144
|
+
["messages", "send", "--channel", channelId, "--content", content],
|
|
145
|
+
{ timeout: 3e4, env: process.env },
|
|
146
|
+
(error, _stdout, stderr) => {
|
|
147
|
+
if (error) {
|
|
148
|
+
log(`buzz reply post failed (channel ${channelId}): ${error.message} ${stderr ?? ""}`);
|
|
149
|
+
}
|
|
150
|
+
resolve();
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/events.ts
|
|
157
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
158
|
+
var SEEN_CAP = 5e3;
|
|
159
|
+
var WATCHDOG_MS = 9e4;
|
|
160
|
+
var EventBus = class {
|
|
161
|
+
constructor(api, sessionId) {
|
|
162
|
+
this.api = api;
|
|
163
|
+
this.sessionId = sessionId;
|
|
164
|
+
}
|
|
165
|
+
api;
|
|
166
|
+
sessionId;
|
|
167
|
+
listeners = /* @__PURE__ */ new Set();
|
|
168
|
+
seen = /* @__PURE__ */ new Set();
|
|
169
|
+
cursor = null;
|
|
170
|
+
stopped = false;
|
|
171
|
+
abort = null;
|
|
172
|
+
connected = false;
|
|
173
|
+
connectWaiters = [];
|
|
174
|
+
onEvent(listener) {
|
|
175
|
+
this.listeners.add(listener);
|
|
176
|
+
return () => this.listeners.delete(listener);
|
|
177
|
+
}
|
|
178
|
+
/** Resolves once the SSE stream is open (posting before that risks missing
|
|
179
|
+
* frames with nothing to catch up from until the next reconnect). */
|
|
180
|
+
waitConnected() {
|
|
181
|
+
if (this.connected) return Promise.resolve();
|
|
182
|
+
return new Promise((resolve) => this.connectWaiters.push(resolve));
|
|
183
|
+
}
|
|
184
|
+
/** Advance the ledger cursor to the tail without dispatching history. */
|
|
185
|
+
async primeCursor() {
|
|
186
|
+
let cursor = null;
|
|
187
|
+
while (true) {
|
|
188
|
+
const page = await this.api.listEvents(this.sessionId, cursor);
|
|
189
|
+
cursor = page.cursor ?? cursor;
|
|
190
|
+
for (const event of page.events) this.remember(event.id);
|
|
191
|
+
if (!page.hasMore) break;
|
|
192
|
+
}
|
|
193
|
+
this.cursor = cursor;
|
|
194
|
+
}
|
|
195
|
+
/** Run the stream loop until stop(). Never throws; logs and retries. */
|
|
196
|
+
async run() {
|
|
197
|
+
let backoffMs = 1e3;
|
|
198
|
+
while (!this.stopped) {
|
|
199
|
+
this.abort = new AbortController();
|
|
200
|
+
const { signal } = this.abort;
|
|
201
|
+
let lastActivity = Date.now();
|
|
202
|
+
const watchdog = setInterval(() => {
|
|
203
|
+
if (Date.now() - lastActivity > WATCHDOG_MS) this.abort?.abort();
|
|
204
|
+
}, 1e4);
|
|
205
|
+
try {
|
|
206
|
+
const body = await this.api.openStream(this.sessionId, signal);
|
|
207
|
+
backoffMs = 1e3;
|
|
208
|
+
this.connected = true;
|
|
209
|
+
for (const resolve of this.connectWaiters.splice(0)) resolve();
|
|
210
|
+
const { parseSseStream } = await import("./sse-MBBVHOJ7.js");
|
|
211
|
+
for await (const frame of parseSseStream(body, () => lastActivity = Date.now())) {
|
|
212
|
+
if (!frame.data) continue;
|
|
213
|
+
try {
|
|
214
|
+
this.dispatch(JSON.parse(frame.data));
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!this.stopped && !signal.aborted) {
|
|
220
|
+
log(`stream error: ${error instanceof Error ? error.message : String(error)}`);
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
this.connected = false;
|
|
224
|
+
clearInterval(watchdog);
|
|
225
|
+
}
|
|
226
|
+
if (this.stopped) break;
|
|
227
|
+
await this.catchUp().catch(
|
|
228
|
+
(error) => log(`catch-up failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
229
|
+
);
|
|
230
|
+
await sleep(backoffMs);
|
|
231
|
+
backoffMs = Math.min(backoffMs * 2, 3e4);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Page the ledger from the last cursor, dispatching anything unseen. */
|
|
235
|
+
async catchUp() {
|
|
236
|
+
while (true) {
|
|
237
|
+
const page = await this.api.listEvents(this.sessionId, this.cursor);
|
|
238
|
+
this.cursor = page.cursor ?? this.cursor;
|
|
239
|
+
for (const event of page.events) this.dispatch(event);
|
|
240
|
+
if (!page.hasMore) break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
stop() {
|
|
244
|
+
this.stopped = true;
|
|
245
|
+
this.abort?.abort();
|
|
246
|
+
}
|
|
247
|
+
dispatch(event) {
|
|
248
|
+
if (typeof event.id === "string" && event.id) {
|
|
249
|
+
if (this.seen.has(event.id)) return;
|
|
250
|
+
this.remember(event.id);
|
|
251
|
+
}
|
|
252
|
+
for (const listener of this.listeners) listener(event);
|
|
253
|
+
}
|
|
254
|
+
remember(id) {
|
|
255
|
+
this.seen.add(id);
|
|
256
|
+
if (this.seen.size > SEEN_CAP) {
|
|
257
|
+
const oldest = this.seen.values().next().value;
|
|
258
|
+
if (oldest !== void 0) this.seen.delete(oldest);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// src/rpc.ts
|
|
264
|
+
import { createInterface } from "readline";
|
|
265
|
+
var RpcError = class extends Error {
|
|
266
|
+
constructor(code, message, data) {
|
|
267
|
+
super(message);
|
|
268
|
+
this.code = code;
|
|
269
|
+
this.data = data;
|
|
270
|
+
}
|
|
271
|
+
code;
|
|
272
|
+
data;
|
|
273
|
+
};
|
|
274
|
+
var RpcEndpoint = class {
|
|
275
|
+
constructor(input = process.stdin, output = process.stdout) {
|
|
276
|
+
this.input = input;
|
|
277
|
+
this.output = output;
|
|
278
|
+
}
|
|
279
|
+
input;
|
|
280
|
+
output;
|
|
281
|
+
handlers = /* @__PURE__ */ new Map();
|
|
282
|
+
on(method, handler) {
|
|
283
|
+
this.handlers.set(method, handler);
|
|
284
|
+
}
|
|
285
|
+
notify(method, params) {
|
|
286
|
+
this.write({ jsonrpc: "2.0", method, params });
|
|
287
|
+
}
|
|
288
|
+
/** Consume stdin until EOF, dispatching requests and notifications. */
|
|
289
|
+
async run() {
|
|
290
|
+
const lines = createInterface({ input: this.input, crlfDelay: Infinity });
|
|
291
|
+
for await (const line of lines) {
|
|
292
|
+
if (!line.trim()) continue;
|
|
293
|
+
let request;
|
|
294
|
+
try {
|
|
295
|
+
request = JSON.parse(line);
|
|
296
|
+
} catch {
|
|
297
|
+
this.write({
|
|
298
|
+
jsonrpc: "2.0",
|
|
299
|
+
id: null,
|
|
300
|
+
error: { code: -32700, message: "parse error" }
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
void this.dispatch(request);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async dispatch(request) {
|
|
308
|
+
const handler = this.handlers.get(request.method);
|
|
309
|
+
if (!handler) {
|
|
310
|
+
if (request.id !== void 0) {
|
|
311
|
+
this.write({
|
|
312
|
+
jsonrpc: "2.0",
|
|
313
|
+
id: request.id,
|
|
314
|
+
error: { code: -32601, message: `method not found: ${request.method}` }
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
const result = await handler(request.params);
|
|
321
|
+
if (request.id !== void 0) {
|
|
322
|
+
this.write({ jsonrpc: "2.0", id: request.id, result: result ?? {} });
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (request.id === void 0) {
|
|
326
|
+
log(`notification ${request.method} failed: ${String(error)}`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const rpcError = error instanceof RpcError ? { code: error.code, message: error.message, data: error.data } : { code: -32603, message: error instanceof Error ? error.message : String(error) };
|
|
330
|
+
this.write({ jsonrpc: "2.0", id: request.id, error: rpcError });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
write(frame) {
|
|
334
|
+
this.output.write(`${JSON.stringify(frame)}
|
|
335
|
+
`);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// src/agent.ts
|
|
340
|
+
var VERSION = "0.1.0";
|
|
341
|
+
var PROTOCOL_VERSION = 2;
|
|
342
|
+
var BRIDGE_NOTE = "[Bridge note] You are connected to this Buzz workspace through the AgentSky bridge. The `buzz` CLI and Nostr relay are NOT available in your environment \u2014 do not attempt to run `buzz` commands. Write your reply as plain response text; the bridge posts it to the correct channel for you.";
|
|
343
|
+
var AcpAgent = class {
|
|
344
|
+
constructor(config, api, rpc) {
|
|
345
|
+
this.config = config;
|
|
346
|
+
this.api = api;
|
|
347
|
+
this.rpc = rpc;
|
|
348
|
+
}
|
|
349
|
+
config;
|
|
350
|
+
api;
|
|
351
|
+
rpc;
|
|
352
|
+
sessionId = null;
|
|
353
|
+
bus = null;
|
|
354
|
+
initPromise = null;
|
|
355
|
+
turnChain = Promise.resolve();
|
|
356
|
+
inflight = false;
|
|
357
|
+
acpSessionCounter = 0;
|
|
358
|
+
register() {
|
|
359
|
+
this.rpc.on("initialize", () => ({
|
|
360
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
361
|
+
agentCapabilities: {
|
|
362
|
+
loadSession: false,
|
|
363
|
+
promptCapabilities: { image: false, audio: false, embeddedContext: false }
|
|
364
|
+
},
|
|
365
|
+
authMethods: [],
|
|
366
|
+
agentInfo: { name: "agentsky-acp", version: VERSION }
|
|
367
|
+
}));
|
|
368
|
+
this.rpc.on("authenticate", () => ({}));
|
|
369
|
+
this.rpc.on("session/new", () => this.newSession());
|
|
370
|
+
this.rpc.on("session/prompt", (params) => this.prompt(params));
|
|
371
|
+
this.rpc.on("session/cancel", () => this.cancel());
|
|
372
|
+
}
|
|
373
|
+
/** Resolve the AgentSky session and start the event pipeline, once. */
|
|
374
|
+
ensureReady() {
|
|
375
|
+
this.initPromise ??= (async () => {
|
|
376
|
+
const sessionId = await this.api.resolveSession();
|
|
377
|
+
this.sessionId = sessionId;
|
|
378
|
+
const bus = new EventBus(this.api, sessionId);
|
|
379
|
+
await bus.primeCursor().catch((error) => {
|
|
380
|
+
log(`cursor prime failed (continuing live-only): ${String(error)}`);
|
|
381
|
+
});
|
|
382
|
+
void bus.run();
|
|
383
|
+
this.bus = bus;
|
|
384
|
+
log(`attached to session ${sessionId}`);
|
|
385
|
+
})();
|
|
386
|
+
return this.initPromise;
|
|
387
|
+
}
|
|
388
|
+
async newSession() {
|
|
389
|
+
await this.ensureReady();
|
|
390
|
+
return { sessionId: `sky-acp-${++this.acpSessionCounter}-${randomUUID().slice(0, 8)}` };
|
|
391
|
+
}
|
|
392
|
+
prompt(params) {
|
|
393
|
+
const turn = this.turnChain.then(
|
|
394
|
+
() => this.runTurn(params),
|
|
395
|
+
() => this.runTurn(params)
|
|
396
|
+
);
|
|
397
|
+
this.turnChain = turn.catch(() => void 0);
|
|
398
|
+
return turn;
|
|
399
|
+
}
|
|
400
|
+
async runTurn(params) {
|
|
401
|
+
await this.ensureReady();
|
|
402
|
+
const sessionId = this.sessionId;
|
|
403
|
+
const bus = this.bus;
|
|
404
|
+
if (!sessionId || !bus) throw new RpcError(-32603, "adapter not initialized");
|
|
405
|
+
const text = (params.prompt ?? []).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n");
|
|
406
|
+
if (!text.trim()) return { stopReason: "end_turn" };
|
|
407
|
+
const channelId = extractChannelId(text);
|
|
408
|
+
const acpSessionId = params.sessionId;
|
|
409
|
+
let replyText = "";
|
|
410
|
+
let sawTurnActivity = false;
|
|
411
|
+
let settle;
|
|
412
|
+
let fail;
|
|
413
|
+
const done = new Promise((resolve, reject) => {
|
|
414
|
+
settle = resolve;
|
|
415
|
+
fail = reject;
|
|
416
|
+
});
|
|
417
|
+
const unsubscribe = bus.onEvent((event) => {
|
|
418
|
+
switch (event.type) {
|
|
419
|
+
case "user.message":
|
|
420
|
+
case "turn.accepted":
|
|
421
|
+
sawTurnActivity = true;
|
|
422
|
+
break;
|
|
423
|
+
case "agent.message": {
|
|
424
|
+
sawTurnActivity = true;
|
|
425
|
+
const chunk = typeof event.text === "string" ? event.text : "";
|
|
426
|
+
if (chunk) {
|
|
427
|
+
replyText += (replyText ? "\n\n" : "") + chunk;
|
|
428
|
+
this.update(acpSessionId, {
|
|
429
|
+
sessionUpdate: "agent_message_chunk",
|
|
430
|
+
content: { type: "text", text: chunk }
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "agent.reasoning": {
|
|
436
|
+
sawTurnActivity = true;
|
|
437
|
+
const part = event.part;
|
|
438
|
+
if (part?.text) {
|
|
439
|
+
this.update(acpSessionId, {
|
|
440
|
+
sessionUpdate: "agent_thought_chunk",
|
|
441
|
+
content: { type: "text", text: part.text }
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
case "agent.tool_use": {
|
|
447
|
+
sawTurnActivity = true;
|
|
448
|
+
const part = event.part;
|
|
449
|
+
this.update(acpSessionId, {
|
|
450
|
+
sessionUpdate: "tool_call",
|
|
451
|
+
toolCallId: part?.call_id ?? randomUUID(),
|
|
452
|
+
title: part?.tool_name ?? "tool",
|
|
453
|
+
kind: "other",
|
|
454
|
+
status: "in_progress"
|
|
455
|
+
});
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
case "agent.tool_result": {
|
|
459
|
+
sawTurnActivity = true;
|
|
460
|
+
const part = event.part;
|
|
461
|
+
if (part?.call_id) {
|
|
462
|
+
this.update(acpSessionId, {
|
|
463
|
+
sessionUpdate: "tool_call_update",
|
|
464
|
+
toolCallId: part.call_id,
|
|
465
|
+
status: part.status === "error" ? "failed" : "completed"
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case "turn.status_idle":
|
|
471
|
+
if (sawTurnActivity) settle("end_turn");
|
|
472
|
+
break;
|
|
473
|
+
case "turn.interrupted":
|
|
474
|
+
settle("cancelled");
|
|
475
|
+
break;
|
|
476
|
+
case "error": {
|
|
477
|
+
const code = typeof event.code === "string" ? event.code : "";
|
|
478
|
+
if (code === "enqueue_failed") {
|
|
479
|
+
fail(new Error("turn enqueue failed \u2014 pod unavailable"));
|
|
480
|
+
} else {
|
|
481
|
+
log(`stream error event: ${code} ${String(event.message ?? "")}`);
|
|
482
|
+
}
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
case "session.deleted":
|
|
486
|
+
fail(new Error("session deleted"));
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
this.inflight = true;
|
|
491
|
+
const timeout = setTimeout(() => {
|
|
492
|
+
log(`turn timeout after ${this.config.turnTimeoutMs}ms \u2014 interrupting`);
|
|
493
|
+
this.api.interrupt(sessionId).catch(() => void 0);
|
|
494
|
+
settle("cancelled");
|
|
495
|
+
}, this.config.turnTimeoutMs);
|
|
496
|
+
try {
|
|
497
|
+
await Promise.race([
|
|
498
|
+
bus.waitConnected(),
|
|
499
|
+
new Promise((resolve) => setTimeout(resolve, 1e4))
|
|
500
|
+
]);
|
|
501
|
+
await this.api.sendMessage(sessionId, `${text}
|
|
502
|
+
|
|
503
|
+
${BRIDGE_NOTE}`, randomUUID());
|
|
504
|
+
const stopReason = await done;
|
|
505
|
+
if (this.config.replyPost && channelId && replyText.trim()) {
|
|
506
|
+
await postReply(this.config.buzzBin, channelId, replyText.trim());
|
|
507
|
+
} else if (!channelId && replyText.trim()) {
|
|
508
|
+
log("no channel UUID found in prompt \u2014 reply not posted to buzz");
|
|
509
|
+
}
|
|
510
|
+
return { stopReason };
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error instanceof ApiError) {
|
|
513
|
+
throw new RpcError(-32603, `agentsky api error (${error.code}): ${error.message}`);
|
|
514
|
+
}
|
|
515
|
+
throw error instanceof RpcError ? error : new RpcError(-32603, error instanceof Error ? error.message : String(error));
|
|
516
|
+
} finally {
|
|
517
|
+
clearTimeout(timeout);
|
|
518
|
+
unsubscribe();
|
|
519
|
+
this.inflight = false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async cancel() {
|
|
523
|
+
if (!this.sessionId || !this.inflight) return;
|
|
524
|
+
await this.api.interrupt(this.sessionId).catch((error) => {
|
|
525
|
+
log(`interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
update(sessionId, update) {
|
|
529
|
+
this.rpc.notify("session/update", { sessionId: sessionId ?? "", update });
|
|
530
|
+
}
|
|
531
|
+
shutdown() {
|
|
532
|
+
this.bus?.stop();
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// src/index.ts
|
|
537
|
+
async function main() {
|
|
538
|
+
const config = loadConfig(process.argv.slice(2));
|
|
539
|
+
const rpc = new RpcEndpoint();
|
|
540
|
+
const agent = new AcpAgent(config, new SkyApi(config), rpc);
|
|
541
|
+
agent.register();
|
|
542
|
+
const shutdown = () => {
|
|
543
|
+
agent.shutdown();
|
|
544
|
+
process.exit(0);
|
|
545
|
+
};
|
|
546
|
+
process.on("SIGINT", shutdown);
|
|
547
|
+
process.on("SIGTERM", shutdown);
|
|
548
|
+
log(`agentsky-acp ${VERSION} \u2014 agent ${config.agent} via ${config.apiBase}`);
|
|
549
|
+
await rpc.run();
|
|
550
|
+
shutdown();
|
|
551
|
+
}
|
|
552
|
+
main().catch((error) => {
|
|
553
|
+
log(`fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
554
|
+
process.exit(1);
|
|
555
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/sse.ts
|
|
2
|
+
async function* parseSseStream(body, onActivity) {
|
|
3
|
+
const reader = body.getReader();
|
|
4
|
+
const decoder = new TextDecoder();
|
|
5
|
+
let buffer = "";
|
|
6
|
+
try {
|
|
7
|
+
while (true) {
|
|
8
|
+
const { done, value } = await reader.read();
|
|
9
|
+
if (done) break;
|
|
10
|
+
onActivity?.();
|
|
11
|
+
buffer += decoder.decode(value, { stream: true });
|
|
12
|
+
let sep;
|
|
13
|
+
while ((sep = buffer.match(/\r?\n\r?\n/)) && sep.index !== void 0) {
|
|
14
|
+
const raw = buffer.slice(0, sep.index);
|
|
15
|
+
buffer = buffer.slice(sep.index + sep[0].length);
|
|
16
|
+
const frame = { data: "" };
|
|
17
|
+
const dataLines = [];
|
|
18
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
19
|
+
if (!line || line.startsWith(":")) continue;
|
|
20
|
+
const colon = line.indexOf(":");
|
|
21
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
22
|
+
const value2 = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
23
|
+
if (field === "id") frame.id = value2;
|
|
24
|
+
else if (field === "event") frame.event = value2;
|
|
25
|
+
else if (field === "data") dataLines.push(value2);
|
|
26
|
+
}
|
|
27
|
+
frame.data = dataLines.join("\n");
|
|
28
|
+
if (frame.id !== void 0 || frame.event !== void 0 || frame.data) yield frame;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} finally {
|
|
32
|
+
reader.releaseLock();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
parseSseStream
|
|
37
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentsky/acp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "agentsky-acp — ACP (Agent Client Protocol) adapter: run an AgentSky cloud agent as a local ACP agent over stdio.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agentsky-acp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"build": "tsup src/index.ts --format esm --target node20 --clean",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20",
|
|
24
|
+
"tsup": "^8.5.0",
|
|
25
|
+
"tsx": "^4.22.4",
|
|
26
|
+
"typescript": "^5",
|
|
27
|
+
"vitest": "^3.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|