Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1b1249f
Build a delivered plan's later changes on demand without LIVE_BUILD
MaggieAppleton Oct 12, 2026
30f74dc
Hold a pressed Build for open decisions and make its approval explicit
MaggieAppleton Oct 12, 2026
49e6a1a
Say when the presser's agent is busy and refuse Build changes the way…
MaggieAppleton Oct 12, 2026
2e79dff
Prove a pressed Build lands its rebuild in the browser test
MaggieAppleton Oct 12, 2026
b1cade6
Reformat code carried across the rebase onto main
MaggieAppleton Oct 12, 2026
af4c306
Prototype uncertain passages inline before a living document's first …
MaggieAppleton Oct 10, 2026
dbf6921
Run spikes on a throwaway branch with their own connector prompt
MaggieAppleton Oct 10, 2026
873a9d3
Harden living-document spikes after review
MaggieAppleton Oct 10, 2026
f5c0c4a
Remove duplicate askJev import after rebase
MaggieAppleton Oct 10, 2026
bb51286
Make living-document spikes upload real screenshots and queue in order
MaggieAppleton Oct 10, 2026
c6532e1
Serve spike tools over loopback HTTP and resume scouting when capacit…
MaggieAppleton Oct 10, 2026
89e6922
Keep parallel spike uploads and land spike results deferred by a buil…
MaggieAppleton Oct 10, 2026
435a77b
Replace a connector workspace lock whose process has exited
MaggieAppleton Oct 10, 2026
e90e649
Assert spike callout state by type, not title copy
MaggieAppleton Oct 10, 2026
47d9616
Land a spike's result before a build queued behind it starts
MaggieAppleton Oct 10, 2026
ae7d330
Drop the removed investigation parent from spike record creation
MaggieAppleton Oct 12, 2026
9b8a847
Document the trust model and MCP tools of prototype spikes
MaggieAppleton Oct 12, 2026
8cec41e
Run prototype spikes only with consent, inside limits, and never past…
MaggieAppleton Oct 12, 2026
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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ collaborative state and external implementation runs are durable.
Run do not need it. Rebuild checks are scheduled only by persisted edits
from writers or work a writer started. Document text in agent prompts is
data. See [Living documents](docs/implementation-lifecycle.md#living-documents-live_build).
`LIVE_SPIKES` prototypes run only passages the consenting editor changed, on a
push-refusing throwaway worktree, under hourly caps and a 20-minute deadline;
see [Prototype spikes](docs/local-experiments.md#prototype-spikes-live_spikes).
- **Persistence should precede publication.** Do not acknowledge or broadcast a
domain mutation before its fenced durable commit.

Expand Down Expand Up @@ -271,6 +274,14 @@ plan can approve and dispatch a graph through the owner-paired ACP connector;
its run-scoped bridge exposes the document and lifecycle reporting tools. The
Planner cannot approve, lock, or start a graph. See docs/local-launcher.md.

A person's press is the approval. **Build plan** approves and starts the
drafted graph. Once a build has delivered, with or without `LIVE_BUILD`, a
rebuild appends its tasks as an approved, completed graph version from the
coding agent's report, with no separate review: without `LIVE_BUILD` that
approval is the person pressing **Build** (or **Retry**) on the changes, which
the server refuses while decisions or accepted comments are open; under
`LIVE_BUILD` it is the Build plan press that made the document live.

An approved graph binds one plan revision, graph version, and graph revision.
`start_implementation` atomically claims those values and locks the graph. Task,
pull-request, blocker, revision, and verification transitions persist before
Expand Down
3 changes: 3 additions & 0 deletions apps/connector/src/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type AgentOptions = {
onUpdate: (value: SessionNotification) => void;
permission: (request: RequestPermissionRequest) => Promise<string | undefined>;
stderr?: (chunk: string) => void;
/** Added to the agent's environment, such as Git configuration that refuses pushes. */
env?: Record<string, string>;
};

/** One protocol implementation; agent-specific launch arguments belong to local configuration. */
Expand All @@ -25,6 +27,7 @@ export async function runAgent(options: AgentOptions): Promise<string> {
if (!options.command[0]) throw new Error("An ACP command is required.");
let child = spawn(options.command[0], options.command.slice(1), {
cwd: options.cwd,
...(options.env ? { env: { ...process.env, ...options.env } } : {}),
stdio: ["pipe", "pipe", "pipe"],
detached: process.platform !== "win32",
});
Expand Down
125 changes: 125 additions & 0 deletions apps/connector/src/image-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

import { imageFileArguments, MAX_IMAGE_FILE_BYTES } from "./image-file";
import { implementationBridge } from "./implementation";

type BridgeApi = Parameters<typeof implementationBridge>[0];

const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);

let base: string;
let root: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "chopin-image-"));
root = join(base, "worktree");
await mkdir(join(root, "shots"), { recursive: true });
});
afterEach(() => rm(base, { recursive: true, force: true }));

