@executablemd/acp 0.0.0-bootstrap.0 → 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taras Mankovski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/esm/mod.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @module
3
+ * ACPX agent provider for Executable.md (specs/acp-client-spec.md).
4
+ *
5
+ * `createAcpxProvider()` returns an `AgentProviderFactory` that drives
6
+ * coding agents over the Agent Client Protocol through the pinned `acpx`
7
+ * runtime. Supply it directly to core's agent vocabulary through the
8
+ * `rootProvider` option:
9
+ * `installAgentVocabulary({ rootProvider: { factory: createAcpxProvider(), options } })`.
10
+ */
11
+ export { createAcpxProvider, useAcpxProviderState } from "./src/provider.js";
12
+ export { useSerialQueues } from "./src/serial-queue.js";
13
+ /** The agent ACPX selects when nothing else is configured. */
14
+ export { DEFAULT_AGENT_NAME } from "acpx/runtime";
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Turn event normalization (specs/acp-client-spec.md §Prompt).
3
+ *
4
+ * `consumeTurn` receives an already-started ACPX turn and the resolved
5
+ * identity, and produces the normalized public event sequence: exactly
6
+ * one `started`, then `text_delta` events for output-stream deltas only,
7
+ * then exactly one `terminal`, then the channel closes with the complete
8
+ * concatenated text — including partial text on failure. Thought,
9
+ * status, tool, usage, and raw ACP events stay private.
10
+ */
11
+ import { each, stream, until } from "effection";
12
+ export function* consumeTurn(turn, identity, channel, markCompleted) {
13
+ yield* channel.send({ type: "started", agent: identity.agent, session: identity.session });
14
+ let text = "";
15
+ let terminal;
16
+ try {
17
+ for (const event of yield* each(stream(turn.events))) {
18
+ if (event.type === "text_delta" && (event.stream ?? "output") === "output") {
19
+ text += event.text;
20
+ yield* channel.send({ type: "text_delta", text: event.text });
21
+ }
22
+ yield* each.next();
23
+ }
24
+ terminal = mapResult(yield* until(turn.result));
25
+ }
26
+ catch (error) {
27
+ terminal = {
28
+ type: "terminal",
29
+ status: "failed",
30
+ error: error instanceof Error ? error : new Error(String(error)),
31
+ };
32
+ }
33
+ yield* channel.send(terminal);
34
+ markCompleted();
35
+ yield* channel.close(text);
36
+ }
37
+ function mapResult(result) {
38
+ if (result.status === "completed") {
39
+ // ACP defines end_turn as the only successful stop reason. An absent
40
+ // stop reason on a completed turn is treated as end_turn — some
41
+ // adapters omit it on normal completion.
42
+ const stopReason = result.stopReason ?? "end_turn";
43
+ if (stopReason === "end_turn") {
44
+ return { type: "terminal", status: "completed", stopReason };
45
+ }
46
+ return {
47
+ type: "terminal",
48
+ status: "failed",
49
+ stopReason,
50
+ error: new Error(`agent prompt failed with stop reason "${stopReason}"`),
51
+ };
52
+ }
53
+ if (result.status === "cancelled") {
54
+ const terminal = { type: "terminal", status: "cancelled" };
55
+ if (result.stopReason !== undefined) {
56
+ terminal.stopReason = result.stopReason;
57
+ }
58
+ return terminal;
59
+ }
60
+ const failure = new Error(result.error.message);
61
+ return { type: "terminal", status: "failed", error: failure };
62
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Permission bridge (specs/acp-client-spec.md §Permissions).
3
+ *
4
+ * ## Routing
5
+ *
6
+ * ACPX delivers a permission request to `onPermissionRequest` keyed by
7
+ * `params.sessionId` — the ACP session id (`acpSessionId`) of the
8
+ * prompting turn. Each active turn registers its subscribing scope, an
9
+ * initial id, and a `refresh` operation that reloads its ACPX record by
10
+ * `acpxRecordId`. The id map is a cache: before routing, the bridge
11
+ * refreshes and verifies a direct candidate; on a miss it refreshes all
12
+ * active registrations and routes only when exactly one matches the
13
+ * request id. This tracks ACPX's reconnect fallback, which updates
14
+ * `record.acpSessionId` and checkpoints the record before running the
15
+ * prompt. The public `Session.agentSessionId` is updated from the
16
+ * refreshed record.
17
+ *
18
+ * ## Fail closed
19
+ *
20
+ * The decision resolves `{ outcome: "cancel" }` — never `undefined`
21
+ * (which would let ACPX fall back to its mode resolver) and never
22
+ * another scope — on: store errors during refresh, zero or multiple
23
+ * matching registrations, a stale or torn-down prompt scope, a policy
24
+ * error, an unknown selected option id, and an abort (before or during
25
+ * evaluation).
26
+ */
27
+ import { on, race } from "effection";
28
+ import { Agent } from "@executablemd/core";
29
+ const CANCEL = { outcome: "cancel" };
30
+ export function createPermissionBridge() {
31
+ const turns = new Map();
32
+ function rekey(entry, newId) {
33
+ // A deferred refresh must not resurrect an unregistered entry.
34
+ if (!entry.active || newId === entry.currentId) {
35
+ return;
36
+ }
37
+ if (turns.get(entry.currentId) === entry) {
38
+ turns.delete(entry.currentId);
39
+ }
40
+ entry.currentId = newId;
41
+ turns.set(newId, entry);
42
+ }
43
+ // Reload one registration's record, apply the current agent session id
44
+ // to the public Session, rekey on a changed ACP session id, and return
45
+ // that ACP session id. `undefined` means "does not currently match"
46
+ // (missing record) — a thrown store error propagates to fail closed.
47
+ function* refreshEntry(entry) {
48
+ const record = yield* entry.refresh();
49
+ if (!record) {
50
+ return undefined;
51
+ }
52
+ if (typeof record.agentSessionId === "string") {
53
+ entry.session.agentSessionId = record.agentSessionId;
54
+ }
55
+ if (typeof record.acpSessionId === "string") {
56
+ rekey(entry, record.acpSessionId);
57
+ return record.acpSessionId;
58
+ }
59
+ return entry.currentId;
60
+ }
61
+ function* resolveTarget(sessionId) {
62
+ const direct = turns.get(sessionId);
63
+ if (direct && direct.active) {
64
+ const refreshed = yield* refreshEntry(direct);
65
+ if (refreshed === sessionId) {
66
+ return direct;
67
+ }
68
+ }
69
+ const matches = [];
70
+ for (const entry of new Set(turns.values())) {
71
+ if (!entry.active) {
72
+ continue;
73
+ }
74
+ const refreshed = yield* refreshEntry(entry);
75
+ if (refreshed === sessionId) {
76
+ matches.push(entry);
77
+ }
78
+ }
79
+ return matches.length === 1 ? matches[0] : undefined;
80
+ }
81
+ return {
82
+ register(acpSessionId, scope, session, refresh) {
83
+ const entry = {
84
+ scope,
85
+ session,
86
+ refresh,
87
+ currentId: acpSessionId,
88
+ active: true,
89
+ };
90
+ turns.set(acpSessionId, entry);
91
+ return {
92
+ unregister() {
93
+ entry.active = false;
94
+ if (turns.get(entry.currentId) === entry) {
95
+ turns.delete(entry.currentId);
96
+ }
97
+ },
98
+ };
99
+ },
100
+ *decision(request, signal) {
101
+ // Subscribe to abort BEFORE any policy work and recheck, so a
102
+ // synchronous abort during evaluation is never lost.
103
+ const aborts = yield* on(signal, "abort");
104
+ if (signal.aborted) {
105
+ return CANCEL;
106
+ }
107
+ let target;
108
+ try {
109
+ target = yield* resolveTarget(request.sessionId);
110
+ }
111
+ catch {
112
+ // Store errors during refresh fail closed.
113
+ return CANCEL;
114
+ }
115
+ if (!target) {
116
+ return CANCEL;
117
+ }
118
+ const registered = target;
119
+ try {
120
+ // Policy errors are contained INSIDE the task: an unhandled
121
+ // task failure would propagate into the prompt scope itself.
122
+ // Halts are unaffected — try/catch does not intercept them.
123
+ const task = registered.scope.run(function* () {
124
+ try {
125
+ const permissionRequest = toPermissionRequest(request, registered.session);
126
+ const outcome = yield* Agent.operations.requestPermission(permissionRequest);
127
+ return toDecision(outcome, permissionRequest.options);
128
+ }
129
+ catch {
130
+ return CANCEL;
131
+ }
132
+ });
133
+ return yield* race([
134
+ task,
135
+ (function* () {
136
+ yield* aborts.next();
137
+ yield* task.halt();
138
+ return CANCEL;
139
+ })(),
140
+ ]);
141
+ }
142
+ catch {
143
+ // Torn-down prompt scopes cancel — never fall through to
144
+ // another policy or ACPX's mode resolver.
145
+ return CANCEL;
146
+ }
147
+ },
148
+ };
149
+ }
150
+ const OPTION_KINDS = ["allow_once", "allow_always", "reject_once", "reject_always"];
151
+ function parseOptionKind(value) {
152
+ if (value === "allow_once" || value === "allow_always") {
153
+ return value;
154
+ }
155
+ if (value === "reject_once" || value === "reject_always") {
156
+ return value;
157
+ }
158
+ return undefined;
159
+ }
160
+ function toPermissionRequest(request, session) {
161
+ const raw = request.raw;
162
+ const options = [];
163
+ for (const option of raw.options) {
164
+ // ACP's PermissionOptionKind is an open union; options whose kind is
165
+ // outside the stable Executable.md shape are not offered.
166
+ const kind = parseOptionKind(option.kind);
167
+ if (kind) {
168
+ options.push({ optionId: String(option.optionId), name: option.name, kind });
169
+ }
170
+ }
171
+ const toolCall = {
172
+ toolCallId: String(raw.toolCall.toolCallId),
173
+ };
174
+ if (typeof raw.toolCall.title === "string") {
175
+ toolCall.title = raw.toolCall.title;
176
+ }
177
+ const kind = raw.toolCall.kind ?? request.inferredKind;
178
+ if (typeof kind === "string") {
179
+ toolCall.kind = kind;
180
+ }
181
+ if (raw.toolCall.rawInput !== undefined) {
182
+ toolCall.rawInput = raw.toolCall.rawInput;
183
+ }
184
+ return { session, toolCall, options };
185
+ }
186
+ function toDecision(outcome, options) {
187
+ if (outcome.outcome === "cancelled") {
188
+ return CANCEL;
189
+ }
190
+ const selected = options.find((option) => option.optionId === outcome.optionId);
191
+ if (!selected || !OPTION_KINDS.includes(selected.kind)) {
192
+ return CANCEL;
193
+ }
194
+ return { outcome: selected.kind };
195
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * ACPX agent provider (specs/acp-client-spec.md §ACPX provider).
3
+ *
4
+ * The factory owns every resource it starts. The shared runtime is
5
+ * created lazily on first use with the contextual cwd and validated
6
+ * contextual timeout — nothing spawns at install time. Availability
7
+ * validation uses a disposable probe runtime per agent: ACPX 0.12.0's
8
+ * `probeAvailability()` only updates internal health, so `doctor()` is
9
+ * used and `report.ok` inspected explicitly; ACPX closes the probe
10
+ * client internally on both success and failure.
11
+ *
12
+ * Prompt subscriptions follow a fixed sequence — resolve identity,
13
+ * acquire the session's FIFO lock, register permission routing, resolve
14
+ * the effective timeout, start the turn — registering unconditional
15
+ * cleanup for each resource as soon as it is acquired, and conditional
16
+ * cancellation only once the turn exists. Provider teardown attempts
17
+ * every remaining cancellation and every distinct handle close with an
18
+ * all-settled strategy and throws the recorded failures from the
19
+ * provider scope.
20
+ */
21
+ import { createChannel, ensure, spawn, until, useScope } from "effection";
22
+ import { randomUUID } from "node:crypto";
23
+ import { homedir } from "node:os";
24
+ import { join, resolve } from "node:path";
25
+ import { Agent, timeout as contextualTimeout } from "@executablemd/core";
26
+ import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime";
27
+ import { createPermissionBridge } from "./permission-bridge.js";
28
+ import { consumeTurn } from "./events.js";
29
+ import { resolveSessionPlacement } from "./session-key.js";
30
+ import { useSerialQueues } from "./serial-queue.js";
31
+ import { cwd } from "@executablemd/runtime";
32
+ function toError(value) {
33
+ return value instanceof Error ? value : new Error(String(value));
34
+ }
35
+ export function createAcpxProvider(seams) {
36
+ return function* (providerOptions) {
37
+ const state = yield* useAcpxProviderState(providerOptions, seams);
38
+ yield* Agent.around({
39
+ *agent([name], _next) {
40
+ return yield* state.agent(name);
41
+ },
42
+ *session([name], _next) {
43
+ return yield* state.session(name);
44
+ },
45
+ *prompt([content, options], _next) {
46
+ return state.promptStream(content, options);
47
+ },
48
+ }, { at: "min" });
49
+ };
50
+ }
51
+ export function* useAcpxProviderState(providerOptions, seams) {
52
+ const createRuntime = seams?.createRuntime ?? createAcpRuntime;
53
+ const store = seams?.sessionStore ?? createRuntimeStore({ stateDir: join(homedir(), ".acpx") });
54
+ const registry = seams?.agentRegistry ?? createAgentRegistry();
55
+ const sessionRouting = seams?.sessionRouting ?? ((_c, op) => op());
56
+ const bridge = createPermissionBridge();
57
+ const stateScope = yield* useScope();
58
+ const turns = yield* useSerialQueues();
59
+ let runtime;
60
+ const validatedAgents = new Set();
61
+ const managed = new Map();
62
+ const activeTurns = new Set();
63
+ const cleanupErrors = [];
64
+ function* runtimeOptions() {
65
+ const dir = yield* cwd();
66
+ const timeoutMs = yield* contextualTimeout;
67
+ return {
68
+ cwd: dir,
69
+ sessionStore: store,
70
+ agentRegistry: registry,
71
+ permissionMode: providerOptions.permissionMode,
72
+ nonInteractivePermissions: "deny",
73
+ timeoutMs,
74
+ };
75
+ }
76
+ function* getRuntime() {
77
+ if (!runtime) {
78
+ const base = yield* runtimeOptions();
79
+ runtime = createRuntime({
80
+ ...base,
81
+ // The acpx callback boundary: `scope.run` returns a Promise-compatible
82
+ // Future over the operation-based bridge decision.
83
+ onPermissionRequest: (request, ctx) => stateScope.run(() => bridge.decision(request, ctx.signal)),
84
+ });
85
+ }
86
+ return runtime;
87
+ }
88
+ function* resolveAgent(name) {
89
+ const selected = name ?? providerOptions.defaultAgent;
90
+ if (!validatedAgents.has(selected)) {
91
+ const base = yield* runtimeOptions();
92
+ const probe = createRuntime({ ...base, probeAgent: selected });
93
+ const report = yield* until(probe.doctor());
94
+ if (!report.ok) {
95
+ const code = report.code ? ` [${report.code}]` : "";
96
+ const details = report.details?.length ? ` (${report.details.join("; ")})` : "";
97
+ throw new Error(`agent "${selected}" is unavailable${code}: ${report.message}${details}`);
98
+ }
99
+ validatedAgents.add(selected);
100
+ }
101
+ return selected;
102
+ }
103
+ // Read-only session resolution. For a Session value it validates the
104
+ // existing managed entry; otherwise it derives the placement (the
105
+ // nearest-existing session), so the RESOLVED sessionKey — not the
106
+ // caller cwd — becomes the session-queue key.
107
+ function* prepare(agentName, option, callerCwd) {
108
+ if (typeof option === "object") {
109
+ const entry = managed.get(option.sessionKey);
110
+ if (!entry) {
111
+ throw new Error(`unknown or stale agent session "${option.sessionKey}" — a Session value must ` +
112
+ `come from this provider's session()`);
113
+ }
114
+ const agentCommand = registry.resolve(agentName);
115
+ if (agentCommand !== entry.agentCommand) {
116
+ throw new Error(`agent "${agentName}" (${agentCommand}) does not match session ` +
117
+ `"${option.sessionKey}" (${entry.agentCommand})`);
118
+ }
119
+ return { kind: "existing", sessionKey: option.sessionKey, entry };
120
+ }
121
+ const agentCommand = registry.resolve(agentName);
122
+ const placement = yield* resolveSessionPlacement(store, agentCommand, callerCwd, option);
123
+ return { kind: "placement", sessionKey: placement.sessionKey, agentCommand, placement };
124
+ }
125
+ function* ensureFromPrepared(agentName, prepared) {
126
+ if (prepared.kind === "existing") {
127
+ return prepared.entry;
128
+ }
129
+ const acp = yield* getRuntime();
130
+ const handle = yield* until(acp.ensureSession({
131
+ sessionKey: prepared.placement.sessionKey,
132
+ agent: agentName,
133
+ mode: "persistent",
134
+ cwd: prepared.placement.cwd,
135
+ }));
136
+ const session = {
137
+ sessionKey: prepared.placement.sessionKey,
138
+ cwd: prepared.placement.cwd,
139
+ };
140
+ if (handle.agentSessionId !== undefined) {
141
+ session.agentSessionId = handle.agentSessionId;
142
+ }
143
+ const entry = {
144
+ handle,
145
+ agentCommand: prepared.agentCommand,
146
+ cwd: prepared.placement.cwd,
147
+ session,
148
+ };
149
+ managed.set(prepared.sessionKey, entry);
150
+ return entry;
151
+ }
152
+ function promptStream(content, options) {
153
+ return {
154
+ *[Symbol.iterator]() {
155
+ const agentName = yield* Agent.operations.agent(options?.agent);
156
+ const callerCwd = resolve(yield* cwd());
157
+ const context = {
158
+ agentName,
159
+ session: options?.session,
160
+ cwd: callerCwd,
161
+ };
162
+ const prepared = yield* sessionRouting(context, () => prepare(agentName, options?.session, callerCwd));
163
+ yield* turns.slot(prepared.sessionKey);
164
+ return yield* sessionRouting(context, function* () {
165
+ const entry = yield* ensureFromPrepared(agentName, prepared);
166
+ const scope = yield* useScope();
167
+ const recordKey = entry.handle.acpxRecordId ?? entry.session.sessionKey;
168
+ // Route by the record's ACP session id, refreshed on demand so
169
+ // a reconnect that updates the record mid-turn (ACPX
170
+ // checkpoints it before the prompt runs) still routes to this
171
+ // scope's policy.
172
+ const refresh = () => (function* () {
173
+ const record = yield* until(store.load(recordKey));
174
+ if (!record) {
175
+ return undefined;
176
+ }
177
+ return { acpSessionId: record.acpSessionId, agentSessionId: record.agentSessionId };
178
+ })();
179
+ const initial = yield* refresh();
180
+ if (initial?.agentSessionId !== undefined) {
181
+ entry.session.agentSessionId = initial.agentSessionId;
182
+ }
183
+ const activeSessionId = initial?.acpSessionId ?? entry.handle.backendSessionId;
184
+ if (activeSessionId !== undefined) {
185
+ const registration = bridge.register(activeSessionId, scope, entry.session, refresh);
186
+ yield* ensure(() => {
187
+ registration.unregister();
188
+ });
189
+ }
190
+ const timeoutMs = options?.timeout ?? (yield* contextualTimeout);
191
+ const acp = yield* getRuntime();
192
+ const turn = acp.startTurn({
193
+ handle: entry.handle,
194
+ text: content,
195
+ mode: "prompt",
196
+ requestId: randomUUID(),
197
+ timeoutMs,
198
+ });
199
+ activeTurns.add(turn);
200
+ let completed = false;
201
+ yield* ensure(function* () {
202
+ activeTurns.delete(turn);
203
+ if (!completed) {
204
+ try {
205
+ yield* until(turn.cancel());
206
+ }
207
+ catch (error) {
208
+ cleanupErrors.push(toError(error));
209
+ }
210
+ }
211
+ });
212
+ const channel = createChannel();
213
+ const subscription = yield* channel;
214
+ yield* spawn(() => consumeTurn(turn, { agent: agentName, session: entry.session }, channel, () => {
215
+ completed = true;
216
+ }));
217
+ return subscription;
218
+ });
219
+ },
220
+ };
221
+ }
222
+ yield* ensure(function* () {
223
+ for (const turn of [...activeTurns]) {
224
+ activeTurns.delete(turn);
225
+ try {
226
+ yield* until(turn.cancel());
227
+ }
228
+ catch (error) {
229
+ cleanupErrors.push(toError(error));
230
+ }
231
+ }
232
+ if (runtime) {
233
+ const closedHandles = new Set();
234
+ for (const entry of managed.values()) {
235
+ const handleKey = entry.handle.acpxRecordId ?? entry.handle.sessionKey;
236
+ if (closedHandles.has(handleKey)) {
237
+ continue;
238
+ }
239
+ closedHandles.add(handleKey);
240
+ try {
241
+ yield* until(runtime.close({ handle: entry.handle, reason: "scope teardown" }));
242
+ }
243
+ catch (error) {
244
+ cleanupErrors.push(toError(error));
245
+ }
246
+ }
247
+ }
248
+ if (cleanupErrors.length === 1) {
249
+ throw cleanupErrors[0];
250
+ }
251
+ if (cleanupErrors.length > 1) {
252
+ throw new AggregateError(cleanupErrors, "agent provider teardown failed");
253
+ }
254
+ });
255
+ return {
256
+ *agent(name) {
257
+ return yield* resolveAgent(name);
258
+ },
259
+ *session(option) {
260
+ const agentName = yield* Agent.operations.agent();
261
+ const callerCwd = resolve(yield* cwd());
262
+ const context = { agentName, session: option, cwd: callerCwd };
263
+ const prepared = yield* sessionRouting(context, () => prepare(agentName, option, callerCwd));
264
+ return yield* turns.withSlot(prepared.sessionKey, () => sessionRouting(context, function* () {
265
+ const entry = yield* ensureFromPrepared(agentName, prepared);
266
+ return entry.session;
267
+ }));
268
+ },
269
+ promptStream,
270
+ };
271
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Keyed serial queues: each key gets one spawned drain loop that
3
+ * grants slots strictly in arrival order and waits for the holder to
4
+ * finish before granting the next. A slot is a resource — it releases
5
+ * when the acquiring scope exits, on normal, failure, and halt paths
6
+ * alike, including a request halted while it is still queued.
7
+ */
8
+ import { createChannel, resource, useScope, withResolvers } from "effection";
9
+ export function* useSerialQueues() {
10
+ const owner = yield* useScope();
11
+ const queues = new Map();
12
+ function* queueFor(key) {
13
+ const existing = queues.get(key);
14
+ if (existing) {
15
+ return existing;
16
+ }
17
+ const channel = createChannel();
18
+ // The subscription must belong to the loop task itself — created in
19
+ // a slot holder's scope it would die with that holder. The ready
20
+ // gate keeps sends from racing the subscribe, and the queue is
21
+ // registered before any suspension so concurrent first callers
22
+ // never create duplicate loops.
23
+ const ready = withResolvers();
24
+ const queue = { channel, ready: ready.operation };
25
+ queues.set(key, queue);
26
+ yield* owner.spawn(function* () {
27
+ const subscription = yield* channel;
28
+ ready.resolve();
29
+ while (true) {
30
+ const next = yield* subscription.next();
31
+ if (next.done) {
32
+ break;
33
+ }
34
+ next.value.grant();
35
+ yield* next.value.done;
36
+ }
37
+ });
38
+ return queue;
39
+ }
40
+ return {
41
+ slot(key) {
42
+ return resource(function* (provide) {
43
+ const queue = yield* queueFor(key);
44
+ yield* queue.ready;
45
+ const granted = withResolvers();
46
+ const done = withResolvers();
47
+ try {
48
+ yield* queue.channel.send({ grant: granted.resolve, done: done.operation });
49
+ yield* granted.operation;
50
+ yield* provide();
51
+ }
52
+ finally {
53
+ done.resolve();
54
+ }
55
+ });
56
+ },
57
+ *withSlot(key, op) {
58
+ const queue = yield* queueFor(key);
59
+ yield* queue.ready;
60
+ const granted = withResolvers();
61
+ const done = withResolvers();
62
+ try {
63
+ yield* queue.channel.send({ grant: granted.resolve, done: done.operation });
64
+ yield* granted.operation;
65
+ return yield* op();
66
+ }
67
+ finally {
68
+ done.resolve();
69
+ }
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Session identity (specs/acp-client-spec.md §Session).
3
+ *
4
+ * Keys are namespaced `xmd:v1:` so they can never collide with sessions
5
+ * owned by other ACPX consumers (ACPX's only key convention is an
6
+ * optional `agent:<name>:` prefix, which this scheme avoids). Every
7
+ * variable-length identity component — the agent command, the directory
8
+ * and an explicit session name — is digested, so a key stays bounded and
9
+ * filesystem-safe however long its inputs are. A session store may use
10
+ * the key as a file name and encode it again, which an undigested
11
+ * component overruns.
12
+ *
13
+ * Session resolution walks candidates from the contextual cwd toward the
14
+ * Git repository root and reuses the nearest candidate whose record
15
+ * already exists — checked through the ACPX session store, never through
16
+ * `ensureSession()`, which would create one. ACPX only reuses a record
17
+ * when the cwd matches, so the matched candidate's directory (not the
18
+ * caller cwd) becomes the session cwd. When nothing exists yet, the
19
+ * session is created for the exact contextual cwd.
20
+ */
21
+ import { until } from "effection";
22
+ import { createHash } from "node:crypto";
23
+ import { dirname, join, resolve } from "node:path";
24
+ import { stat } from "@executablemd/runtime";
25
+ function digest(value) {
26
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
27
+ }
28
+ export function deriveSessionKey(agentCommand, dir, name) {
29
+ // The unnamed session keeps a literal marker so it stays recognisable
30
+ // and distinct from any given name.
31
+ return [
32
+ "xmd",
33
+ "v1",
34
+ digest(agentCommand),
35
+ digest(resolve(dir)),
36
+ name === undefined ? "default" : digest(name),
37
+ ].join(":");
38
+ }
39
+ /**
40
+ * One candidate per directory from the contextual cwd up to the Git
41
+ * repository root, nearest first. `.git` may be a directory or a file
42
+ * (worktrees). Outside a repository the exact cwd is the only candidate.
43
+ */
44
+ export function* sessionCandidates(agentCommand, cwdPath, name) {
45
+ const start = resolve(cwdPath);
46
+ const chain = [];
47
+ let gitRoot;
48
+ let current = start;
49
+ while (true) {
50
+ chain.push(current);
51
+ const dotGit = yield* stat(join(current, ".git"));
52
+ if (dotGit.exists) {
53
+ gitRoot = current;
54
+ break;
55
+ }
56
+ const parent = dirname(current);
57
+ if (parent === current) {
58
+ break;
59
+ }
60
+ current = parent;
61
+ }
62
+ const dirs = gitRoot === undefined ? [start] : chain;
63
+ return dirs.map((dir) => ({ sessionKey: deriveSessionKey(agentCommand, dir, name), cwd: dir }));
64
+ }
65
+ /**
66
+ * Select where a session lives: the nearest candidate whose ACPX record
67
+ * exists for the same agent command and directory, or the exact
68
+ * contextual cwd when none does.
69
+ */
70
+ export function* resolveSessionPlacement(store, agentCommand, cwdPath, name) {
71
+ const candidates = yield* sessionCandidates(agentCommand, cwdPath, name);
72
+ for (const candidate of candidates) {
73
+ const record = yield* until(store.load(candidate.sessionKey));
74
+ if (record &&
75
+ record.agentCommand === agentCommand &&
76
+ resolve(record.cwd) === resolve(candidate.cwd)) {
77
+ return candidate;
78
+ }
79
+ }
80
+ return candidates[0];
81
+ }
package/package.json CHANGED
@@ -1,14 +1,32 @@
1
1
  {
2
2
  "name": "@executablemd/acp",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Bootstrap reservation for ACPX agent provider for executable.md documents: drives coding agents over the Agent Client Protocol..",
5
- "license": "MIT",
3
+ "version": "0.5.0",
4
+ "description": "ACPX agent provider for executable.md documents: drives coding agents over the Agent Client Protocol.",
5
+ "homepage": "https://executable.md",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/taras/executable.md.git"
9
9
  },
10
- "homepage": "https://executable.md",
11
- "files": [
12
- "README.md"
13
- ]
14
- }
10
+ "license": "MIT",
11
+ "bugs": {
12
+ "url": "https://github.com/taras/executable.md/issues"
13
+ },
14
+ "module": "./esm/mod.js",
15
+ "types": "./types/mod.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "types": "./types/mod.d.ts",
20
+ "default": "./esm/mod.js"
21
+ }
22
+ }
23
+ },
24
+ "scripts": {},
25
+ "dependencies": {
26
+ "@executablemd/core": "^0.5.0",
27
+ "@executablemd/runtime": "^0.5.0",
28
+ "acpx": "0.12.0",
29
+ "effection": "4.1.0-alpha.7"
30
+ },
31
+ "_generatedBy": "dnt@dev"
32
+ }
package/types/mod.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @module
3
+ * ACPX agent provider for Executable.md (specs/acp-client-spec.md).
4
+ *
5
+ * `createAcpxProvider()` returns an `AgentProviderFactory` that drives
6
+ * coding agents over the Agent Client Protocol through the pinned `acpx`
7
+ * runtime. Supply it directly to core's agent vocabulary through the
8
+ * `rootProvider` option:
9
+ * `installAgentVocabulary({ rootProvider: { factory: createAcpxProvider(), options } })`.
10
+ */
11
+ export { createAcpxProvider, useAcpxProviderState } from "./src/provider.js";
12
+ export { useSerialQueues } from "./src/serial-queue.js";
13
+ export type { SerialQueues } from "./src/serial-queue.js";
14
+ export type { AcpxProviderSeams, AcpxProviderState, ProbeCapableRuntime, SessionRoutingContext, } from "./src/provider.js";
15
+ /** The agent ACPX selects when nothing else is configured. */
16
+ export { DEFAULT_AGENT_NAME } from "acpx/runtime";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Turn event normalization (specs/acp-client-spec.md §Prompt).
3
+ *
4
+ * `consumeTurn` receives an already-started ACPX turn and the resolved
5
+ * identity, and produces the normalized public event sequence: exactly
6
+ * one `started`, then `text_delta` events for output-stream deltas only,
7
+ * then exactly one `terminal`, then the channel closes with the complete
8
+ * concatenated text — including partial text on failure. Thought,
9
+ * status, tool, usage, and raw ACP events stay private.
10
+ */
11
+ import type { Channel, Operation } from "effection";
12
+ import type { AgentPromptEvent, Session } from "@executablemd/core";
13
+ import type { AcpRuntimeTurn } from "acpx/runtime";
14
+ export interface TurnIdentity {
15
+ agent: string;
16
+ session: Session;
17
+ }
18
+ export declare function consumeTurn(turn: AcpRuntimeTurn, identity: TurnIdentity, channel: Channel<AgentPromptEvent, string>, markCompleted: () => void): Operation<void>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Permission bridge (specs/acp-client-spec.md §Permissions).
3
+ *
4
+ * ## Routing
5
+ *
6
+ * ACPX delivers a permission request to `onPermissionRequest` keyed by
7
+ * `params.sessionId` — the ACP session id (`acpSessionId`) of the
8
+ * prompting turn. Each active turn registers its subscribing scope, an
9
+ * initial id, and a `refresh` operation that reloads its ACPX record by
10
+ * `acpxRecordId`. The id map is a cache: before routing, the bridge
11
+ * refreshes and verifies a direct candidate; on a miss it refreshes all
12
+ * active registrations and routes only when exactly one matches the
13
+ * request id. This tracks ACPX's reconnect fallback, which updates
14
+ * `record.acpSessionId` and checkpoints the record before running the
15
+ * prompt. The public `Session.agentSessionId` is updated from the
16
+ * refreshed record.
17
+ *
18
+ * ## Fail closed
19
+ *
20
+ * The decision resolves `{ outcome: "cancel" }` — never `undefined`
21
+ * (which would let ACPX fall back to its mode resolver) and never
22
+ * another scope — on: store errors during refresh, zero or multiple
23
+ * matching registrations, a stale or torn-down prompt scope, a policy
24
+ * error, an unknown selected option id, and an abort (before or during
25
+ * evaluation).
26
+ */
27
+ import type { Operation, Scope } from "effection";
28
+ import type { Session } from "@executablemd/core";
29
+ import type { AcpPermissionDecision, AcpPermissionRequest } from "acpx/runtime";
30
+ /** Reloads a registration's record; undefined when the record is gone. */
31
+ export type RefreshRecord = () => Operation<{
32
+ acpSessionId?: string;
33
+ agentSessionId?: string;
34
+ } | undefined>;
35
+ export interface Registration {
36
+ /** Remove this registration; an in-flight refresh cannot reinsert it. */
37
+ unregister(): void;
38
+ }
39
+ export interface PermissionBridge {
40
+ /** Register the active turn's scope under its ACP session id. */
41
+ register(acpSessionId: string, scope: Scope, session: Session, refresh: RefreshRecord): Registration;
42
+ /**
43
+ * Decide one permission request. Never undefined — every failure mode
44
+ * (see the module doc) resolves `{ outcome: "cancel" }`, so ACPX can
45
+ * never fall back to its own mode resolver.
46
+ */
47
+ decision(request: AcpPermissionRequest, signal: AbortSignal): Operation<AcpPermissionDecision>;
48
+ }
49
+ export declare function createPermissionBridge(): PermissionBridge;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ACPX agent provider (specs/acp-client-spec.md §ACPX provider).
3
+ *
4
+ * The factory owns every resource it starts. The shared runtime is
5
+ * created lazily on first use with the contextual cwd and validated
6
+ * contextual timeout — nothing spawns at install time. Availability
7
+ * validation uses a disposable probe runtime per agent: ACPX 0.12.0's
8
+ * `probeAvailability()` only updates internal health, so `doctor()` is
9
+ * used and `report.ok` inspected explicitly; ACPX closes the probe
10
+ * client internally on both success and failure.
11
+ *
12
+ * Prompt subscriptions follow a fixed sequence — resolve identity,
13
+ * acquire the session's FIFO lock, register permission routing, resolve
14
+ * the effective timeout, start the turn — registering unconditional
15
+ * cleanup for each resource as soon as it is acquired, and conditional
16
+ * cancellation only once the turn exists. Provider teardown attempts
17
+ * every remaining cancellation and every distinct handle close with an
18
+ * all-settled strategy and throws the recorded failures from the
19
+ * provider scope.
20
+ */
21
+ import type { Operation, Stream } from "effection";
22
+ import type { AgentPromptEvent, AgentProviderFactory, AgentProviderOptions, PromptOptions, Session } from "@executablemd/core";
23
+ import type { AcpAgentRegistry, AcpRuntime, AcpRuntimeDoctorReport, AcpRuntimeOptions, AcpSessionStore } from "acpx/runtime";
24
+ /** The runtime surface the provider needs — ACPX's runtime plus its probe. */
25
+ export interface ProbeCapableRuntime extends AcpRuntime {
26
+ doctor(): Promise<AcpRuntimeDoctorReport>;
27
+ }
28
+ /** Context for the session-routing seam: the registry-dependent inputs. */
29
+ export interface SessionRoutingContext {
30
+ agentName: string;
31
+ session: string | Session | undefined;
32
+ /** Normalized contextual cwd. */
33
+ cwd: string;
34
+ }
35
+ export interface AcpxProviderSeams {
36
+ createRuntime?: (options: AcpRuntimeOptions) => ProbeCapableRuntime;
37
+ sessionStore?: AcpSessionStore;
38
+ agentRegistry?: AcpAgentRegistry;
39
+ /**
40
+ * Wraps registry-dependent work — session preparation AND
41
+ * ensure/session validation + turn start — so an embedder can pin its
42
+ * route for that critical section. `op` runs in the CALLER's scope
43
+ * (no `scoped()`), so returned prompt resources belong to the
44
+ * subscriber. The default invokes `op` directly.
45
+ */
46
+ sessionRouting?: <T>(context: SessionRoutingContext, op: () => Operation<T>) => Operation<T>;
47
+ }
48
+ /**
49
+ * The provider's operations, decoupled from the Agent Api install so
50
+ * embedders (e.g. the test agent) can hold several independent states —
51
+ * each with its own runtime, sessions, locks, and teardown — in sibling
52
+ * scopes. Teardown registers in the calling scope.
53
+ */
54
+ export interface AcpxProviderState {
55
+ agent(name?: string): Operation<string>;
56
+ session(option?: string | Session): Operation<Session>;
57
+ promptStream(content: string, options?: PromptOptions): Stream<AgentPromptEvent, string>;
58
+ }
59
+ export declare function createAcpxProvider(seams?: AcpxProviderSeams): AgentProviderFactory;
60
+ export declare function useAcpxProviderState(providerOptions: AgentProviderOptions, seams?: AcpxProviderSeams): Operation<AcpxProviderState>;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Keyed serial queues: each key gets one spawned drain loop that
3
+ * grants slots strictly in arrival order and waits for the holder to
4
+ * finish before granting the next. A slot is a resource — it releases
5
+ * when the acquiring scope exits, on normal, failure, and halt paths
6
+ * alike, including a request halted while it is still queued.
7
+ */
8
+ import type { Operation } from "effection";
9
+ export interface SerialQueues {
10
+ /** Hold this key's slot for the calling scope's lifetime. */
11
+ slot(key: string): Operation<void>;
12
+ /**
13
+ * Hold this key's slot only while `op` runs, in the caller's own
14
+ * scope — for critical sections that must not outlive the operation
15
+ * and must not capture the resources `op` acquires.
16
+ */
17
+ withSlot<T>(key: string, op: () => Operation<T>): Operation<T>;
18
+ }
19
+ export declare function useSerialQueues(): Operation<SerialQueues>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Session identity (specs/acp-client-spec.md §Session).
3
+ *
4
+ * Keys are namespaced `xmd:v1:` so they can never collide with sessions
5
+ * owned by other ACPX consumers (ACPX's only key convention is an
6
+ * optional `agent:<name>:` prefix, which this scheme avoids). Every
7
+ * variable-length identity component — the agent command, the directory
8
+ * and an explicit session name — is digested, so a key stays bounded and
9
+ * filesystem-safe however long its inputs are. A session store may use
10
+ * the key as a file name and encode it again, which an undigested
11
+ * component overruns.
12
+ *
13
+ * Session resolution walks candidates from the contextual cwd toward the
14
+ * Git repository root and reuses the nearest candidate whose record
15
+ * already exists — checked through the ACPX session store, never through
16
+ * `ensureSession()`, which would create one. ACPX only reuses a record
17
+ * when the cwd matches, so the matched candidate's directory (not the
18
+ * caller cwd) becomes the session cwd. When nothing exists yet, the
19
+ * session is created for the exact contextual cwd.
20
+ */
21
+ import type { Operation } from "effection";
22
+ import type { AcpSessionStore } from "acpx/runtime";
23
+ export interface SessionCandidate {
24
+ sessionKey: string;
25
+ cwd: string;
26
+ }
27
+ export declare function deriveSessionKey(agentCommand: string, dir: string, name?: string): string;
28
+ /**
29
+ * One candidate per directory from the contextual cwd up to the Git
30
+ * repository root, nearest first. `.git` may be a directory or a file
31
+ * (worktrees). Outside a repository the exact cwd is the only candidate.
32
+ */
33
+ export declare function sessionCandidates(agentCommand: string, cwdPath: string, name?: string): Operation<SessionCandidate[]>;
34
+ /**
35
+ * Select where a session lives: the nearest candidate whose ACPX record
36
+ * exists for the same agent command and directory, or the exact
37
+ * contextual cwd when none does.
38
+ */
39
+ export declare function resolveSessionPlacement(store: AcpSessionStore, agentCommand: string, cwdPath: string, name?: string): Operation<SessionCandidate>;
package/README.md DELETED
@@ -1,5 +0,0 @@
1
- # @executablemd/acp
2
-
3
- This is a bootstrap reservation for the package name. It contains no
4
- implementation. Install a stable release from the `latest` dist-tag once
5
- available.