@agentsky/acp 0.1.0 → 0.2.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 (3) hide show
  1. package/README.md +62 -30
  2. package/dist/index.js +699 -80
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,27 +1,33 @@
1
1
  # @agentsky/acp
2
2
 
3
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.
4
+ **ACP agent** (Agent Client Protocol v1, JSON-RPC over stdio). Any ACP client can
5
+ spawn it in place of a local harness; it proxies every turn to your AgentSky
6
+ session over the public `/api/v1` API. Built for
7
+ [Kylon Custom (ACP)](https://docs.kylon.io/agents/acp-agent-contract),
8
+ [block/buzz](https://github.com/block/buzz)'s `buzz-acp` harness, Zed, and any
9
+ other ACP v1 client.
8
10
 
9
- Why: buzz's local harnesses die with the laptop and forget on every session
11
+ Why: local ACP harnesses die with the laptop and forget on every session
10
12
  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
+ ACP session onto a durable cloud conversation, so your agent stays online and
14
+ never loses memory.
13
15
 
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
16
+ ## Kylon setup
20
17
 
21
18
  ```sh
22
19
  npm install -g @agentsky/acp
23
20
  # get a token: https://agentsky.dev/settings?tab=tokens (or `sky auth login`)
24
21
 
22
+ kylon agent configure <agent-id> --env SKY_API_TOKEN=ast_… -- agentsky-acp @handle
23
+ ```
24
+
25
+ The agent defaults to `sky` (the Sky orchestrator). Pass any handle or
26
+ `sess-<id>` to target a specific agent.
27
+
28
+ ## Buzz setup
29
+
30
+ ```sh
25
31
  export SKY_API_TOKEN=ast_…
26
32
  export BUZZ_ACP_AGENT_COMMAND=agentsky-acp
27
33
  export BUZZ_ACP_AGENT_ARGS=<your-agent-slug>
@@ -44,28 +50,54 @@ Or as a buzz desktop custom harness (`<app-data>/custom_harnesses/agentsky.json`
44
50
  | env | meaning |
45
51
  |---|---|
46
52
  | `SKY_API_TOKEN` | AgentSky API token (`ast_…`), required |
47
- | `SKY_API_URL` | API base, default `https://agentsky.dev` |
53
+ | `SKY_API_URL` | API base, default `https://api.agentsky.dev/v1` |
48
54
  | `SKY_AGENT` | agent spec slug or `sess-…` session id (alternative to argv) |
49
55
  | `SKY_UNIVERSE` | universe slug (sent as `X-Universe`) |
50
56
  | `SKY_ACP_NO_REPLY_POST=1` | don't post replies to buzz channels via the buzz CLI |
51
57
  | `SKY_ACP_BUZZ_BIN` | buzz CLI binary, default `buzz` |
52
58
  | `SKY_ACP_TURN_TIMEOUT_MS` | per-turn safety timeout, default 3600000 |
59
+ | `SKY_ACP_REQUEST_TIMEOUT_MS` | how long to wait for a permission answer before applying the D5 fallback, default 120000 |
60
+ | `SKY_ACP_MCP_BRIDGE=0` | don't forward stdio MCP servers from `session/new` (they are forwarded through the relay bridge by default) |
61
+
62
+ ## CLI flags
63
+
64
+ | flag | meaning |
65
+ |---|---|
66
+ | `--session per-acp` | one AgentSky session per ACP session (default outside buzz) |
67
+ | `--session agent` | all ACP sessions share one AgentSky session (default under buzz) |
68
+ | `--context-block none` | send all prompt text blocks as `user.message` instead of treating the first block as `system.message` |
69
+ | `--budget-cents <n>` | root budget cap in US cents |
53
70
 
54
71
  ## Behavior notes
55
72
 
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).
73
+ - **Protocol v1**: accepts `initialize` with `protocolVersion: 1` only; returns
74
+ `-32000` for any other version or a missing `SKY_API_TOKEN`.
75
+ - **Session modes**:
76
+ - `per-acp` (default outside buzz): one AgentSky session per ACP session,
77
+ created lazily on the first prompt so the handshake stays inside 60 s.
78
+ - `agent` (default under buzz, detected via `BUZZ_RELAY_URL`): all ACP sessions
79
+ share one durable AgentSky session — channel context arrives in every prompt
80
+ and the agent keeps one continuous memory.
81
+ - **Context block** (item 3): the first text block of the first prompt is sent as
82
+ a `system.message` event right after the turn's `user.message`, in the same post (operating context per RFC-0149 §5 D6); subsequent
83
+ blocks are `user.message`. `--context-block none` disables this.
84
+ - **MCP server forwarding** (item 2): HTTP entries in `session/new`.`mcpServers`
85
+ are forwarded as a `mcp_servers` overlay on the created AgentSky session. Stdio entries
86
+ (Kylon's `kylon` server among them) are spawned locally, registered as a
87
+ bridge server via `POST /api/v1/bridges` and `POST /api/v1/bridges/{id}/servers`,
88
+ and the relay long-poll (`GET /api/v1/desktop/requests?wait=25`) proxies
89
+ MCP JSON-RPC to the local process. `SKY_ACP_MCP_BRIDGE=0` turns the stdio
90
+ bridge off. Both degrade to a log note when the platform answers 404.
91
+ - **Task request forwarding** (item 4): open task requests are forwarded to the
92
+ ACP client as `session/request_permission`; if the client doesn't answer within
93
+ `SKY_ACP_REQUEST_TIMEOUT_MS` or declines, the RFC-0149 D5 fallback is applied
94
+ (`approval`/`spend` → decline; `question` → best-judgement instruction).
95
+ - **Cancel**: `session/cancel` interrupts the running turn and settles with
96
+ `stopReason: "cancelled"` within 5 s.
97
+ - **loadSession**: advertised `true` in `agent` mode (session is always resumable);
98
+ `false` in `per-acp` mode.
99
+ - **Reply posting** (buzz only): the adapter posts the completed reply to the
100
+ buzz channel with `buzz messages send --channel <uuid>` (channel UUID parsed
101
+ from the prompt's `[Context]` section).
102
+ - **Reliability**: `Idempotency-Key` on every message post, standing SSE with
103
+ liveness watchdog + reconnect, events-ledger catch-up deduped by event id.
package/dist/index.js CHANGED
@@ -16,9 +16,10 @@ var SkyApi = class {
16
16
  }
17
17
  config;
18
18
  headers() {
19
- const headers = {
20
- authorization: `Bearer ${this.config.token}`
21
- };
19
+ const headers = {};
20
+ if (this.config.token) {
21
+ headers["authorization"] = `Bearer ${this.config.token}`;
22
+ }
22
23
  if (this.config.universe) headers["x-universe"] = this.config.universe;
23
24
  return headers;
24
25
  }
@@ -45,7 +46,8 @@ var SkyApi = class {
45
46
  }
46
47
  throw new ApiError(res.status, code, message);
47
48
  }
48
- return await res.json();
49
+ const text = await res.text();
50
+ return text ? JSON.parse(text) : {};
49
51
  }
50
52
  /** Resolve the agent ref to the one session this adapter drives: an
51
53
  * explicit `sess-…` id, else the spec's default session, else a freshly
@@ -53,38 +55,86 @@ var SkyApi = class {
53
55
  async resolveSession() {
54
56
  const ref = this.config.agent;
55
57
  if (ref.startsWith("sess-")) return ref;
56
- const { agent } = await this.request("GET", `/api/v1/agents/${encodeURIComponent(ref)}`);
58
+ const { agent } = await this.request("GET", `/agents/${encodeURIComponent(ref)}`);
57
59
  if (agent.defaultSessionId) return agent.defaultSessionId;
58
60
  const { session } = await this.request(
59
61
  "POST",
60
- "/api/v1/sessions",
62
+ "/sessions",
61
63
  { agent: ref, title: "buzz (acp bridge)" }
62
64
  );
63
65
  return session.id;
64
66
  }
67
+ /** Create a new session for per-acp mode with optional budget and mcp overlay. */
68
+ async createSession(options) {
69
+ const body = {
70
+ agent: this.config.agent,
71
+ title: options.title ?? "acp session",
72
+ // The first prompt boots the sandbox, after any bridged MCP servers
73
+ // have been added to the overlay (setMcpServers).
74
+ provision: "deferred"
75
+ };
76
+ if (options.budgetCents !== void 0) {
77
+ body["budget"] = {
78
+ type: "limit",
79
+ max_list_cost: { amount: String(options.budgetCents), currency: "USD" }
80
+ };
81
+ }
82
+ if (options.mcpServers && options.mcpServers.length > 0) {
83
+ body["mcp_servers"] = options.mcpServers;
84
+ }
85
+ const { session } = await this.request("POST", "/sessions", body);
86
+ return {
87
+ id: session.id,
88
+ taskId: session.taskId ?? session.task_id ?? null,
89
+ canReopen: session.canReopen ?? false
90
+ };
91
+ }
92
+ /** Replace the session's MCP server overlay (PATCH /sessions/{id}). */
93
+ async setMcpServers(sessionId, servers) {
94
+ await this.request("PATCH", `/sessions/${encodeURIComponent(sessionId)}`, { mcp_servers: servers });
95
+ }
65
96
  async sendMessage(sessionId, text, idempotencyKey) {
66
97
  await this.request(
67
98
  "POST",
68
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
69
- { parts: [{ type: "text", index: 0, text }] },
99
+ `/sessions/${encodeURIComponent(sessionId)}/events`,
100
+ { events: [{ type: "user.message", content: [{ type: "text", text }] }] },
101
+ { "idempotency-key": idempotencyKey }
102
+ );
103
+ }
104
+ /** Send a turn whose operating context (D6) rides as a system.message.
105
+ * The platform accepts a system.message only right after a user.message,
106
+ * so both go in one post, user first. */
107
+ async sendMessageWithContext(sessionId, text, context, idempotencyKey) {
108
+ await this.request(
109
+ "POST",
110
+ `/sessions/${encodeURIComponent(sessionId)}/events`,
111
+ {
112
+ events: [
113
+ { type: "user.message", content: [{ type: "text", text }] },
114
+ { type: "system.message", content: [{ type: "text", text: context }] }
115
+ ]
116
+ },
70
117
  { "idempotency-key": idempotencyKey }
71
118
  );
72
119
  }
73
120
  async interrupt(sessionId) {
74
- await this.request("POST", `/api/v1/sessions/${encodeURIComponent(sessionId)}/interrupt`);
121
+ await this.request("POST", `/sessions/${encodeURIComponent(sessionId)}/interrupt`);
75
122
  }
123
+ /** Catch-up over the ChatMessage-backed history: user.message /
124
+ * agent.message / session.status_idle events only — per-part events
125
+ * (agent.reasoning, agent.tool_use, …) are live-stream-only. */
76
126
  async listEvents(sessionId, cursor, limit = 500) {
77
127
  const params = new URLSearchParams({ limit: String(limit) });
78
128
  if (cursor !== null) params.set("cursor", cursor);
79
129
  return this.request(
80
130
  "GET",
81
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/events?${params}`
131
+ `/sessions/${encodeURIComponent(sessionId)}/events?${params}`
82
132
  );
83
133
  }
84
134
  /** Open the standing SSE stream. Returns the body; caller parses/aborts. */
85
135
  async openStream(sessionId, signal) {
86
136
  const res = await fetch(
87
- `${this.config.apiBase}/api/v1/sessions/${encodeURIComponent(sessionId)}/stream`,
137
+ `${this.config.apiBase}/sessions/${encodeURIComponent(sessionId)}/stream`,
88
138
  { headers: { ...this.headers(), accept: "text/event-stream" }, signal }
89
139
  );
90
140
  if (!res.ok) {
@@ -93,32 +143,156 @@ var SkyApi = class {
93
143
  if (!res.body) throw new ApiError(502, "no_body", "stream returned no body");
94
144
  return res.body;
95
145
  }
146
+ /** Fetch open requests for a task from GET /tasks/{id}.
147
+ * The task id is the root session id; task.requests[] contains the open
148
+ * requests addressed to the caller. Returns [] on 404 (task not yet created). */
149
+ async listOpenRequests(taskId) {
150
+ try {
151
+ const data = await this.request("GET", `/tasks/${encodeURIComponent(taskId)}`);
152
+ return data.task.requests ?? [];
153
+ } catch (error) {
154
+ if (error instanceof ApiError && error.status === 404) return [];
155
+ throw error;
156
+ }
157
+ }
158
+ async answerRequest(taskId, requestId, answer) {
159
+ await this.request(
160
+ "POST",
161
+ `/tasks/${encodeURIComponent(taskId)}/requests/${encodeURIComponent(requestId)}/answer`,
162
+ answer
163
+ );
164
+ }
165
+ /** POST /api/v1/bridges — returns 404 when lane B not yet landed.
166
+ * Real shape: {bridge: {id, name, …}, connect_token: "brc_…"} — connect_token
167
+ * is at the TOP level, same convention as Mac machines (bridge PR #3314). */
168
+ async createBridge(name) {
169
+ try {
170
+ const data = await this.request("POST", "/bridges", { name });
171
+ const token = data.connect_token ?? data.bridge.connect_token ?? "";
172
+ return { id: data.bridge.id, connectToken: token };
173
+ } catch (error) {
174
+ if (error instanceof ApiError && error.status === 404) return null;
175
+ throw error;
176
+ }
177
+ }
178
+ /** POST /api/v1/bridges/{id}/servers — returns null on 404.
179
+ * Requires the bridge connect token as bearer (not the workspace API key).
180
+ * session_id must be a public sess_… id.
181
+ * Real shape: {server: {id, bridge_id, name, mcp_url, created_at}} */
182
+ async registerBridgeServer(bridgeId, name, connectToken, sessionId) {
183
+ try {
184
+ const body = { name };
185
+ if (sessionId) body["session_id"] = sessionId;
186
+ const data = await this.request("POST", `/bridges/${encodeURIComponent(bridgeId)}/servers`, body, {
187
+ authorization: `Bearer ${connectToken}`
188
+ });
189
+ return { id: data.server.id, mcpUrl: data.server.mcp_url };
190
+ } catch (error) {
191
+ if (error instanceof ApiError && error.status === 404) return null;
192
+ throw error;
193
+ }
194
+ }
195
+ /** GET /api/v1/desktop/requests?wait=25 — long-poll for relay requests. */
196
+ async longPollDesktopRequests(connectToken, signal) {
197
+ try {
198
+ const data = await this.request("GET", "/desktop/requests?wait=25", void 0, {
199
+ authorization: `Bearer ${connectToken}`,
200
+ "x-skip-universe": "1"
201
+ });
202
+ return (data.requests ?? []).map((r) => ({
203
+ id: r.id,
204
+ serverId: relayServerKey(r.mount_id),
205
+ method: r.method,
206
+ body: r.body
207
+ }));
208
+ } catch (error) {
209
+ if (signal.aborted) return [];
210
+ if (error instanceof ApiError && (error.status === 404 || error.status === 408)) return [];
211
+ throw error;
212
+ }
213
+ }
214
+ /** Answer one relay request with the HTTP response the pod receives. */
215
+ async answerDesktopRequest(requestId, response, connectToken) {
216
+ await this.request(
217
+ "POST",
218
+ `/desktop/requests/${encodeURIComponent(requestId)}/response`,
219
+ { status: response.status, headers: response.headers ?? {}, body: response.body ?? null },
220
+ { authorization: `Bearer ${connectToken}` }
221
+ );
222
+ }
96
223
  };
224
+ function relayServerKey(id) {
225
+ return id.replace(/^(dmt|brs)_/, "");
226
+ }
97
227
 
98
228
  // src/agent.ts
99
- import { randomUUID } from "crypto";
229
+ import { randomUUID as randomUUID2 } from "crypto";
100
230
 
101
231
  // src/buzz.ts
102
232
  import { execFile } from "child_process";
103
233
 
104
234
  // src/config.ts
235
+ function isBuzz() {
236
+ return Boolean(process.env.BUZZ_RELAY_URL || process.env.BUZZ_ACP_BIN);
237
+ }
238
+ function parseSessionMode(raw, defaultMode) {
239
+ if (raw === "per-acp" || raw === "agent") return raw;
240
+ if (raw !== void 0) {
241
+ process.stderr.write(
242
+ `[agentsky-acp] unknown --session value "${raw}"; using "${defaultMode}"
243
+ `
244
+ );
245
+ }
246
+ return defaultMode;
247
+ }
105
248
  function loadConfig(argv) {
106
- const agent = argv[0] ?? process.env.SKY_AGENT;
107
- const token = process.env.SKY_API_TOKEN;
108
- if (!agent || !token) {
249
+ const args = [...argv];
250
+ let sessionModeArg;
251
+ let contextBlockArg = true;
252
+ let budgetCents;
253
+ const positional = [];
254
+ for (let i = 0; i < args.length; i++) {
255
+ const a = args[i];
256
+ if (a === "--session") {
257
+ sessionModeArg = args[++i];
258
+ } else if (a.startsWith("--session=")) {
259
+ sessionModeArg = a.slice("--session=".length);
260
+ } else if (a === "--context-block") {
261
+ const v = args[++i];
262
+ if (v === "none") contextBlockArg = false;
263
+ } else if (a.startsWith("--context-block=")) {
264
+ contextBlockArg = a.slice("--context-block=".length) !== "none";
265
+ } else if (a === "--budget-cents") {
266
+ budgetCents = Number(args[++i]);
267
+ } else if (a.startsWith("--budget-cents=")) {
268
+ budgetCents = Number(a.slice("--budget-cents=".length));
269
+ } else {
270
+ positional.push(a);
271
+ }
272
+ }
273
+ const agent = positional[0] ?? process.env.SKY_AGENT ?? "sky";
274
+ const token = process.env.SKY_API_TOKEN || void 0;
275
+ if (!agent) {
109
276
  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"
277
+ "usage: SKY_API_TOKEN=ast_\u2026 agentsky-acp [--session per-acp|agent] [--context-block none] [--budget-cents N] <agent-id>\n env: SKY_API_URL (default https://api.agentsky.dev/v1), 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), SKY_ACP_REQUEST_TIMEOUT_MS (default 120000),\n SKY_ACP_MCP_BRIDGE=0 (disable the stdio MCP bridge; on by default)\n"
111
278
  );
112
279
  process.exit(2);
113
280
  }
281
+ const defaultMode = isBuzz() ? "agent" : "per-acp";
282
+ const sessionMode = parseSessionMode(sessionModeArg, defaultMode);
114
283
  return {
115
- apiBase: (process.env.SKY_API_URL ?? "https://agentsky.dev").replace(/\/$/, ""),
284
+ apiBase: (process.env.SKY_API_URL ?? "https://api.agentsky.dev/v1").replace(/\/$/, ""),
116
285
  token,
117
286
  agent,
118
287
  universe: process.env.SKY_UNIVERSE,
119
288
  replyPost: process.env.SKY_ACP_NO_REPLY_POST !== "1",
120
289
  buzzBin: process.env.SKY_ACP_BUZZ_BIN ?? "buzz",
121
- turnTimeoutMs: Number(process.env.SKY_ACP_TURN_TIMEOUT_MS ?? 36e5)
290
+ turnTimeoutMs: Number(process.env.SKY_ACP_TURN_TIMEOUT_MS ?? 36e5),
291
+ sessionMode,
292
+ contextBlock: contextBlockArg,
293
+ budgetCents,
294
+ requestTimeoutMs: Number(process.env.SKY_ACP_REQUEST_TIMEOUT_MS ?? 12e4),
295
+ mcpBridge: process.env.SKY_ACP_MCP_BRIDGE !== "0"
122
296
  };
123
297
  }
124
298
  function log(message) {
@@ -279,20 +453,38 @@ var RpcEndpoint = class {
279
453
  input;
280
454
  output;
281
455
  handlers = /* @__PURE__ */ new Map();
456
+ pendingRequests = /* @__PURE__ */ new Map();
457
+ nextId = 1;
282
458
  on(method, handler) {
283
459
  this.handlers.set(method, handler);
284
460
  }
285
461
  notify(method, params) {
286
462
  this.write({ jsonrpc: "2.0", method, params });
287
463
  }
464
+ /** Send a JSON-RPC request to the client and wait for a response. */
465
+ async request(method, params, timeoutMs) {
466
+ const id = this.nextId++;
467
+ return new Promise((resolve, reject) => {
468
+ this.pendingRequests.set(id, { resolve, reject });
469
+ this.write({ jsonrpc: "2.0", id, method, params });
470
+ if (timeoutMs !== void 0) {
471
+ setTimeout(() => {
472
+ if (this.pendingRequests.has(id)) {
473
+ this.pendingRequests.delete(id);
474
+ reject(new Error(`request ${method} timed out after ${timeoutMs}ms`));
475
+ }
476
+ }, timeoutMs);
477
+ }
478
+ });
479
+ }
288
480
  /** Consume stdin until EOF, dispatching requests and notifications. */
289
481
  async run() {
290
482
  const lines = createInterface({ input: this.input, crlfDelay: Infinity });
291
483
  for await (const line of lines) {
292
484
  if (!line.trim()) continue;
293
- let request;
485
+ let frame;
294
486
  try {
295
- request = JSON.parse(line);
487
+ frame = JSON.parse(line);
296
488
  } catch {
297
489
  this.write({
298
490
  jsonrpc: "2.0",
@@ -301,6 +493,20 @@ var RpcEndpoint = class {
301
493
  });
302
494
  continue;
303
495
  }
496
+ if (!("method" in frame) && "id" in frame && frame.id !== null && frame.id !== void 0) {
497
+ const response = frame;
498
+ const pending = this.pendingRequests.get(response.id);
499
+ if (pending) {
500
+ this.pendingRequests.delete(response.id);
501
+ if (response.error) {
502
+ pending.reject(new RpcError(response.error.code, response.error.message, response.error.data));
503
+ } else {
504
+ pending.resolve(response.result);
505
+ }
506
+ }
507
+ continue;
508
+ }
509
+ const request = frame;
304
510
  void this.dispatch(request);
305
511
  }
306
512
  }
@@ -336,59 +542,333 @@ var RpcEndpoint = class {
336
542
  }
337
543
  };
338
544
 
545
+ // src/bridge.ts
546
+ import { spawn } from "child_process";
547
+ import { randomUUID } from "crypto";
548
+ var MAX_CLAIMS = 4;
549
+ var McpBridge = class {
550
+ constructor(api) {
551
+ this.api = api;
552
+ }
553
+ api;
554
+ servers = /* @__PURE__ */ new Map();
555
+ // name → live server
556
+ aborts = /* @__PURE__ */ new Map();
557
+ // serverId → relay abort
558
+ bridgeId = null;
559
+ connectToken = null;
560
+ /** Spawn and register all stdio entries. Returns mcp_urls keyed by name.
561
+ * HTTP entries are passed through unchanged. Failures are logged, not thrown. */
562
+ async setup(entries, sessionId) {
563
+ const result = /* @__PURE__ */ new Map();
564
+ if (!this.bridgeId) {
565
+ const bridge = await this.api.createBridge(`acp-${randomUUID().slice(0, 8)}`);
566
+ if (!bridge) {
567
+ log("mcp bridge endpoints not available (lane B not yet landed); stdio servers ignored");
568
+ return result;
569
+ }
570
+ this.bridgeId = bridge.id;
571
+ this.connectToken = bridge.connectToken;
572
+ }
573
+ for (const entry of entries) {
574
+ try {
575
+ const mcpUrl = await this.spawnAndRegister(entry, sessionId);
576
+ if (mcpUrl) result.set(entry.name, mcpUrl);
577
+ } catch (error) {
578
+ log(
579
+ `failed to start mcp server "${entry.name}": ${error instanceof Error ? error.message : String(error)}`
580
+ );
581
+ }
582
+ }
583
+ return result;
584
+ }
585
+ async spawnAndRegister(entry, sessionId) {
586
+ const existing = this.servers.get(entry.name);
587
+ if (existing) {
588
+ log(`reusing existing bridge server "${entry.name}"`);
589
+ return null;
590
+ }
591
+ const child = spawn(entry.command, entry.args ?? [], {
592
+ env: { ...process.env, ...entry.env },
593
+ stdio: ["pipe", "pipe", "inherit"]
594
+ });
595
+ await new Promise((resolve, reject) => {
596
+ child.once("spawn", resolve);
597
+ child.once("error", reject);
598
+ setTimeout(() => reject(new Error("spawn timed out")), 5e3);
599
+ });
600
+ const server = await this.api.registerBridgeServer(
601
+ this.bridgeId,
602
+ entry.name,
603
+ this.connectToken,
604
+ sessionId
605
+ );
606
+ if (!server) {
607
+ child.kill();
608
+ return null;
609
+ }
610
+ const live = {
611
+ process: child,
612
+ serverId: server.id,
613
+ connectToken: this.connectToken
614
+ };
615
+ this.servers.set(entry.name, live);
616
+ log(`registered bridge server "${entry.name}" \u2192 ${server.mcpUrl}`);
617
+ const ac = new AbortController();
618
+ this.aborts.set(server.id, ac);
619
+ void this.relayLoop(live, ac.signal).catch(
620
+ (error) => log(`relay loop "${entry.name}" error: ${error instanceof Error ? error.message : String(error)}`)
621
+ );
622
+ return server.mcpUrl;
623
+ }
624
+ /** Long-poll loop: claim relay requests, proxy to stdio, answer back. */
625
+ async relayLoop(live, signal) {
626
+ const inflight = /* @__PURE__ */ new Set();
627
+ while (!signal.aborted) {
628
+ let batch;
629
+ try {
630
+ batch = await this.api.longPollDesktopRequests(live.connectToken, signal);
631
+ } catch (error) {
632
+ if (signal.aborted) break;
633
+ log(`relay poll error: ${error instanceof Error ? error.message : String(error)}`);
634
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
635
+ continue;
636
+ }
637
+ for (const item of batch) {
638
+ if (item.serverId !== relayServerKey(live.serverId)) continue;
639
+ if (inflight.size >= MAX_CLAIMS) continue;
640
+ if (inflight.has(item.id)) continue;
641
+ inflight.add(item.id);
642
+ void this.handleOne(item, live, signal).finally(
643
+ () => inflight.delete(item.id)
644
+ );
645
+ }
646
+ }
647
+ }
648
+ async handleOne(item, live, signal) {
649
+ const json = { "content-type": "application/json" };
650
+ if (item.method !== "POST") {
651
+ await this.answer(item.id, { status: 405, headers: { allow: "POST" } }, live);
652
+ return;
653
+ }
654
+ let request = null;
655
+ try {
656
+ request = JSON.parse(item.body ?? "");
657
+ } catch {
658
+ await this.answer(item.id, { status: 400, headers: json, body: JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }) }, live);
659
+ return;
660
+ }
661
+ if (request && request.id === void 0) {
662
+ live.process.stdin?.write(`${JSON.stringify(request)}
663
+ `);
664
+ await this.answer(item.id, { status: 202 }, live);
665
+ return;
666
+ }
667
+ try {
668
+ const response = await this.proxyToStdio(live.process, request, signal);
669
+ if (!signal.aborted) await this.answer(item.id, { status: 200, headers: json, body: JSON.stringify(response) }, live);
670
+ } catch (error) {
671
+ if (signal.aborted) return;
672
+ const body = JSON.stringify({
673
+ jsonrpc: "2.0",
674
+ id: request?.id ?? null,
675
+ error: { code: -32603, message: error instanceof Error ? error.message : String(error) }
676
+ });
677
+ await this.answer(item.id, { status: 200, headers: json, body }, live);
678
+ }
679
+ }
680
+ async answer(requestId, response, live) {
681
+ await this.api.answerDesktopRequest(requestId, response, live.connectToken).catch((e) => log(`answer relay error: ${e instanceof Error ? e.message : String(e)}`));
682
+ }
683
+ /** Send JSON-RPC to the stdio process and wait for the matching response. */
684
+ proxyToStdio(child, request, signal) {
685
+ return new Promise((resolve, reject) => {
686
+ const reqId = request?.id;
687
+ let buffered = "";
688
+ const onData = (chunk) => {
689
+ buffered += chunk.toString();
690
+ let nl;
691
+ while ((nl = buffered.indexOf("\n")) !== -1) {
692
+ const line2 = buffered.slice(0, nl).trim();
693
+ buffered = buffered.slice(nl + 1);
694
+ if (!line2) continue;
695
+ try {
696
+ const frame = JSON.parse(line2);
697
+ if (frame.id === reqId) {
698
+ cleanup();
699
+ resolve(frame);
700
+ }
701
+ } catch {
702
+ }
703
+ }
704
+ };
705
+ const onError = (err) => {
706
+ cleanup();
707
+ reject(err);
708
+ };
709
+ const onAbort = () => {
710
+ cleanup();
711
+ reject(new Error("aborted"));
712
+ };
713
+ const cleanup = () => {
714
+ child.stdout?.removeListener("data", onData);
715
+ child.removeListener("error", onError);
716
+ signal.removeEventListener("abort", onAbort);
717
+ };
718
+ child.stdout?.on("data", onData);
719
+ child.on("error", onError);
720
+ signal.addEventListener("abort", onAbort, { once: true });
721
+ const line = JSON.stringify(request) + "\n";
722
+ child.stdin?.write(line, (err) => {
723
+ if (err) {
724
+ cleanup();
725
+ reject(err);
726
+ }
727
+ });
728
+ });
729
+ }
730
+ stop() {
731
+ for (const ac of this.aborts.values()) ac.abort();
732
+ for (const live of this.servers.values()) {
733
+ live.process.kill();
734
+ }
735
+ this.servers.clear();
736
+ this.aborts.clear();
737
+ }
738
+ };
739
+
339
740
  // src/agent.ts
340
- var VERSION = "0.1.0";
341
- var PROTOCOL_VERSION = 2;
741
+ var VERSION = "0.2.0";
742
+ var PROTOCOL_VERSION = 1;
342
743
  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
744
  var AcpAgent = class {
344
745
  constructor(config, api, rpc) {
345
746
  this.config = config;
346
747
  this.api = api;
347
748
  this.rpc = rpc;
749
+ this.bridge = new McpBridge(api);
348
750
  }
349
751
  config;
350
752
  api;
351
753
  rpc;
352
- sessionId = null;
353
- bus = null;
354
- initPromise = null;
754
+ // per-acp mode: ACP session id → AgentSky session state (populated lazily)
755
+ perAcpSessions = /* @__PURE__ */ new Map();
756
+ // per-acp mode: ACP session id → pending params from session/new
757
+ pendingNewParams = /* @__PURE__ */ new Map();
758
+ // agent mode: the single shared session
759
+ sharedState = null;
760
+ sharedInitPromise = null;
355
761
  turnChain = Promise.resolve();
356
762
  inflight = false;
763
+ interruptRequested = false;
357
764
  acpSessionCounter = 0;
765
+ bridge;
358
766
  register() {
359
- this.rpc.on("initialize", () => ({
767
+ this.rpc.on("initialize", (params) => this.initialize(params));
768
+ this.rpc.on("authenticate", () => ({}));
769
+ this.rpc.on("session/new", (params) => this.newSession(params));
770
+ this.rpc.on("session/prompt", (params) => this.prompt(params));
771
+ this.rpc.on("session/cancel", () => this.cancel());
772
+ }
773
+ initialize(params) {
774
+ const requested = params?.protocolVersion;
775
+ if (requested !== void 0 && requested !== PROTOCOL_VERSION) {
776
+ throw new RpcError(
777
+ -32602,
778
+ `unsupported protocol version ${requested}; this adapter supports version ${PROTOCOL_VERSION}`
779
+ );
780
+ }
781
+ if (!this.config.token) {
782
+ throw new RpcError(-32e3, "SKY_API_TOKEN is not set; set it with --env SKY_API_TOKEN=ast_\u2026");
783
+ }
784
+ return {
360
785
  protocolVersion: PROTOCOL_VERSION,
361
786
  agentCapabilities: {
362
- loadSession: false,
787
+ loadSession: this.config.sessionMode === "agent",
363
788
  promptCapabilities: { image: false, audio: false, embeddedContext: false }
364
789
  },
365
790
  authMethods: [],
366
791
  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());
792
+ };
372
793
  }
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;
794
+ async newSession(params) {
795
+ const acpSessionId = `sky-acp-${++this.acpSessionCounter}-${randomUUID2().slice(0, 8)}`;
796
+ if (this.config.sessionMode === "agent") {
797
+ void this.ensureShared();
798
+ } else {
799
+ this.pendingNewParams.set(acpSessionId, params);
800
+ }
801
+ return { sessionId: acpSessionId };
802
+ }
803
+ // ── Session resolution ────────────────────────────────────────────────────
804
+ ensureShared() {
805
+ this.sharedInitPromise ??= this.createSharedState();
806
+ return this.sharedInitPromise;
807
+ }
808
+ async createSharedState() {
809
+ const sessionId = await this.api.resolveSession();
810
+ const bus = new EventBus(this.api, sessionId);
811
+ await bus.primeCursor().catch((error) => {
812
+ log(`cursor prime failed (continuing live-only): ${String(error)}`);
813
+ });
814
+ void bus.run();
815
+ this.sharedState = { skySessionId: sessionId, taskId: null, bus, canReopen: true };
816
+ log(`attached to shared session ${sessionId}`);
817
+ return this.sharedState;
818
+ }
819
+ async ensurePerAcp(acpSessionId) {
820
+ const existing = this.perAcpSessions.get(acpSessionId);
821
+ if (existing) return existing;
822
+ const params = this.pendingNewParams.get(acpSessionId) ?? {};
823
+ const httpOverlay = [];
824
+ const stdioEntries = [];
825
+ for (const srv of params.mcpServers ?? []) {
826
+ const isHttp = srv.type === "http" || srv.type === "sse" || srv.url && !srv.command;
827
+ if (isHttp && srv.url) {
828
+ httpOverlay.push({ name: srv.name, url: srv.url });
829
+ } else if (srv.command) {
830
+ stdioEntries.push({ name: srv.name, command: srv.command, args: srv.args, env: srv.env });
831
+ }
832
+ }
833
+ const { id: skySessionId, taskId, canReopen } = await this.api.createSession({
834
+ title: `acp-${acpSessionId}`,
835
+ budgetCents: this.config.budgetCents,
836
+ mcpServers: httpOverlay.length > 0 ? httpOverlay : void 0
837
+ });
838
+ if (this.config.mcpBridge && stdioEntries.length > 0) {
839
+ const urlMap = await this.bridge.setup(stdioEntries, skySessionId);
840
+ if (urlMap.size > 0) {
841
+ const bridged = [...urlMap].map(([name, url]) => ({ name, url }));
842
+ await this.api.setMcpServers(skySessionId, [...httpOverlay, ...bridged]);
843
+ }
844
+ if (urlMap.size < stdioEntries.length) {
845
+ const missing = stdioEntries.filter((e) => !urlMap.has(e.name)).map((e) => e.name).join(", ");
846
+ log(`stdio mcp server(s) not registered: ${missing}`);
847
+ }
848
+ } else if (!this.config.mcpBridge && stdioEntries.length > 0) {
849
+ log(
850
+ `stdio mcp servers ignored (SKY_ACP_MCP_BRIDGE=0): ${stdioEntries.map((e) => e.name).join(", ")}`
851
+ );
852
+ }
853
+ const bus = new EventBus(this.api, skySessionId);
854
+ await bus.primeCursor().catch((error) => {
855
+ log(`cursor prime failed (continuing live-only): ${String(error)}`);
856
+ });
857
+ void bus.run();
858
+ const state = { skySessionId, taskId, bus, canReopen };
859
+ this.perAcpSessions.set(acpSessionId, state);
860
+ this.pendingNewParams.delete(acpSessionId);
861
+ log(`created per-acp session ${skySessionId} for acp session ${acpSessionId}`);
862
+ return state;
387
863
  }
388
- async newSession() {
389
- await this.ensureReady();
390
- return { sessionId: `sky-acp-${++this.acpSessionCounter}-${randomUUID().slice(0, 8)}` };
864
+ async resolveState(acpSessionId) {
865
+ if (this.config.sessionMode === "agent") {
866
+ return this.ensureShared();
867
+ }
868
+ if (!acpSessionId) throw new RpcError(-32602, "sessionId required in per-acp mode");
869
+ return this.ensurePerAcp(acpSessionId);
391
870
  }
871
+ // ── Turns ─────────────────────────────────────────────────────────────────
392
872
  prompt(params) {
393
873
  const turn = this.turnChain.then(
394
874
  () => this.runTurn(params),
@@ -398,16 +878,29 @@ var AcpAgent = class {
398
878
  return turn;
399
879
  }
400
880
  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);
881
+ const state = await this.resolveState(params.sessionId);
882
+ const { skySessionId: sessionId, bus, taskId } = state;
883
+ const blocks = params.prompt ?? [];
884
+ const textBlocks = blocks.filter(
885
+ (block) => block.type === "text" && typeof block.text === "string"
886
+ );
887
+ if (textBlocks.length === 0) return { stopReason: "end_turn" };
408
888
  const acpSessionId = params.sessionId;
889
+ const channelId = extractChannelId(
890
+ textBlocks.map((b) => b.text).join("\n")
891
+ );
892
+ let systemText = null;
893
+ let userText;
894
+ if (this.config.contextBlock && textBlocks.length > 1) {
895
+ systemText = textBlocks[0].text;
896
+ userText = textBlocks.slice(1).map((b) => b.text).join("\n\n");
897
+ } else {
898
+ userText = textBlocks.map((b) => b.text).join("\n\n");
899
+ }
900
+ if (!userText.trim() && !systemText) return { stopReason: "end_turn" };
409
901
  let replyText = "";
410
902
  let sawTurnActivity = false;
903
+ this.interruptRequested = false;
411
904
  let settle;
412
905
  let fail;
413
906
  const done = new Promise((resolve, reject) => {
@@ -416,10 +909,6 @@ var AcpAgent = class {
416
909
  });
417
910
  const unsubscribe = bus.onEvent((event) => {
418
911
  switch (event.type) {
419
- case "user.message":
420
- case "turn.accepted":
421
- sawTurnActivity = true;
422
- break;
423
912
  case "agent.message": {
424
913
  sawTurnActivity = true;
425
914
  const chunk = typeof event.text === "string" ? event.text : "";
@@ -448,7 +937,7 @@ var AcpAgent = class {
448
937
  const part = event.part;
449
938
  this.update(acpSessionId, {
450
939
  sessionUpdate: "tool_call",
451
- toolCallId: part?.call_id ?? randomUUID(),
940
+ toolCallId: part?.call_id ?? randomUUID2(),
452
941
  title: part?.tool_name ?? "tool",
453
942
  kind: "other",
454
943
  status: "in_progress"
@@ -467,21 +956,18 @@ var AcpAgent = class {
467
956
  }
468
957
  break;
469
958
  }
470
- case "turn.status_idle":
471
- if (sawTurnActivity) settle("end_turn");
959
+ case "session.status_idle":
960
+ if (sawTurnActivity) settle(this.interruptRequested ? "cancelled" : "end_turn");
472
961
  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
- }
962
+ case "session.error": {
963
+ const error = event.error;
964
+ const retry = typeof error?.retry_status === "string" ? ` (${error.retry_status})` : "";
965
+ log(`session error: ${String(error?.type ?? "")} ${String(error?.message ?? "")}${retry}`);
483
966
  break;
484
967
  }
968
+ case "session.status_terminated":
969
+ fail(new Error("session terminated"));
970
+ break;
485
971
  case "session.deleted":
486
972
  fail(new Error("session deleted"));
487
973
  break;
@@ -490,46 +976,177 @@ var AcpAgent = class {
490
976
  this.inflight = true;
491
977
  const timeout = setTimeout(() => {
492
978
  log(`turn timeout after ${this.config.turnTimeoutMs}ms \u2014 interrupting`);
979
+ this.interruptRequested = true;
493
980
  this.api.interrupt(sessionId).catch(() => void 0);
494
981
  settle("cancelled");
495
982
  }, this.config.turnTimeoutMs);
983
+ let requestPollStop = null;
984
+ if (taskId) {
985
+ requestPollStop = this.startRequestPoll(acpSessionId, taskId, done);
986
+ }
496
987
  try {
497
988
  await Promise.race([
498
989
  bus.waitConnected(),
499
990
  new Promise((resolve) => setTimeout(resolve, 1e4))
500
991
  ]);
501
- await this.api.sendMessage(sessionId, `${text}
992
+ const turnKey = randomUUID2();
993
+ const messageText = this.config.replyPost && channelId ? `${userText}
502
994
 
503
- ${BRIDGE_NOTE}`, randomUUID());
995
+ ${BRIDGE_NOTE}` : userText;
996
+ if (systemText) {
997
+ await this.api.sendMessageWithContext(sessionId, messageText, systemText, turnKey);
998
+ } else {
999
+ await this.api.sendMessage(sessionId, messageText, turnKey);
1000
+ }
1001
+ sawTurnActivity = true;
504
1002
  const stopReason = await done;
505
1003
  if (this.config.replyPost && channelId && replyText.trim()) {
506
1004
  await postReply(this.config.buzzBin, channelId, replyText.trim());
507
- } else if (!channelId && replyText.trim()) {
1005
+ } else if (!channelId && replyText.trim() && this.config.replyPost) {
508
1006
  log("no channel UUID found in prompt \u2014 reply not posted to buzz");
509
1007
  }
510
1008
  return { stopReason };
511
1009
  } catch (error) {
512
1010
  if (error instanceof ApiError) {
1011
+ if (error.status === 401 || error.status === 403) {
1012
+ throw new RpcError(-32e3, `authentication failed: ${error.message}`);
1013
+ }
513
1014
  throw new RpcError(-32603, `agentsky api error (${error.code}): ${error.message}`);
514
1015
  }
515
1016
  throw error instanceof RpcError ? error : new RpcError(-32603, error instanceof Error ? error.message : String(error));
516
1017
  } finally {
517
1018
  clearTimeout(timeout);
518
1019
  unsubscribe();
1020
+ requestPollStop?.();
519
1021
  this.inflight = false;
520
1022
  }
521
1023
  }
1024
+ // ── Task request forwarding (item 4) ─────────────────────────────────────
1025
+ startRequestPoll(acpSessionId, taskId, turnDone) {
1026
+ let stopped = false;
1027
+ const stop = () => {
1028
+ stopped = true;
1029
+ };
1030
+ void (async () => {
1031
+ const POLL_INTERVAL_MS = 3e3;
1032
+ const handledRequests = /* @__PURE__ */ new Set();
1033
+ while (!stopped) {
1034
+ await Promise.race([
1035
+ new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)),
1036
+ turnDone
1037
+ ]);
1038
+ if (stopped) break;
1039
+ let openRequests;
1040
+ try {
1041
+ openRequests = await this.api.listOpenRequests(taskId);
1042
+ } catch {
1043
+ continue;
1044
+ }
1045
+ for (const req of openRequests) {
1046
+ if (handledRequests.has(req.id)) continue;
1047
+ handledRequests.add(req.id);
1048
+ void this.handleTaskRequest(acpSessionId, taskId, req);
1049
+ }
1050
+ }
1051
+ })();
1052
+ return stop;
1053
+ }
1054
+ async handleTaskRequest(acpSessionId, taskId, req) {
1055
+ const permissionType = this.mapKindToPermission(req.kind);
1056
+ let granted = false;
1057
+ try {
1058
+ const response = await this.rpc.request(
1059
+ "session/request_permission",
1060
+ {
1061
+ sessionId: acpSessionId ?? "",
1062
+ permission: {
1063
+ type: permissionType,
1064
+ title: req.subject ?? req.kind,
1065
+ description: req.question ?? "",
1066
+ options: req.options,
1067
+ allowFreeText: req.allow_free_text,
1068
+ requestId: req.id
1069
+ }
1070
+ },
1071
+ this.config.requestTimeoutMs
1072
+ );
1073
+ granted = response?.granted === true;
1074
+ } catch (error) {
1075
+ log(
1076
+ `permission request timed out or failed for ${req.id}: ${error instanceof Error ? error.message : String(error)}`
1077
+ );
1078
+ granted = false;
1079
+ }
1080
+ const answer = granted ? { decision: "approved" } : this.d5Fallback(req);
1081
+ try {
1082
+ await this.api.answerRequest(taskId, req.id, answer);
1083
+ } catch (error) {
1084
+ log(
1085
+ `failed to answer request ${req.id}: ${error instanceof Error ? error.message : String(error)}`
1086
+ );
1087
+ }
1088
+ }
1089
+ mapKindToPermission(kind) {
1090
+ switch (kind) {
1091
+ case "approval":
1092
+ return "execute";
1093
+ case "spend":
1094
+ return "spend";
1095
+ case "connect_app":
1096
+ return "connect";
1097
+ case "connect_account":
1098
+ return "connect";
1099
+ case "question":
1100
+ return "question";
1101
+ default:
1102
+ return kind;
1103
+ }
1104
+ }
1105
+ d5Fallback(req) {
1106
+ switch (req.kind) {
1107
+ case "approval":
1108
+ case "spend":
1109
+ return { decision: "declined", reason: "ACP harness did not grant permission" };
1110
+ case "connect_app":
1111
+ case "connect_account":
1112
+ return {
1113
+ decision: "declined",
1114
+ reason: "ACP harness did not grant permission",
1115
+ link: req.link ?? null
1116
+ };
1117
+ case "question":
1118
+ return {
1119
+ decision: "answered",
1120
+ answer: "No one can answer here; proceed on your best judgement and state what you assumed"
1121
+ };
1122
+ default:
1123
+ return { decision: "declined", reason: "ACP harness did not grant permission" };
1124
+ }
1125
+ }
1126
+ // ── Cancel ────────────────────────────────────────────────────────────────
522
1127
  async cancel() {
523
- if (!this.sessionId || !this.inflight) return;
524
- await this.api.interrupt(this.sessionId).catch((error) => {
1128
+ if (!this.inflight) return;
1129
+ this.interruptRequested = true;
1130
+ let sessionId = null;
1131
+ if (this.config.sessionMode === "agent" && this.sharedState) {
1132
+ sessionId = this.sharedState.skySessionId;
1133
+ } else {
1134
+ const last = [...this.perAcpSessions.values()].at(-1);
1135
+ if (last) sessionId = last.skySessionId;
1136
+ }
1137
+ if (!sessionId) return;
1138
+ await this.api.interrupt(sessionId).catch((error) => {
525
1139
  log(`interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
526
1140
  });