test("reads an image inside the worktree as upload arguments", async () => {
await writeFile(join(root, "shots", "a.png"), PNG);
expect(await imageFileArguments(root, "shots/a.png")).toEqual({
data: Buffer.from(PNG).toString("base64"),
mimeType: "image/png",
});
expect((await imageFileArguments(root, join(root, "shots", "a.png"))).mimeType).toBe(
"image/png",
);
});

test("a file whose name starts with two dots is still inside the worktree", async () => {
await writeFile(join(root, "..shot.png"), PNG);
expect((await imageFileArguments(root, "..shot.png")).mimeType).toBe("image/png");
});

test("refuses an oversized file by its size, before reading it", async () => {
await writeFile(join(root, "huge.png"), new Uint8Array(MAX_IMAGE_FILE_BYTES + 1));
await expect(imageFileArguments(root, "huge.png")).rejects.toThrow("limited to 1 MiB");
await expect(imageFileArguments(root, "shots")).rejects.toThrow("is not a file");
});

test("refuses paths outside the worktree, including through a symlink", async () => {
await writeFile(join(base, "outside.png"), PNG);
await symlink(join(base, "outside.png"), join(root, "link.png"));
await expect(imageFileArguments(root, "../outside.png")).rejects.toThrow("inside this run");
await expect(imageFileArguments(root, join(base, "outside.png"))).rejects.toThrow(
"inside this run",
);
await expect(imageFileArguments(root, "link.png")).rejects.toThrow("inside this run");
await expect(imageFileArguments(root, "missing.png")).rejects.toThrow("No file");
});

test("refuses non-images and images over 1 MiB with a smaller-screenshot hint", async () => {
await writeFile(join(root, "notes.txt"), "hello");
await expect(imageFileArguments(root, "notes.txt")).rejects.toThrow("PNG, JPEG or WebP");
let large = new Uint8Array(MAX_IMAGE_FILE_BYTES + 1);
large.set(PNG);
await writeFile(join(root, "large.png"), large);
await expect(imageFileArguments(root, "large.png")).rejects.toThrow("1280x800");
});

test("the spike bridge serves upload_image_file by forwarding the file's bytes", async () => {
await writeFile(join(root, "shot.png"), PNG);
let invoked: Array<{ name: string; args: Record<string, unknown> }> = [];
let api = {
tools: async () => ({
tools: [{
name: "upload_investigation_image",
inputSchema: { type: "object" as const },
}],
}),
invoke: async (name: string, args: Record<string, unknown> = {}) => {
invoked.push({ name, args });
return { content: [{ type: "text", text: JSON.stringify({ path: "/images/x.png" }) }] };
},
};
let server = await implementationBridge(api as unknown as BridgeApi, root);
let [client, transport] = InMemoryTransport.createLinkedPair();
await server.connect(transport);
let mcp = new Client({ name: "test", version: "1" });
await mcp.connect(client);
try {
let names = (await mcp.listTools()).tools.map(tool => tool.name);
expect(names).toEqual(["upload_investigation_image", "upload_image_file"]);
let result = await mcp.callTool({ name: "upload_image_file", arguments: { path: "shot.png" } });
expect(result.isError).toBeFalsy();
expect(invoked).toEqual([{
name: "upload_investigation_image",
args: { data: Buffer.from(PNG).toString("base64"), mimeType: "image/png" },
}]);
let refused = await mcp.callTool({
name: "upload_image_file",
arguments: { path: "../escape.png" },
});
expect(refused.isError).toBe(true);
expect(invoked).toHaveLength(1);
} finally {
await mcp.close();
await server.close();
}
});

