@nanobpm/agentic 0.1.0 → 0.4.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 (117) hide show
  1. package/README.md +1 -0
  2. package/dist/demand/model.d.ts +7 -4
  3. package/dist/demand/model.js +22 -4
  4. package/dist/demand/taskdef.d.ts +13 -1
  5. package/dist/demand/taskdef.js +20 -2
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/protocol/conformance/frames.js +32 -4
  9. package/dist/protocol/index.d.ts +1 -1
  10. package/dist/protocol/payloads.d.ts +44 -0
  11. package/dist/protocol/payloads.js +61 -7
  12. package/dist/session/acp/client.d.ts +109 -0
  13. package/dist/session/acp/client.js +254 -0
  14. package/dist/session/acp/index.d.ts +27 -0
  15. package/dist/session/acp/index.js +27 -0
  16. package/dist/session/acp/jsonrpc.d.ts +25 -0
  17. package/dist/session/acp/jsonrpc.js +148 -0
  18. package/dist/session/acp/normalize.d.ts +48 -0
  19. package/dist/session/acp/normalize.js +162 -0
  20. package/dist/session/acp/protocol.d.ts +94 -0
  21. package/dist/session/acp/protocol.js +136 -0
  22. package/dist/session/acp/spawn.d.ts +36 -0
  23. package/dist/session/acp/spawn.js +68 -0
  24. package/dist/session/acp/transport.d.ts +62 -0
  25. package/dist/session/acp/transport.js +126 -0
  26. package/dist/session/adapter.d.ts +135 -0
  27. package/dist/session/adapter.js +24 -0
  28. package/dist/session/backend.d.ts +43 -0
  29. package/dist/session/backend.js +95 -0
  30. package/dist/session/events.d.ts +152 -0
  31. package/dist/session/events.js +192 -0
  32. package/dist/session/index.d.ts +31 -0
  33. package/dist/session/index.js +5 -0
  34. package/dist/session/log.d.ts +107 -0
  35. package/dist/session/log.js +351 -0
  36. package/dist/session/normalizer/claude.d.ts +23 -0
  37. package/dist/session/normalizer/claude.js +138 -0
  38. package/dist/session/normalizer/copilot.d.ts +27 -0
  39. package/dist/session/normalizer/copilot.js +105 -0
  40. package/dist/session/normalizer/deepseek.d.ts +11 -0
  41. package/dist/session/normalizer/deepseek.js +68 -0
  42. package/dist/session/normalizer/index.d.ts +36 -0
  43. package/dist/session/normalizer/index.js +29 -0
  44. package/dist/session/normalizer/kimi.d.ts +10 -0
  45. package/dist/session/normalizer/kimi.js +80 -0
  46. package/dist/session/normalizer/link.d.ts +36 -0
  47. package/dist/session/normalizer/link.js +56 -0
  48. package/dist/session/normalizer/pi.d.ts +13 -0
  49. package/dist/session/normalizer/pi.js +61 -0
  50. package/dist/session/normalizer/qwen.d.ts +11 -0
  51. package/dist/session/normalizer/qwen.js +65 -0
  52. package/dist/session/normalizer/record.d.ts +21 -0
  53. package/dist/session/normalizer/record.js +87 -0
  54. package/dist/session/normalizer/types.d.ts +139 -0
  55. package/dist/session/normalizer/types.js +31 -0
  56. package/dist/session/schema.d.ts +38 -0
  57. package/dist/session/schema.js +74 -0
  58. package/package.json +17 -1
  59. package/src/demand/model.test.ts +82 -4
  60. package/src/demand/model.ts +30 -9
  61. package/src/demand/taskdef.test.ts +51 -6
  62. package/src/demand/taskdef.ts +31 -2
  63. package/src/index.ts +1 -0
  64. package/src/protocol/conformance/frames.ts +32 -4
  65. package/src/protocol/index.ts +4 -0
  66. package/src/protocol/payloads.test.ts +31 -1
  67. package/src/protocol/payloads.ts +110 -7
  68. package/src/session/acp/client.test.ts +222 -0
  69. package/src/session/acp/client.ts +356 -0
  70. package/src/session/acp/fake-agent.ts +71 -0
  71. package/src/session/acp/index.ts +68 -0
  72. package/src/session/acp/integration.test.ts +37 -0
  73. package/src/session/acp/jsonrpc.test.ts +75 -0
  74. package/src/session/acp/jsonrpc.ts +171 -0
  75. package/src/session/acp/normalize.test.ts +150 -0
  76. package/src/session/acp/normalize.ts +204 -0
  77. package/src/session/acp/protocol.ts +178 -0
  78. package/src/session/acp/spawn.test.ts +45 -0
  79. package/src/session/acp/spawn.ts +91 -0
  80. package/src/session/acp/transport.test.ts +82 -0
  81. package/src/session/acp/transport.ts +155 -0
  82. package/src/session/adapter.ts +159 -0
  83. package/src/session/backend.test.ts +198 -0
  84. package/src/session/backend.ts +128 -0
  85. package/src/session/events.test.ts +168 -0
  86. package/src/session/events.ts +347 -0
  87. package/src/session/index.ts +67 -0
  88. package/src/session/log.test.ts +215 -0
  89. package/src/session/log.ts +525 -0
  90. package/src/session/normalizer/backend-integration.test.ts +103 -0
  91. package/src/session/normalizer/claude.test.ts +68 -0
  92. package/src/session/normalizer/claude.ts +136 -0
  93. package/src/session/normalizer/copilot.test.ts +59 -0
  94. package/src/session/normalizer/copilot.ts +133 -0
  95. package/src/session/normalizer/deepseek.ts +80 -0
  96. package/src/session/normalizer/index.ts +61 -0
  97. package/src/session/normalizer/kimi.ts +82 -0
  98. package/src/session/normalizer/link.test.ts +24 -0
  99. package/src/session/normalizer/link.ts +81 -0
  100. package/src/session/normalizer/pi.ts +75 -0
  101. package/src/session/normalizer/probe.test.ts +49 -0
  102. package/src/session/normalizer/qwen.test.ts +20 -0
  103. package/src/session/normalizer/qwen.ts +77 -0
  104. package/src/session/normalizer/record.test.ts +68 -0
  105. package/src/session/normalizer/record.ts +88 -0
  106. package/src/session/normalizer/resume.test.ts +25 -0
  107. package/src/session/normalizer/types.ts +152 -0
  108. package/src/session/normalizer/vectors.test.ts +180 -0
  109. package/src/session/schema.test.ts +84 -0
  110. package/src/session/schema.ts +78 -0
  111. package/src/session/test-db.ts +56 -0
  112. package/dist/blackboard/test-db.d.ts +0 -5
  113. package/dist/blackboard/test-db.js +0 -42
  114. package/dist/presence/test-db.d.ts +0 -5
  115. package/dist/presence/test-db.js +0 -42
  116. package/dist/transcript/test-db.d.ts +0 -5
  117. package/dist/transcript/test-db.js +0 -41
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The ACP ingestion backend — ADR 0062, slice 2 (the preferred adapter target).
3
+ *
4
+ * {@link AcpSessionClient} connects an ACP-speaking harness (reference:
5
+ * `opencode acp`; also Claude via `claude-code-acp`, the Gemini lineage) to a
6
+ * Slice-1 {@link SessionEventSink} — normalising the harness's `session/update`
7
+ * stream into canonical {@link SessionEvent}s and driving/steering the session
8
+ * over JSON-RPC. It realises the three ADR 0062 §5 primitives:
9
+ *
10
+ * - `session/update` → **emit**: each notification is classified
11
+ * ({@link classifyUpdate}), streamed message chunks are coalesced into whole
12
+ * `assistant`/`reasoning`/`user` events, and the `tool_call`/`tool_call_update`
13
+ * lifecycle becomes canonical `tool-call`/`tool-result` events.
14
+ * - `session/load` → **restore**: replays the harness's prior history (delivered
15
+ * as `session/update`s during the load) back into the sink.
16
+ * - `agentCapabilities.loadSession` (from `initialize`) → the **durable-resume
17
+ * capability probe** the nano-workforce enrolment gate (slice 5) consumes.
18
+ *
19
+ * This backend owns event **identity** and **causality** (the `id`/`parentId`
20
+ * chain) exactly as `events.ts` prescribes — ACP carries neither — while the sink
21
+ * (a {@link SessionAdapter}) owns ordering and fencing. Keeping those split lets a
22
+ * `SessionBackend` be passed straight in as the sink with no adapter.
23
+ */
24
+ import { randomUUID } from "node:crypto";
25
+ import { AcpConnection } from "./jsonrpc.js";
26
+ import { classifyUpdate } from "./normalize.js";
27
+ import { ACP_CLIENT_METHOD, ACP_METHOD, ACP_PROTOCOL_VERSION, isRecord, parseInitializeResult, parseSessionId, } from "./protocol.js";
28
+ /**
29
+ * The ACP ingestion client. Bind it to a transport and a sink, `initialize`, then
30
+ * either `newSession` + `prompt` (drive) or `restore` an existing session id.
31
+ */
32
+ export class AcpSessionClient {
33
+ #connection;
34
+ #sink;
35
+ #newEventId;
36
+ #clientInfo;
37
+ #lastId = null;
38
+ #buffer;
39
+ #collector = null;
40
+ #sessionId;
41
+ constructor(connection, sink, options = {}) {
42
+ this.#connection = connection;
43
+ this.#sink = sink;
44
+ this.#newEventId = options.newEventId ?? randomUUID;
45
+ this.#clientInfo = options.clientInfo ?? { name: "nano-agentic", version: "0" };
46
+ const permit = options.onPermissionRequest ?? defaultPermissionResponse;
47
+ connection.onNotification(ACP_CLIENT_METHOD.sessionUpdate, (params) => this.#ingest(params));
48
+ connection.onRequest(ACP_CLIENT_METHOD.requestPermission, (params) => permit(params));
49
+ }
50
+ /** The active session id (after `newSession`/`restore`), or `undefined`. */
51
+ get sessionId() {
52
+ return this.#sessionId;
53
+ }
54
+ /**
55
+ * Perform the `initialize` handshake and return the durable-resume capability
56
+ * probe. Must be called before any `session/*` method.
57
+ */
58
+ async initialize() {
59
+ const result = await this.#connection.request(ACP_METHOD.initialize, {
60
+ protocolVersion: ACP_PROTOCOL_VERSION,
61
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
62
+ clientInfo: this.#clientInfo,
63
+ });
64
+ const parsed = parseInitializeResult(result);
65
+ return {
66
+ protocolVersion: parsed.protocolVersion,
67
+ loadSession: parsed.agentCapabilities.loadSession,
68
+ durableResume: parsed.agentCapabilities.loadSession,
69
+ promptCapabilities: parsed.agentCapabilities.promptCapabilities,
70
+ agentCapabilities: parsed.rawAgentCapabilities,
71
+ };
72
+ }
73
+ /** Open a fresh session (`session/new`); stores and returns its id. */
74
+ async newSession(params) {
75
+ const result = await this.#connection.request(ACP_METHOD.sessionNew, {
76
+ cwd: params.cwd,
77
+ mcpServers: params.mcpServers ?? [],
78
+ });
79
+ this.#sessionId = parseSessionId(result);
80
+ return this.#sessionId;
81
+ }
82
+ /**
83
+ * **restore** — load an existing session (`session/load`). The agent replays its
84
+ * prior history as `session/update` notifications, which this client ingests
85
+ * into the sink; when the load resolves, the pending message is flushed. Returns
86
+ * the canonical events reconstructed from the replayed history, in order.
87
+ *
88
+ * Only valid against an agent whose probe reported `durableResume: true`.
89
+ */
90
+ async restore(sessionId, params) {
91
+ const collected = await this.#collecting(() => this.#connection.request(ACP_METHOD.sessionLoad, {
92
+ sessionId,
93
+ cwd: params.cwd,
94
+ mcpServers: params.mcpServers ?? [],
95
+ }));
96
+ this.#sessionId = sessionId;
97
+ return collected.events;
98
+ }
99
+ /**
100
+ * **drive** — send a prompt (`session/prompt`) and resolve when the turn ends.
101
+ * All assistant output arrives as `session/update` notifications and is emitted
102
+ * to the sink during the call; the final buffered message is flushed on
103
+ * completion.
104
+ */
105
+ async prompt(input) {
106
+ if (this.#sessionId === undefined) {
107
+ throw new Error("prompt requires an active session — call newSession() or restore() first");
108
+ }
109
+ const prompt = typeof input === "string" ? [{ type: "text", text: input }] : input;
110
+ const collected = await this.#collecting(() => this.#connection.request(ACP_METHOD.sessionPrompt, { sessionId: this.#sessionId, prompt }));
111
+ return { stopReason: readStopReason(collected.result), events: collected.events };
112
+ }
113
+ /** **steer** — request cancellation of the running turn (`session/cancel`, a notification). */
114
+ cancel() {
115
+ if (this.#sessionId === undefined)
116
+ return;
117
+ this.#connection.notify(ACP_METHOD.sessionCancel, { sessionId: this.#sessionId });
118
+ }
119
+ /** Close the underlying connection and flush any pending buffered message. */
120
+ close() {
121
+ this.#flushMessage();
122
+ this.#connection.close();
123
+ }
124
+ async #collecting(op) {
125
+ const events = [];
126
+ const previous = this.#collector;
127
+ this.#collector = events;
128
+ try {
129
+ const result = await op();
130
+ // Every session/update queued before the response has been ingested by now
131
+ // (ordered transport delivery); flush the final in-flight message.
132
+ this.#flushMessage();
133
+ return { result, events };
134
+ }
135
+ finally {
136
+ this.#collector = previous;
137
+ }
138
+ }
139
+ #ingest(params) {
140
+ if (!isRecord(params))
141
+ return;
142
+ // session/update carries the target sessionId (ACP). Once this client is driving
143
+ // a session, ignore updates addressed to a *different* session — a late update
144
+ // from a previous session, or a misrouted one, must not corrupt this stream.
145
+ // Guarded on #sessionId being set: during session/load replay the id is not yet
146
+ // stored, and those updates legitimately belong to the session being restored.
147
+ const sessionId = params.sessionId;
148
+ if (this.#sessionId !== undefined && typeof sessionId === "string" && sessionId !== this.#sessionId) {
149
+ return;
150
+ }
151
+ const update = classifyUpdate(params.update);
152
+ switch (update.kind) {
153
+ case "message":
154
+ this.#appendMessage(update);
155
+ return;
156
+ case "tool-call": {
157
+ this.#flushMessage();
158
+ const { id, parentId } = this.#nextIdentity();
159
+ this.#push({ type: "tool-call", id, parentId, callId: update.callId, name: update.name, args: update.args });
160
+ return;
161
+ }
162
+ case "tool-result": {
163
+ this.#flushMessage();
164
+ const { id, parentId } = this.#nextIdentity();
165
+ this.#push({ type: "tool-result", id, parentId, callId: update.callId, ok: update.ok, result: update.result });
166
+ return;
167
+ }
168
+ case "ignored":
169
+ return;
170
+ }
171
+ }
172
+ #appendMessage(update) {
173
+ const buffer = this.#buffer;
174
+ if (buffer !== undefined && buffer.role === update.role && sameMessage(buffer.messageId, update.messageId)) {
175
+ this.#buffer = { role: buffer.role, messageId: buffer.messageId, text: buffer.text + update.text };
176
+ return;
177
+ }
178
+ this.#flushMessage();
179
+ this.#buffer = { role: update.role, messageId: update.messageId, text: update.text };
180
+ }
181
+ #flushMessage() {
182
+ const buffer = this.#buffer;
183
+ if (buffer === undefined)
184
+ return;
185
+ this.#buffer = undefined;
186
+ const { id, parentId } = this.#nextIdentity();
187
+ switch (buffer.role) {
188
+ case "assistant":
189
+ this.#push({ type: "assistant", id, parentId, text: buffer.text });
190
+ return;
191
+ case "reasoning":
192
+ this.#push({ type: "reasoning", id, parentId, text: buffer.text });
193
+ return;
194
+ case "user":
195
+ this.#push({ type: "user", id, parentId, text: buffer.text });
196
+ return;
197
+ }
198
+ }
199
+ #nextIdentity() {
200
+ const id = this.#newEventId();
201
+ const parentId = this.#lastId;
202
+ this.#lastId = id;
203
+ return { id, parentId };
204
+ }
205
+ #push(event) {
206
+ this.#sink.emit(event);
207
+ this.#collector?.push(event);
208
+ }
209
+ }
210
+ /**
211
+ * Two message chunks belong to the same logical message when their `messageId`s
212
+ * are equal, or when neither carries one (the agent omitted it) — in which case
213
+ * consecutive same-role chunks coalesce until a role change or a tool boundary.
214
+ */
215
+ function sameMessage(a, b) {
216
+ if (a === null && b === null)
217
+ return true;
218
+ return a === b;
219
+ }
220
+ function readStopReason(result) {
221
+ if (isRecord(result) && typeof result.stopReason === "string")
222
+ return result.stopReason;
223
+ return null;
224
+ }
225
+ /**
226
+ * Select the first option whose kind reads as an approval from a
227
+ * `session/request_permission` request, so a driven session proceeds without a
228
+ * human in the loop; cancel when the agent offers no allow option. Full
229
+ * interactive-permission and client-side `fs`/`terminal` support is out of this
230
+ * slice's scope (this backend advertises neither capability at `initialize`).
231
+ */
232
+ function defaultPermissionResponse(params) {
233
+ if (isRecord(params) && Array.isArray(params.options)) {
234
+ for (const option of params.options) {
235
+ if (!isRecord(option))
236
+ continue;
237
+ const kind = option.kind;
238
+ if (typeof kind === "string" && kind.startsWith("allow") && typeof option.optionId === "string") {
239
+ return { outcome: { outcome: "selected", optionId: option.optionId } };
240
+ }
241
+ }
242
+ }
243
+ // No "allow"-flavoured option (or no options at all): cancel, as documented,
244
+ // rather than silently selecting an arbitrary — possibly deny — option.
245
+ return { outcome: { outcome: "cancelled" } };
246
+ }
247
+ /**
248
+ * Open an ACP ingestion client over `transport`, emitting into `sink`. Wraps the
249
+ * transport in an {@link AcpConnection} and returns the ready client; call
250
+ * {@link AcpSessionClient.initialize} first.
251
+ */
252
+ export function openAcpSession(transport, sink, options = {}) {
253
+ return new AcpSessionClient(new AcpConnection(transport), sink, options);
254
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@nanobpm/agentic/session/acp` — the ACP ingestion backend (ADR 0062, slice 2).
3
+ *
4
+ * The preferred harness-facing backend of the `@nanobpm/agentic/session` contract
5
+ * (ADR 0062 §5): an Agent Client Protocol (protocol v1) client that connects to an
6
+ * ACP-speaking harness (`opencode acp`, `claude-code-acp`, the Gemini lineage),
7
+ * runs the `initialize` handshake to probe `agentCapabilities.loadSession`, and
8
+ * normalises the `session/update` stream into canonical {@link SessionEvent}s —
9
+ * driving via `session/prompt`, steering via `session/cancel`, and **restoring**
10
+ * prior history via `session/load`.
11
+ *
12
+ * Exports, in the order a consumer meets them:
13
+ * - {@link openAcpSession} / {@link AcpSessionClient} — the ingestion client and
14
+ * its capability probe / prompt-result types;
15
+ * - {@link classifyUpdate} + {@link ACP_FIDELITY_GAPS} — the pure wire→canonical
16
+ * normaliser and the enumerated ADR 0062 §5 fidelity gaps that justify slice 3;
17
+ * - the {@link AcpConnection} JSON-RPC peer and the {@link AcpTransport} seam,
18
+ * with {@link spawnAcpTransport} (a real harness subprocess) and
19
+ * {@link inMemoryTransportPair} (an in-memory peer for tests);
20
+ * - the ACP wire vocabulary ({@link ACP_METHOD}, guards) from `protocol.ts`.
21
+ */
22
+ export { AcpSessionClient, type AcpCapabilityProbe, type AcpPromptInput, type AcpPromptResult, type AcpSessionClientOptions, type AcpSessionParams, type AcpTextContentBlock, openAcpSession, type SessionEventSink, } from "./client.ts";
23
+ export { ACP_FIDELITY_GAPS, type AcpClassifiedUpdate, type AcpFidelityGap, classifyUpdate, } from "./normalize.ts";
24
+ export { AcpConnection, type AcpNotificationHandler, type AcpRequestHandler, AcpRpcError, } from "./jsonrpc.ts";
25
+ export { type AcpTransport, encodeMessageLine, inMemoryTransportPair, NewlineJsonDecoder, } from "./transport.ts";
26
+ export { type SpawnAcpOptions, type SpawnedAcpTransport, spawnAcpTransport } from "./spawn.ts";
27
+ export { ACP_CLIENT_METHOD, ACP_METHOD, ACP_PROTOCOL_VERSION, type AcpAgentCapabilities, type AcpInitializeResult, type AcpPromptCapabilities, AcpProtocolError, contentBlockText, parseInitializeResult, parseSessionId, } from "./protocol.ts";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@nanobpm/agentic/session/acp` — the ACP ingestion backend (ADR 0062, slice 2).
3
+ *
4
+ * The preferred harness-facing backend of the `@nanobpm/agentic/session` contract
5
+ * (ADR 0062 §5): an Agent Client Protocol (protocol v1) client that connects to an
6
+ * ACP-speaking harness (`opencode acp`, `claude-code-acp`, the Gemini lineage),
7
+ * runs the `initialize` handshake to probe `agentCapabilities.loadSession`, and
8
+ * normalises the `session/update` stream into canonical {@link SessionEvent}s —
9
+ * driving via `session/prompt`, steering via `session/cancel`, and **restoring**
10
+ * prior history via `session/load`.
11
+ *
12
+ * Exports, in the order a consumer meets them:
13
+ * - {@link openAcpSession} / {@link AcpSessionClient} — the ingestion client and
14
+ * its capability probe / prompt-result types;
15
+ * - {@link classifyUpdate} + {@link ACP_FIDELITY_GAPS} — the pure wire→canonical
16
+ * normaliser and the enumerated ADR 0062 §5 fidelity gaps that justify slice 3;
17
+ * - the {@link AcpConnection} JSON-RPC peer and the {@link AcpTransport} seam,
18
+ * with {@link spawnAcpTransport} (a real harness subprocess) and
19
+ * {@link inMemoryTransportPair} (an in-memory peer for tests);
20
+ * - the ACP wire vocabulary ({@link ACP_METHOD}, guards) from `protocol.ts`.
21
+ */
22
+ export { AcpSessionClient, openAcpSession, } from "./client.js";
23
+ export { ACP_FIDELITY_GAPS, classifyUpdate, } from "./normalize.js";
24
+ export { AcpConnection, AcpRpcError, } from "./jsonrpc.js";
25
+ export { encodeMessageLine, inMemoryTransportPair, NewlineJsonDecoder, } from "./transport.js";
26
+ export { spawnAcpTransport } from "./spawn.js";
27
+ export { ACP_CLIENT_METHOD, ACP_METHOD, ACP_PROTOCOL_VERSION, AcpProtocolError, contentBlockText, parseInitializeResult, parseSessionId, } from "./protocol.js";
@@ -0,0 +1,25 @@
1
+ import type { AcpTransport } from "./transport.ts";
2
+ /** A handler for an inbound agent→client request; its resolved value is the result. */
3
+ export type AcpRequestHandler = (params: unknown) => unknown | Promise<unknown>;
4
+ /** A handler for an inbound agent→client notification (no response). */
5
+ export type AcpNotificationHandler = (params: unknown) => void;
6
+ /** A JSON-RPC error returned by, or raised toward, the peer. */
7
+ export declare class AcpRpcError extends Error {
8
+ readonly code: number;
9
+ readonly data: unknown;
10
+ constructor(code: number, message: string, data?: unknown);
11
+ }
12
+ export declare class AcpConnection {
13
+ #private;
14
+ constructor(transport: AcpTransport);
15
+ /** Send a request and resolve with its `result` (or reject with its `error`). */
16
+ request(method: string, params?: unknown): Promise<unknown>;
17
+ /** Send a fire-and-forget notification (no id, no response). */
18
+ notify(method: string, params?: unknown): void;
19
+ /** Register the handler for an inbound notification `method` (last wins). */
20
+ onNotification(method: string, handler: AcpNotificationHandler): void;
21
+ /** Register the handler for an inbound request `method` (last wins). */
22
+ onRequest(method: string, handler: AcpRequestHandler): void;
23
+ /** Close the connection, rejecting every in-flight request. */
24
+ close(): void;
25
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * A minimal JSON-RPC 2.0 peer for ACP — ADR 0062, slice 2.
3
+ *
4
+ * ACP is symmetric: the client sends requests/notifications to the agent
5
+ * (`initialize`, `session/*`) and the agent sends requests/notifications back to
6
+ * the client (`session/update`, `session/request_permission`). {@link AcpConnection}
7
+ * is the bidirectional peer over an {@link AcpTransport}: it correlates responses
8
+ * to outbound requests by id, dispatches inbound notifications and requests to
9
+ * registered handlers, and answers an unhandled inbound request with a
10
+ * JSON-RPC "method not found" instead of hanging the agent.
11
+ *
12
+ * It is intentionally tiny and dependency-free — the repo has no JSON-RPC library
13
+ * and this is the only surface that needs one, so a focused peer beats pulling a
14
+ * general framework (and avoids a drift surface).
15
+ */
16
+ import { isRecord } from "./protocol.js";
17
+ /** A JSON-RPC error returned by, or raised toward, the peer. */
18
+ export class AcpRpcError extends Error {
19
+ code;
20
+ data;
21
+ constructor(code, message, data) {
22
+ super(message);
23
+ this.name = "AcpRpcError";
24
+ this.code = code;
25
+ this.data = data;
26
+ }
27
+ }
28
+ const METHOD_NOT_FOUND = -32601;
29
+ const INTERNAL_ERROR = -32603;
30
+ export class AcpConnection {
31
+ #transport;
32
+ #pending = new Map();
33
+ #notificationHandlers = new Map();
34
+ #requestHandlers = new Map();
35
+ #nextId = 1;
36
+ #closed = false;
37
+ constructor(transport) {
38
+ this.#transport = transport;
39
+ transport.onMessage((message) => this.#dispatch(message));
40
+ transport.onError((error) => this.#failAll(error));
41
+ }
42
+ /** Send a request and resolve with its `result` (or reject with its `error`). */
43
+ request(method, params) {
44
+ if (this.#closed) {
45
+ return Promise.reject(new Error(`ACP connection is closed; cannot call ${method}`));
46
+ }
47
+ const id = this.#nextId++;
48
+ return new Promise((resolve, reject) => {
49
+ this.#pending.set(id, { resolve, reject });
50
+ this.#transport.send({ jsonrpc: "2.0", id, method, params });
51
+ });
52
+ }
53
+ /** Send a fire-and-forget notification (no id, no response). */
54
+ notify(method, params) {
55
+ if (this.#closed)
56
+ return;
57
+ this.#transport.send({ jsonrpc: "2.0", method, params });
58
+ }
59
+ /** Register the handler for an inbound notification `method` (last wins). */
60
+ onNotification(method, handler) {
61
+ this.#notificationHandlers.set(method, handler);
62
+ }
63
+ /** Register the handler for an inbound request `method` (last wins). */
64
+ onRequest(method, handler) {
65
+ this.#requestHandlers.set(method, handler);
66
+ }
67
+ /** Close the connection, rejecting every in-flight request. */
68
+ close() {
69
+ if (this.#closed)
70
+ return;
71
+ this.#failAll(new Error("ACP connection closed"));
72
+ this.#closed = true;
73
+ this.#transport.close();
74
+ }
75
+ #dispatch(message) {
76
+ if (!isRecord(message))
77
+ return;
78
+ const hasId = "id" in message && (typeof message.id === "number" || typeof message.id === "string");
79
+ const isResponse = hasId && ("result" in message || "error" in message);
80
+ if (isResponse) {
81
+ this.#handleResponse(message);
82
+ return;
83
+ }
84
+ if (typeof message.method === "string") {
85
+ if (hasId) {
86
+ this.#handleInboundRequest(message.method, message.id, message.params);
87
+ }
88
+ else {
89
+ this.#handleInboundNotification(message.method, message.params);
90
+ }
91
+ }
92
+ }
93
+ #handleResponse(message) {
94
+ const id = message.id;
95
+ if (typeof id !== "number")
96
+ return; // we only ever issue numeric ids
97
+ const pending = this.#pending.get(id);
98
+ if (pending === undefined)
99
+ return;
100
+ this.#pending.delete(id);
101
+ if ("error" in message && message.error !== undefined && message.error !== null) {
102
+ pending.reject(toRpcError(message.error));
103
+ return;
104
+ }
105
+ pending.resolve("result" in message ? message.result : undefined);
106
+ }
107
+ #handleInboundNotification(method, params) {
108
+ const handler = this.#notificationHandlers.get(method);
109
+ if (handler !== undefined)
110
+ handler(params);
111
+ }
112
+ #handleInboundRequest(method, id, params) {
113
+ const handler = this.#requestHandlers.get(method);
114
+ if (handler === undefined) {
115
+ this.#transport.send({
116
+ jsonrpc: "2.0",
117
+ id,
118
+ error: { code: METHOD_NOT_FOUND, message: `method not found: ${method}` },
119
+ });
120
+ return;
121
+ }
122
+ Promise.resolve()
123
+ .then(() => handler(params))
124
+ .then((result) => this.#transport.send({ jsonrpc: "2.0", id, result: result ?? null }), (reason) => this.#transport.send({ jsonrpc: "2.0", id, error: errorPayload(reason) }));
125
+ }
126
+ #failAll(error) {
127
+ for (const pending of this.#pending.values())
128
+ pending.reject(error);
129
+ this.#pending.clear();
130
+ }
131
+ }
132
+ function toRpcError(error) {
133
+ if (isRecord(error)) {
134
+ const code = typeof error.code === "number" ? error.code : INTERNAL_ERROR;
135
+ const message = typeof error.message === "string" ? error.message : "ACP request failed";
136
+ return new AcpRpcError(code, message, error.data);
137
+ }
138
+ return new AcpRpcError(INTERNAL_ERROR, "ACP request failed with a non-object error");
139
+ }
140
+ function errorPayload(reason) {
141
+ if (reason instanceof AcpRpcError) {
142
+ return { code: reason.code, message: reason.message, data: reason.data };
143
+ }
144
+ if (reason instanceof Error) {
145
+ return { code: INTERNAL_ERROR, message: reason.message };
146
+ }
147
+ return { code: INTERNAL_ERROR, message: String(reason) };
148
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The outcome of classifying one ACP `session/update`. A `message` still needs
3
+ * its chunks coalesced by the client; a `tool-call`/`tool-result` is a complete
4
+ * canonical event body; an `ignored` update carries no canonical meaning.
5
+ */
6
+ export type AcpClassifiedUpdate = {
7
+ readonly kind: "message";
8
+ readonly role: "assistant" | "reasoning" | "user";
9
+ /** Groups streamed chunks into one message; `null` when the agent omits it. */
10
+ readonly messageId: string | null;
11
+ readonly text: string;
12
+ } | {
13
+ readonly kind: "tool-call";
14
+ readonly callId: string;
15
+ readonly name: string;
16
+ readonly args: unknown;
17
+ } | {
18
+ readonly kind: "tool-result";
19
+ readonly callId: string;
20
+ readonly ok: boolean;
21
+ readonly result: unknown;
22
+ } | {
23
+ readonly kind: "ignored";
24
+ readonly sessionUpdate: string;
25
+ readonly reason: string;
26
+ };
27
+ /**
28
+ * Classify one ACP `update` object (the `params.update` of a `session/update`
29
+ * notification). Pure and total — every input yields a classification, malformed
30
+ * or unknown ones landing in `ignored` with a diagnostic reason rather than
31
+ * throwing, so a single odd notification can never abort an ingestion stream.
32
+ */
33
+ export declare function classifyUpdate(value: unknown): AcpClassifiedUpdate;
34
+ /** One place where ACP is thinner than a native transcript (ADR 0062 §5). */
35
+ export interface AcpFidelityGap {
36
+ /** The canonical concept that cannot be fully reconstructed from ACP alone. */
37
+ readonly concept: string;
38
+ /** Why ACP cannot carry it, and what a native transcript (slice 3) preserves. */
39
+ readonly detail: string;
40
+ }
41
+ /**
42
+ * The enumerated resume-fidelity gaps in the ACP backend — the documented reason
43
+ * (ADR 0062 §5) the slice 3 native normaliser exists as a fallback path. This is
44
+ * a derived source of truth: `normalize.test.ts` asserts the mapping above only
45
+ * ever emits `assistant`/`reasoning`/`user`/`tool-call`/`tool-result`, so any
46
+ * canonical event ACP *cannot* produce is accounted for here.
47
+ */
48
+ export declare const ACP_FIDELITY_GAPS: readonly AcpFidelityGap[];