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
5 changes: 5 additions & 0 deletions .changeset/soft-hosts-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Keep opt-in Claude, Codex, and Cursor development installs synchronized with each successful dev epoch so hosts pick up changed Skills, Hooks, MCP Apps, and manifests without reinstalling.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"test:packed:native": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-native-smoke.test.ts",
"test:packed:native:claude": "pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CLAUDE_SMOKE=1 pnpm test:packed:native",
"test:packed:native:codex": "pnpm build && AGENT_BUNDLE_PACKED_NATIVE_CODEX_SMOKE=1 pnpm test:packed:native",
"test:host-install": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-proof.test.ts",
"test:host-install": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-proof.test.ts packages/agent-bundle/tests/dev-host-install.test.ts",
"test:host-install:build": "pnpm build && pnpm test:host-install",
"test:host-install:packed": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-host-install-proof.test.ts",
"test:host-install:packed:build": "pnpm build && pnpm test:host-install:packed",
Expand Down
31 changes: 30 additions & 1 deletion packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ artifact-bound MCP playground with the raw protocol trace, a hook playground tha
wrapper, a durable ordered Playground trace with replay and export, and eval runs and comparisons.

The same session is available programmatically through the public `startDevServer` export, which
accepts the options the CLI flags map to (`root`, `port`, `open`, `agentApi`) and resolves to a
accepts the options the CLI flags map to (`root`, `port`, `open`, `agentApi`, `installHosts`) and resolves to a
`DevServerSession` exposing the loopback `url`, a `status()` snapshot, and `close()`:

```ts
Expand All @@ -157,6 +157,35 @@ console.log(session.url);
await session.close();
```

Pass `--install-host <claude|codex|cursor>` more than once to install development variants into
the selected hosts:

```sh
agent-bundle dev --install-host cursor --install-host claude
```

The first successful epoch uses the ordinary host installer. Claude and Codex therefore register
the plugin normally and read its files from their host-owned
`plugins/cache/<marketplace>/<plugin>/<version>` directory; Cursor reads
`~/.cursor/plugins/local/<plugin>`. The installed root contains
`.agent-bundle-dev.json` with schema version `1`, the project root, host, and installed epoch.
Its MCP document always launches
the framework CLI through the running dev server's Node executable as
`agent-bundle dev proxy --root <projectRoot> --server <serverName> --target <host>`;
rebuilds never replace that stable command with an epoch path, and host process `PATH` contents do
not affect whether the project-local framework can be spawned.

Each later `artifact.available` event copies the new target into an immutable installed generation.
Top-level directories switch by atomic symlink (or Windows junction) rename and top-level files by
atomic sibling-file rename, so a host sees an old or new complete entry and no synchronized
directory disappears between generations. A failed publication rolls pointers back to the prior
generation and emits an `AB7202` diagnostic on `dev.host.sync`; a failed build emits no
`artifact.available`, so the last-good install is unchanged. Re-sync writes the host cache directly
and does not invoke the Claude or Codex CLI again.

Stopping the dev server leaves the marked development install in place. Hooks and Skills remain on
disk, while the stable proxy command fails closed until that project dev server is running again.