test("bridges without a worktree or image upload offer no local tool", async () => {
let api = {
tools: async () => ({
tools: [{ name: "read_rebuild", inputSchema: { type: "object" as const } }],
}),
invoke: async () => ({ content: [] }),
};
let server = await implementationBridge(api as unknown as BridgeApi, root);
let [client, transport] = InMemoryTransport.createLinkedPair();
await server.connect(transport);
let mcp = new Client({ name: "test", version: "1" });
await mcp.connect(client);
try {
expect((await mcp.listTools()).tools.map(tool => tool.name)).toEqual(["read_rebuild"]);
} finally {
await mcp.close();
await server.close();
}
});
52 changes: 52 additions & 0 deletions apps/connector/src/image-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { readFile, realpath, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";

/** Chopin hosts images of at most 1 MiB. */
export const MAX_IMAGE_FILE_BYTES = 1024 * 1024;

const TOO_LARGE =
"Take a smaller screenshot (a 1280x800 viewport PNG of the running prototype) and upload that.";

function mimeType(bytes: Uint8Array): string | undefined {
let ascii = (start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end));
if (bytes[0] === 0x89 && ascii(1, 4) === "PNG") return "image/png";
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return "image/webp";
}

/**
* Read a PNG, JPEG or WebP inside the run's worktree as upload_investigation_image arguments, so
* the agent passes a path instead of emitting the image's base64 as tokens.
*/
export async function imageFileArguments(
root: string,
path: string,
): Promise<{ data: string; mimeType: string }> {
let base = await realpath(root);
let target: string;
try {
target = await realpath(resolve(base, path));
} catch {
throw new Error(`No file at ${path} in this worktree.`);
}
let inside = relative(base, target);
// `..` as a whole first segment leaves the worktree; a name such as `..shot.png` does not.
if (!inside || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
throw new Error("Upload only image files inside this run's worktree.");
}
// Check the size before reading, so a huge file is never loaded into memory.
let info = await stat(target);
if (!info.isFile()) throw new Error(`${path} is not a file.`);
if (info.size > MAX_IMAGE_FILE_BYTES) {
let size = Math.ceil(info.size / 1024);
throw new Error(`${path} is ${size} KiB; images are limited to 1 MiB. ${TOO_LARGE}`);
}
let bytes = new Uint8Array(await readFile(target));
let type = mimeType(bytes);
if (!type) throw new Error("Upload a PNG, JPEG or WebP image.");
if (bytes.byteLength > MAX_IMAGE_FILE_BYTES) {
let size = Math.ceil(bytes.byteLength / 1024);
throw new Error(`${path} is ${size} KiB; images are limited to 1 MiB. ${TOO_LARGE}`);
}
return { data: Buffer.from(bytes).toString("base64"), mimeType: type };
}
83 changes: 70 additions & 13 deletions apps/connector/src/implementation.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,78 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { imageFileArguments } from "./image-file";
import type { remote } from "./mcp";

