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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ COPY --from=production-dependencies --chown=bun:bun /app/packages/question/node_
COPY --chown=bun:bun package.json bun.lock ./
COPY --chown=bun:bun apps/server ./apps/server
COPY --from=build --chown=bun:bun /app/apps/web/dist ./apps/web/dist
COPY --from=build --chown=bun:bun /app/apps/connector/dist ./apps/connector/dist
COPY --chown=bun:bun apps/web/package.json ./apps/web/package.json
COPY --chown=bun:bun packages ./packages

Expand Down
6 changes: 5 additions & 1 deletion apps/connector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": { "types": "tsgo --noEmit --skipLibCheck", "start": "bun src/main.ts" },
"scripts": {
"build": "bun build src/main.ts --target=bun --outfile dist/connector.js",
"types": "tsgo --noEmit --skipLibCheck",
"start": "bun src/main.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.6.0",
"@modelcontextprotocol/sdk": "1.32.1",
Expand Down
32 changes: 21 additions & 11 deletions apps/connector/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { requestSchema } from "@chopin/experiment";
import { createBridge } from "./bridge";
import { ConnectorError, remote } from "./mcp";
import { createOutput } from "./output";
import { permissionMemory } from "./permission";
import { runWork } from "./run";
import { implementationBridge } from "./implementation";
import { lockWorkspace, stateDirectory, workspace } from "./workspace";
Expand Down Expand Up @@ -52,16 +54,19 @@ async function connect(args: string[]) {
!Array.isArray(command) || !command.length
|| command.some(item => typeof item !== "string" || !item)
) {
throw new Error(
"Usage: CHOPIN_URL=https://your-instance chopin connect [checkout] -- agent acp-arguments",
);
throw new Error(USAGE);
}
let url = origin(process.env.CHOPIN_URL ?? "https://chopin.githubnext.com");
let info = await workspace(resolve(args[0] && args[0] !== "--" ? args[0] : "."));
let directory = stateDirectory();
let release = await lockWorkspace(info.root, directory);
let abort = new AbortController();
let stop = () => abort.abort();
// The first Ctrl-C reports running work as stopped, then disconnects; a second forces exit.
let stop = () => {
if (abort.signal.aborted) process.exit(130);
abort.abort();
process.once("SIGINT", () => process.exit(130));
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
async function post(path: string, body: unknown) {
Expand All @@ -76,11 +81,12 @@ async function connect(args: string[]) {
return value;
}
let api: Awaited<ReturnType<typeof remote>> | undefined;
let output = createOutput();
let permissions = permissionMemory();
try {
let { root: _root, ...source } = info;
let pairing = await post("/api/connector/pairings", { ...source, label: basename(info.root) });
console.error(`Connect this checkout of ${info.repository} in Chopin: ${pairing.url}`);
console.error(`Confirm code ${pairing.code} in Chopin.`);
output.pairing(info.repository, pairing.url, pairing.code);
let token: string | undefined;
while (!abort.signal.aborted && !token) {
let claim = await post(`/api/connector/pairings/${pairing.id}/claim`, {
Expand All @@ -91,9 +97,7 @@ async function connect(args: string[]) {
}
if (!token) return;
api = await remote(url, token);
console.error(
`Connected. Work you start in any ${info.repository} document runs here. Ctrl-C disconnects.`,
);
output.connected(info.repository);
while (!abort.signal.aborted) {
let offered = await api.call("wait_for_work", {}, abort.signal) as {
id?: string;
Expand Down Expand Up @@ -123,6 +127,9 @@ async function connect(args: string[]) {
url,
script: resolve(process.argv[1]),
signal: abort.signal,
output,
permissions,
interrupt: stop,
});
}
} catch (error) {
Expand Down Expand Up @@ -151,15 +158,18 @@ function delay(ms: number, signal: AbortSignal) {
});
}

const USAGE = "Usage: CHOPIN_URL=https://your-instance bun connector.js connect [checkout] -- "
+ "agent [ACP arguments]";

if (import.meta.main) {
let [mode, ...args] = process.argv.slice(2);
try {
if (mode === "bridge") await bridge();
else if (mode === "connect") await connect(args);
else throw new Error("Usage: chopin connect [checkout] -- agent [ACP arguments]");
else throw new Error(USAGE);
} catch (error) {
if (!(error instanceof Error && error.name === "AbortError")) {
console.error(error instanceof Error ? error.message : "Connector failed");
createOutput().error(error instanceof Error ? error.message : "Connector failed");
process.exitCode = 1;
}
}
Expand Down
147 changes: 147 additions & 0 deletions apps/connector/src/output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { expect, test } from "bun:test";
import { createOutput } from "./output";
import { ask, describePermission, permissionMemory } from "./permission";
import { PassThrough } from "node:stream";
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";

function capture(tty: boolean, env: Record<string, string | undefined> = {}) {
let out = "";
let err = "";
let output = createOutput(
{ isTTY: tty, write: text => out += text },
{ isTTY: tty, write: text => err += text },
env,
);
return { output, out: () => out, err: () => err };
}

test("connector messages are plain text on stdout when not a terminal", () => {
let { output, out, err } = capture(false);
output.pairing("org/repo", "https://chopin.test/connect?pairing=abc", "ABCD-EFGH");
output.connected("org/repo");
output.job("build", "Ship the thing", "/tmp/runs/1");
output.finished();
expect(out()).toBe(
[
"",
"Chopin connector · org/repo",
"",
"1. Open this link and sign in:",
" https://chopin.test/connect?pairing=abc",
"",
"2. Check the page shows:",
" ABCD-EFGH",
"",
"✓ Connected.",
" Work you start in any org/repo document runs here. Ctrl-C disconnects.",
"",
"› Building Ship the thing",
" /tmp/runs/1",
" ✓ Finished",
"",
].join("\n") + "\n",
);
expect(err()).toBe("");
});

test("a terminal gets ANSI styling unless NO_COLOR is set", () => {
let styled = capture(true);
styled.output.pairing("org/repo", "https://chopin.test/x", "ABCD-EFGH");
expect(styled.out()).toContain("\x1b[1mChopin connector · org/repo\x1b[22m");
expect(styled.out()).toContain("\x1b[4m\x1b[36mhttps://chopin.test/x\x1b[39m\x1b[24m");
let plain = capture(true, { NO_COLOR: "1" });
plain.output.pairing("org/repo", "https://chopin.test/x", "ABCD-EFGH");
expect(plain.out()).not.toContain("\x1b[");
});

test("failures go to stderr in red; agent noise only with CHOPIN_DEBUG=1", () => {
let quiet = capture(true);
quiet.output.agent("loading model\n");
quiet.output.failed("Agent stopped: refusal", ["auth required"]);
expect(quiet.out()).toBe("");
expect(quiet.err()).toContain("\x1b[31m✗ Failed: Agent stopped: refusal\x1b[39m");
expect(quiet.err()).toContain("agent │ auth required");
expect(quiet.err()).not.toContain("loading model");
let debug = capture(false, { CHOPIN_DEBUG: "1" });
debug.output.agent("loading model\n");
expect(debug.err()).toBe(" agent │ loading model\n");
});

test("job lines name the work by kind and a long brief by its first line", () => {
let { output, out } = capture(false);
output.job("investigation", "Measure startup\nwith details", "/w");
output.job("spike", "x".repeat(100), "/w");
expect(out()).toContain("› Investigating Measure startup\n");
expect(out()).toContain(`› Prototyping ${"x".repeat(79)}…\n`);
});

function request(
toolCall: Partial<RequestPermissionRequest["toolCall"]>,
): RequestPermissionRequest {
return {
sessionId: "s",
toolCall: { toolCallId: "t", ...toolCall },
options: [
{ optionId: "once", name: "Allow once", kind: "allow_once" },
{ optionId: "always", name: "Always allow", kind: "allow_always" },
{ optionId: "deny", name: "Deny", kind: "reject_once" },
],
};
}

test("a permission request shows the tool kind and the actual command, paths or input", () => {
expect(describePermission(request({
kind: "execute",
title: "Running command",
rawInput: { command: "bun test\nbun run types" },
}))).toEqual(["execute: Running command", "$ bun test", " bun run types"]);
expect(describePermission(request({
kind: "edit",
title: "Edit file",
locations: [{ path: "/w/a.ts", line: 3 }, { path: "/w/b.ts" }],
}))).toEqual(["edit: Edit file", "/w/a.ts:3", "/w/b.ts"]);
expect(describePermission(request({ title: "Fetch", rawInput: { url: "https://x.test" } })))
.toEqual(["tool: Fetch", '{"url":"https://x.test"}']);
});

test("always allow is remembered for that tool kind only", () => {
let memory = permissionMemory();
let run = request({ kind: "execute", title: "Running command" });
expect(memory.remembered(run)).toBeUndefined();
memory.chose(run, "once");
expect(memory.remembered(run)).toBeUndefined();
memory.chose(run, "always");
expect(memory.remembered(request({ kind: "execute", title: "Another command" }))).toBe("always");
expect(memory.remembered(request({ kind: "edit", title: "Edit file" }))).toBeUndefined();
});

test("Ctrl-C at a permission prompt interrupts the connector instead of pausing readline", async () => {
let input = new PassThrough();
let controller = new AbortController();
let interrupted = 0;
let answer = ask("Select option: ", {
input,
output: new PassThrough(),
terminal: true,
signal: controller.signal,
interrupt: () => {
interrupted++;
controller.abort();
},
});
input.write("\x03");
expect(await answer).toBeUndefined();
expect(interrupted).toBe(1);
});

test("a permission prompt returns the typed answer", async () => {
let input = new PassThrough();
let answer = ask("Select option: ", {
input,
output: new PassThrough(),
signal: new AbortController().signal,
interrupt: () => {},
});
input.write("2\n");
expect(await answer).toBe("2");
});
101 changes: 101 additions & 0 deletions apps/connector/src/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* The connector's human-facing terminal lines. Messages go to stdout so terminals don't paint them
* as errors; failures go to stderr. ANSI styling only reaches a TTY and honours `NO_COLOR`.
*/
type Stream = { write(text: string): unknown; isTTY?: boolean };

const CODES = {
bold: [1, 22],
dim: [2, 22],
underline: [4, 24],
cyan: [36, 39],
green: [32, 39],
red: [31, 39],
} as const;

export type Style = Record<keyof typeof CODES, (text: string) => string>;

export function style(enabled: boolean): Style {
return Object.fromEntries(
Object.entries(CODES).map((
[name, [open, close]],
) => [name, (text: string) => enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text]),
) as Style;
}

export function colorful(stream: Stream, env: Record<string, string | undefined> = process.env) {
return !!stream.isTTY && !env.NO_COLOR;
}

export type JobKind = "build" | "investigation" | "spike";

const VERBS: Record<JobKind, string> = {
build: "Building",
investigation: "Investigating",
spike: "Prototyping",
};

/** A brief can be paragraphs long; the terminal names it by its first line. */
function headline(text: string) {
let line = text.trim().split("\n")[0]!.trim();
return line.length > 80 ? `${line.slice(0, 79)}…` : line;
}

export function createOutput(
out: Stream = process.stdout,
err: Stream = process.stderr,
env: Record<string, string | undefined> = process.env,
) {
let s = style(colorful(out, env));
let e = style(colorful(err, env));
let say = (...lines: string[]) => void out.write(lines.join("\n") + "\n");
return {
style: s,
pairing(repository: string, url: string, code: string) {
say(
"",
s.bold(`Chopin connector · ${repository}`),
"",
"1. Open this link and sign in:",
` ${s.underline(s.cyan(url))}`,
"",
"2. Check the page shows:",
` ${s.bold(code)}`,
"",
);
},
connected(repository: string) {
say(
`${s.green("✓ Connected.")}`,
s.dim(` Work you start in any ${repository} document runs here. Ctrl-C disconnects.`),
"",
);
},
job(kind: JobKind, title: string, path: string) {
say(`› ${VERBS[kind]} ${headline(title)}`, s.dim(` ${path}`));
},
finished() {
say(` ${s.green("✓")} Finished`, "");
},
stopped() {
say(` ${s.dim("■ Stopped from this terminal")}`, "");
},
/** `agent` is the agent's last stderr lines, shown when debugging didn't already show them. */
failed(message: string, agent: string[] = []) {
let tail = env.CHOPIN_DEBUG === "1" ? [] : agent.map(line => e.dim(` agent │ ${line}`));
err.write([` ${e.red(`✗ Failed: ${message}`)}`, ...tail, "", ""].join("\n"));
},
error(message: string) {
err.write(`${e.red(message)}\n`);
},
/** The agent's own stderr is noise unless debugging; it never looks like a Chopin error. */
agent(text: string) {
if (env.CHOPIN_DEBUG !== "1") return;
let lines = text.replace(/\n$/, "").split("\n");
err.write(lines.map(line => e.dim(` agent │ ${line}`)).join("\n") + "\n");
},
say,
};
}

export type Output = ReturnType<typeof createOutput>;
Loading
Loading