`script.run` is a production-mounted, trusted-local Playground operation. It runs only the selected
manifest-owned emitted script for the selected target, in a managed workspace, and preserves bounded
stdout/stderr, exit, cancellation, and raw event references. Native prompts choose a server catalog
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ interface JsonInputOptions {

interface DevCommandOptions {
readonly agentApi?: boolean;
readonly installHost: readonly InstallHost[];
readonly open?: boolean;
readonly port?: number;
readonly root: string;
Expand Down Expand Up @@ -160,6 +161,9 @@ const installHost = (value: string): InstallHost => {
throw new TypeError('Install host must be claude, codex, or cursor.');
};

const collectInstallHost = (value: string, previous: readonly InstallHost[]): readonly InstallHost[] =>
[...previous, installHost(value)];

const installScope = (value: string): InstallScope => {
if (value === 'user' || value === 'project' || value === 'local') return value;
throw new TypeError('Install scope must be user, project, or local.');
Expand Down Expand Up @@ -468,12 +472,14 @@ export const runCli = async (
.option('--port <port>', 'Loopback TCP port', port)
.option('--agent-api', 'Enable the authenticated Agent API on /mcp')
.option('--no-agent-api', 'Disable the authenticated Agent API on /mcp')
.option('--install-host <host>', 'Install and re-sync a development host (repeatable)', collectInstallHost, [])
.option('--open', 'Open the workbench after the foreground server starts')
.option('--no-open', 'Do not open the workbench after the foreground server starts');
devCommand.action(async (options: DevCommandOptions) => {
const { startDevServer: start } = await import('./api.ts');
const session = await (dependencies.startDevServer ?? start)({
...(options.agentApi === undefined ? {} : { agentApi: options.agentApi }),
installHosts: options.installHost,
open: options.open === true,
...(options.port === undefined ? {} : { port: options.port }),
root: options.root,
Expand Down
81 changes: 81 additions & 0 deletions packages/agent-bundle/src/dev/dev-proxy-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { lstat, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';

import type { InstallHost } from '../install/install.ts';

interface AgentBundlePackage {
readonly bin?: unknown;
readonly name?: unknown;
}

const packageRootFor = async (modulePath: string): Promise<Readonly<{
readonly document: AgentBundlePackage;
readonly root: string;
}>> => {
let directory = dirname(modulePath);
for (;;) {
const packagePath = join(directory, 'package.json');
try {
const document = JSON.parse(await readFile(packagePath, 'utf8')) as AgentBundlePackage;
if (document.name === 'agent-bundle') return Object.freeze({ document, root: directory });
} catch (error) {
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const parent = dirname(directory);
if (parent === directory) {
throw new Error('Cannot locate the installed agent-bundle package root.');
}
directory = parent;
}
};

const binPath = (document: AgentBundlePackage): string | undefined => {
if (typeof document.bin === 'string') return document.bin;
if (
typeof document.bin === 'object' &&
document.bin !== null &&
!Array.isArray(document.bin) &&
typeof (document.bin as Record<string, unknown>)['agent-bundle'] === 'string'
) {
return (document.bin as Record<string, string>)['agent-bundle'];
}
return undefined;
};

const resolveAgentBundleCliEntry = async (): Promise<string> => {
const { document, root } = await packageRootFor(fileURLToPath(import.meta.url));
const declaredBin = binPath(document);
if (declaredBin === undefined) {
throw new Error(`agent-bundle package at ${JSON.stringify(root)} does not declare its CLI bin entry.`);
}
const entry = resolve(root, declaredBin);
const metadata = await lstat(entry).catch(() => undefined);
if (metadata === undefined || !metadata.isFile()) {
throw new Error(`agent-bundle CLI entry ${JSON.stringify(entry)} does not exist as a regular file.`);
}
return entry;
};

/** The single stage-1 integration seam for host-facing development MCP commands. */
export const devProxyServerCommand = async (
projectRoot: string,
serverName: string,
host: InstallHost,
): Promise<Readonly<{
readonly args: readonly string[];
readonly command: string;
}>> => Object.freeze({
args: Object.freeze([
await resolveAgentBundleCliEntry(),
'dev',
'proxy',
'--root',
projectRoot,
'--server',
serverName,
'--target',
host,
]),
command: process.execPath,
});
5 changes: 3 additions & 2 deletions packages/agent-bundle/src/dev/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type ProjectReplayGap,
} from './types.ts';

type EpochScopedProjectEventType = 'artifact.available';
type EpochScopedProjectEventType = 'artifact.available' | 'dev.host.sync';

type ProjectEventInputFor<TType extends ProjectEventType> = Readonly<{
readonly occurredAt?: string;
Expand Down Expand Up @@ -80,11 +80,12 @@ const eventTypes = new Set<ProjectEventType>([
'build.failed',
'artifact.available',
'artifact.status',
'dev.host.sync',
'runtime.event',
]);

const requiresEpoch = (type: ProjectEventType): boolean =>
type === 'artifact.available';
type === 'artifact.available' || type === 'dev.host.sync';

const ensureReplayLimit = (replayLimit: number): number => {
if (!Number.isSafeInteger(replayLimit) || replayLimit < 1) {
Expand Down
Loading
Loading