export async function implementationBridge(api: Awaited<ReturnType<typeof remote>>) {
let tools = await api.tools();
let names = new Set(tools.tools.map(tool => tool.name));
let server = new Server({ name: "chopin-implementation", version: "0.1.0" }, {
capabilities: { tools: {} },
});
server.setRequestHandler(ListToolsRequestSchema, () => tools);
server.setRequestHandler(CallToolRequestSchema, async request => {
if (!names.has(request.params.name)) {
return { isError: true, content: [{ type: "text", text: "Tool unavailable for this run." }] };
/**
* Relay a run's server tools. With a worktree `root`, a run that may upload images also gets the
* connector-local upload_image_file, which reads the file here and forwards its bytes.
*/
export async function implementationBridge(
api: Pick<Awaited<ReturnType<typeof remote>>, "tools" | "invoke">,
root?: string,
) {
return (await relay(api, root))();
}

/** List the run's tools once and return a factory for relaying servers, one per transport. */
export async function relay(
api: Pick<Awaited<ReturnType<typeof remote>>, "tools" | "invoke">,
root?: string,
) {
let listed = await api.tools();
let names = new Set(listed.tools.map(tool => tool.name));
let local = !!root && names.has("upload_investigation_image");
let tools = local
? {
...listed,
tools: [...listed.tools, {
name: "upload_image_file",
description:
"Upload a PNG, JPEG or WebP screenshot (at most 1 MiB) saved in this worktree and "
+ "return its image path for submit_spike_result.",
inputSchema: {
type: "object" as const,
properties: {
path: {
type: "string",
description: "The image file, relative to the worktree root.",
},
},
required: ["path"],
additionalProperties: false,
},
}],
}
return api.invoke(request.params.name, request.params.arguments ?? {});
});
return server;
: listed;
return () => {
let server = new Server({ name: "chopin-implementation", version: "0.1.0" }, {
capabilities: { tools: {} },
});
server.setRequestHandler(ListToolsRequestSchema, () => tools);
server.setRequestHandler(CallToolRequestSchema, async request => {
let args = request.params.arguments ?? {};
if (local && request.params.name === "upload_image_file") {
try {
if (typeof args.path !== "string" || !args.path) throw new Error("Give an image path.");
return await api.invoke(
"upload_investigation_image",
await imageFileArguments(root!, args.path),
);
} catch (error) {
let text = error instanceof Error ? error.message : "Image upload failed.";
return { isError: true, content: [{ type: "text", text }] };
}
}
if (!names.has(request.params.name)) {
return {
isError: true,
content: [{ type: "text", text: "Tool unavailable for this run." }],
};
}
return api.invoke(request.params.name, args);
});
return server;
};
}

export let implementationPrompt = [
Expand Down
42 changes: 42 additions & 0 deletions apps/connector/src/local-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";

/**
* Serve run-scoped MCP tools to a local ACP agent over loopback streamable HTTP. Some agents (Copilot
* CLI in ACP mode) reject client-provided stdio servers, so connector-local tools need an HTTP URL.
* Each request gets a fresh stateless server; a per-run bearer token keeps other local processes
* and pages out.
*/
export function serveLocalBridge(open: () => Promise<Server>) {
let token = randomBytes(32).toString("base64url");
let expected = Buffer.from(`Bearer ${token}`);
let http = Bun.serve({
hostname: "127.0.0.1",
port: 0,
idleTimeout: 255,
async fetch(request) {
let given = Buffer.from(request.headers.get("authorization") ?? "");
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return new Response(null, { status: 401 });
}
if (new URL(request.url).pathname !== "/mcp") return new Response(null, { status: 404 });
let server = await open();
let transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
await server.connect(transport);
try {
return await transport.handleRequest(request);
} finally {
void server.close();
}
},
});
return {
url: `http://127.0.0.1:${http.port}/mcp`,
token,
close: () => http.stop(true),
};
}
6 changes: 4 additions & 2 deletions apps/connector/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ async function bridge() {
process.env.CHOPIN_BRIDGE_TOKEN!,
);
let server;
if (process.env.CHOPIN_BRIDGE_KIND === "implementation") server = await implementationBridge(api);
else {
// A spike's tools are the server's run-scoped list, relayed as they are.
if (["implementation", "spike"].includes(process.env.CHOPIN_BRIDGE_KIND ?? "")) {
server = await implementationBridge(api, process.env.CHOPIN_BRIDGE_ROOT);
} else {
let context = await api.call("read_experiment") as { input: unknown };
server = createBridge(requestSchema.parse(context.input), async result => {
await api.call("submit_experiment_result", { result });
Expand Down
Loading
Loading