1141
+ await new Promise((resolve) => setTimeout(resolve, 5e3));
527
1142
  }
528
1143
  update(sessionId, update) {
529
1144
  this.rpc.notify("session/update", { sessionId: sessionId ?? "", update });
530
1145
  }
531
1146
  shutdown() {
532
- this.bus?.stop();
1147
+ for (const state of this.perAcpSessions.values()) state.bus.stop();
1148
+ this.sharedState?.bus.stop();
1149
+ this.bridge.stop();
533
1150
  }
534
1151
  };
535
1152
 
@@ -545,7 +1162,9 @@ async function main() {
545
1162
  };
546
1163
  process.on("SIGINT", shutdown);
547
1164
  process.on("SIGTERM", shutdown);
548
- log(`agentsky-acp ${VERSION} \u2014 agent ${config.agent} via ${config.apiBase}`);
1165
+ log(
1166
+ `agentsky-acp ${VERSION} \u2014 agent ${config.agent} via ${config.apiBase} (session=${config.sessionMode}, context-block=${config.contextBlock})`
1167
+ );
549
1168
  await rpc.run();
550
1169
  shutdown();
551
1170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsky/acp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "agentsky-acp — ACP (Agent Client Protocol) adapter: run an AgentSky cloud agent as a local ACP agent over stdio.",
5
5
  "license": "MIT",
6
6
  "type": "module",