Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/acp/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/acp",
"version": "0.10.0",
"version": "0.10.1",
"license": "MIT",
"exports": {
".": "./mod.ts",
Expand Down
10 changes: 6 additions & 4 deletions packages/acp/embedded-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@
*
* Its own entrypoint, and deliberately not part of `@executablemd/acp`. This is
* a temporary arrangement: no published Codex or Claude release names the turn
* a Prompt completed, so a workflow run executes the snapshots under
* `vendor/adapters` instead. Issue #636 removes them, one provider at a time,
* as qualifying releases appear.
* a Prompt completed, so every profile that runs one of those two agents
* executes the snapshots under `vendor/adapters` instead. Issue #636 removes
* them, one provider at a time, as qualifying releases appear.
*
* Anything on the package root is a stable contract somebody may build on, and
* withdrawing one is a compatibility break. A workaround should not be able to
* earn that, so it lives here and goes away with the thing it exists for.
*
* The workflow Agent profile is the only caller.
* The CLI's three Agent profiles β€” the workflow attachment, `xmd run` and the
* `xmd plan` authorship ceiling β€” are the callers.
*/

export {
AdapterSnapshotError,
carriesEmbeddedAdapter,
createEmbeddedAdapters,
embeddedAdapterDependencies,
embeddedAdapterIdentities,
overlaidAdapterRegistry,
} from "./src/adapter-snapshots.ts";
Expand Down
2 changes: 1 addition & 1 deletion packages/acp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/acp",
"version": "0.10.0",
"version": "0.10.1",
"description": "ACPX agent provider for executable.md documents: drives coding agents over the Agent Client Protocol.",
"type": "module",
"exports": {
Expand Down
34 changes: 34 additions & 0 deletions packages/acp/src/adapter-snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { readdir, readFile, rename as renamePath, rm as rmPath, writeFile } from
import { exec, useQuietProcessOutput } from "@executablemd/runtime";
import { createAgentRegistry } from "./acpx-runtime.ts";
import type { AcpAgentRegistry } from "./acpx-runtime.ts";
import type { AcpxProviderDependencies } from "./provider.ts";
import { ensure, type Operation, scoped, sleep } from "effection";
import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto";
Expand Down Expand Up @@ -722,6 +723,39 @@ export function carriesEmbeddedAdapter(adapters: EmbeddedAdapters, agentName: st
return adapters.providers.includes(agentName);
}

/**
* What a host that carries its own adapters states to a provider.
*
* Both clauses together, because neither is an arrangement on its own: the
* registry says Codex and Claude resolve to this build's snapshot, and the
* preparation is what puts that snapshot on disk. A host that stated only the
* registry would name a file nothing had written, and one that stated only the
* preparation would materialize an adapter and then run whatever `npx` resolved.
*
* Preparation happens at the first point the provider would run that command,
* which is its availability probe β€” earlier than a `<Session>` placement, and
* earlier than any turn. A snapshot that cannot prove itself refuses the agent
* there rather than surfacing later as an adapter that would not start.
*
* It is asked only about an agent this build actually carries. An agent ACPX
* resolves is already a command on this machine, so there is nothing to put on
* disk for it, and reaching into the snapshots to find that out would make every
* run pay for a mechanism that has nothing to say about it.
*/
export function embeddedAdapterDependencies(
adapters: EmbeddedAdapters,
): Pick<AcpxProviderDependencies, "agentRegistry" | "prepareAgent"> {
return {
agentRegistry: overlaidAdapterRegistry(adapters),
*prepareAgent(agentName: string): Operation<void> {
if (!carriesEmbeddedAdapter(adapters, agentName)) {
return;
}
yield* adapters.materialize(agentName);
},
};
}

/** Every embedded snapshot's identity, for provenance checks and diagnostics. */
export function embeddedAdapterIdentities(): readonly EmbeddedAdapterSnapshot[] {
return EMBEDDED_ADAPTER_SNAPSHOTS;
Expand Down
3 changes: 2 additions & 1 deletion packages/acp/vendor/adapters/PROVENANCE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Embedded ACP adapter provenance

This directory carries one npm tarball per provider, and a workflow run executes
This directory carries one npm tarball per provider, and every command that runs
one of those providers β€” `xmd workflow`, `xmd run` and `xmd plan` β€” executes
those instead of the adapter `npx` would resolve.

| Provider | Package | Version | Contracts |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/cli",
"version": "0.10.0",
"version": "0.10.1",
"license": "MIT",
"exports": "./src/deno.ts",
"imports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/cli",
"version": "0.10.0",
"version": "0.10.1",
"description": "The xmd command-line interface for executable.md.",
"bin": {
"xmd": "./src/node.ts"
Expand Down
58 changes: 47 additions & 11 deletions packages/cli/src/agent-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,47 @@ import type { AgentProviderFactory, PermissionMode } from "@executablemd/core";
import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime";
import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp";
import type { AcpxProviderDependencies } from "@executablemd/acp";
// A separate entrypoint because the embedded adapters are temporary (#636) and
// must not become part of the package's stable surface.
import {
createEmbeddedAdapters,
embeddedAdapterDependencies,
} from "@executablemd/acp/embedded-adapters";
import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters";
import { Err, Ok } from "effection";
import type { Operation, Result } from "effection";
import { homedir } from "node:os";
import { join } from "node:path";

import { resolveAgentConfig } from "./agent-config.ts";
import type { AgentFlags } from "./agent-config.ts";
import type { MachineSessionAssembly } from "./session-coordinator.ts";

/**
* Where a command that is not a workflow run materializes its adapters.
*
* One root for this machine, content-addressed beneath it: two invocations
* asking for the same adapter name the same directory, so the second one runs
* what the first installed, and a build carrying a different snapshot names a
* different directory instead of deciding whether this one is current.
*/
export const DEFAULT_ADAPTER_ROOT: string = join(homedir(), ".xmd", "adapters");

/** Everything one invocation settled about agents, resolved exactly once. */
export interface AgentStack {
/** The provider name the caller selected, already known to be registered. */
provider: string;
/** The agent every consumer defaults to, environment fallback applied. */
defaultAgent: string;
permissionMode: PermissionMode;
/**
* The ACP adapters this build carries, and where this host puts them.
*
* Part of the one settled answer because both consumers resolve agents
* through it: the assistant that writes a Plan and the run of the approved
* Plan are the same Codex or Claude, launched from the same snapshot.
*/
adapters: EmbeddedAdapters;
/** What this host states about machine-wide agent sessions, if anything. */
sessions?: MachineSessionAssembly;
}
Expand Down Expand Up @@ -67,25 +94,32 @@ export function* resolveAgentStack(
provider: flags.agentProvider,
defaultAgent,
permissionMode: config.permissionMode,
adapters: createEmbeddedAdapters(DEFAULT_ADAPTER_ROOT),
...(sessions === undefined ? {} : { sessions }),
});
}

/**
* What this host built, if it built anything.
* What this host carries and what it built, stated to the provider.
*
* The adapters are first, and unconditional: an `xmd run` or an `xmd plan` that
* asked for Codex or Claude and got ACPX's own registry would run whatever
* `npx` resolved from that build's pins β€” an adapter that names no turn, or one
* carrying an agent release this machine does not have (#672).
*
* Each piece reaches the provider directly rather than through a context: who
* owns a session and which build it belongs to are security decisions, and ones
* a document could replace are not ones. The two advertised sets are stated by
* the host, not inherited.
* Each of the rest reaches the provider directly rather than through a context:
* who owns a session and which build it belongs to are security decisions, and
* ones a document could replace are not ones. The two advertised sets are stated
* by the host, not inherited.
*/
export function hostAcpDependencies(
sessions: MachineSessionAssembly | undefined,
): AcpxProviderDependencies {
export function hostAcpDependencies(stack: AgentStack): AcpxProviderDependencies {
const { sessions } = stack;
const adapters = embeddedAdapterDependencies(stack.adapters);
if (sessions === undefined) {
return {};
return adapters;
}
return {
...adapters,
...(sessions.coordinator ? { coordinator: sessions.coordinator } : {}),
...(sessions.routeStore ? { routeStore: sessions.routeStore } : {}),
...(sessions.executableObserver ? { executableObserver: sessions.executableObserver } : {}),
Expand All @@ -99,10 +133,12 @@ export function hostAcpDependencies(
* components with the resolved root provider, the permission mode, and the
* terminal this command has to give away.
*
* Nothing starts an agent β€” the provider validates availability on first use.
* Nothing starts an agent β€” the provider validates availability on first use,
* and an embedded adapter reaches the disk at that same point. A document that
* asks for no agent installs no adapter.
*/
export function* installRunAgentStack(stack: AgentStack): Operation<void> {
const acpx = createAcpxProvider(hostAcpDependencies(stack.sessions));
const acpx = createAcpxProvider(hostAcpDependencies(stack));
yield* registerAgentProvider("acpx", acpx);

// The trusted host selects its own root provider by name. Document-level
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/authorship-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,22 @@ function validator(profile: AuthorshipProfile): IdentityComponent {
* The host's own assembly is passed through, then overridden: a coordinator or
* a route store says who owns a session, which this profile still has to respect,
* while nothing a caller wrote may reach the four fields below.
*
* That assembly is also where this build's own ACP adapters enter, so the
* assistant that writes a Plan is launched from the same snapshot as the run of
* the approved Plan. A ceiling built without them resolved Codex and Claude
* through ACPX's published pins and could reach neither (#672).
*
* Exported for the suite that pins exactly that: what a provider is built from
* is not observable through a provider, and a case that could only watch a turn
* fail would be reading a live agent's machine rather than this host's decision.
*/
function authorshipCeiling(profile: AuthorshipProfile, workdir: string): AcpxProviderDependencies {
export function authorshipCeiling(
profile: AuthorshipProfile,
workdir: string,
): AcpxProviderDependencies {
return {
...hostAcpDependencies(profile.stack.sessions),
...hostAcpDependencies(profile.stack),
...profile.acp,
// deno-lint-ignore require-yield
*agentCwd() {
Expand Down
26 changes: 6 additions & 20 deletions packages/cli/src/workflow-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ import type {
import {
carriesEmbeddedAdapter,
createEmbeddedAdapters,
overlaidAdapterRegistry,
embeddedAdapterDependencies,
} from "@executablemd/acp/embedded-adapters";
import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters";
import {
Expand Down Expand Up @@ -421,25 +421,11 @@ export function* useWorkflowAgentProfile(options: WorkflowAgentProfileOptions):

const factory: AgentProviderFactory = createAcpxProvider({
sessionStore: store,
// ACPX's own registry with this build's two patched snapshots over the top.
// Codex and Claude resolve to the adapter that names its turns; every other
// agent resolves to the command it always did.
agentRegistry: overlaidAdapterRegistry(adapters),
// At the first point the provider would run that command, which is its
// availability probe β€” earlier than a `<Session>` placement, and earlier
// than any turn. A snapshot that cannot prove itself refuses the agent here
// rather than surfacing later as an adapter that would not start.
//
// Asked only about an agent this build actually carries. An agent ACPX
// resolves is already a command on this machine, so there is nothing to put
// on disk for it, and reaching into the snapshots to find that out would
// make every run pay for a mechanism that has nothing to say about it.
*prepareAgent(agentName): Operation<void> {
if (!carriesEmbeddedAdapter(adapters, agentName)) {
return;
}
yield* adapters.materialize(agentName);
},
// ACPX's own registry with this build's two patched snapshots over the top,
// and the preparation that puts one on disk. Codex and Claude resolve to the
// adapter that names its turns; every other agent resolves to the command it
// always did.
...embeddedAdapterDependencies(adapters),
...(options.createRuntime === undefined ? {} : { createRuntime: options.createRuntime }),
// ACP-only, stated rather than inherited. A workflow session belongs to a
// run, not to this machine: it is named by a row in the run's own database,
Expand Down
Loading
Loading