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
18 changes: 17 additions & 1 deletion NodRuntime/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,23 @@ graph files. `ctx.projectPath` comes from `$NOD_PROJECT_PATH`.

`ask` and `handoff` follow `NodSettings.messagesOtherLoops`, read on every call.
`draftForMe` emits `mailDraft` and sends nothing. `send` sends `messageNode` as this loop.
`never` refuses. The runtime answers `sendDraft` with `sendDraft(ctx, {toNodeID, text})`.
`never` refuses.
The runtime keeps each draft's addressee and answers `sendDraft` with `sendDraft(ctx, {toNodeID, text})`, the text as the human edited it.
Without `$NOD_PROJECT_PATH` the server stays mounted and its tools say the graph is out of reach.

### Mounting

| | Claude engine | Copilot engine |
|---|---|---|
| graphcode | in-process SDK MCP server `graphcode` (`mcp__graphcode__<tool>`) | custom tools `graphcode_<tool>` |
| `.mcp.json` | `mcpServers`, with `strictMcpConfig: true` | `mcpServers` (config discovery stays off) |
| gate | PreToolUse forces every `mcp__` call to `canUseTool` | `mcp` and `custom-tool` permission requests |

`.mcp.json` is read walking up from the loop's working directory, the nearer file winning a
name, with `${VAR}` and `${VAR:-default}` expanded. Names in `NodSettings.disabledMCPServers`
are skipped, and an entry called `graphcode` is ignored. A project server's tool asks as
`mcpTool` with subject `<server>/<tool>`.

`classifyInbound(line, graph, nodeID)` maps a typed `[graphcode] <Sender>: …` line to the
sending loop, so a drafted reply goes to the right place.

Expand Down
8 changes: 8 additions & 0 deletions NodRuntime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ graphcode-nod -p <prompt> [--engine claude|copilot] [--model <id>] # one-shot
| `presence.ts` | the `presence`/`activity`/`usage` zmx labels and `sessions/<node>.id` that `PresenceHooks` writes |
| `credentials.ts` · `agentRuntimes.ts` | Keychain sign-ins, and where each engine's agent runtime is |
| `brief.ts` | `--inherit <brief.json>` (PROTOCOL.md, Inherited briefs) |
| `mcp/` · `mcpServers.ts` | the built-in graphcode server, the project's `.mcp.json` servers, and how each engine mounts them |

```sh
bun install && bun test # unit tests run against fake engines; the contract test compiles NodProtocol.swift
Expand Down Expand Up @@ -107,6 +108,13 @@ bun src/main.ts -p "hello" # from source
turn a human or timer started). Claude's cost is exact at each turn end and estimated
mid-turn from tokens, scaled to the last exact figure, so the cap can stop mid-turn.
Copilot reports premium requests, which its plan caps.
- **MCP**: each engine mounts exactly two kinds of server. The built-in `graphcode` server
is always on: in-process on Claude (an SDK MCP server), custom tools named
`graphcode_<tool>` on Copilot. The project's `.mcp.json` servers are found the way the
CLIs find them, walking up from the working directory with `${VAR}` expanded, minus
`NodSettings.disabledMCPServers`. Claude runs with `strictMcpConfig`, so Claude Code adds
nothing of its own; Copilot never discovers config. graphcode's tools pass the gate and
apply `messagesOtherLoops` themselves; any other server's tool is an `mcpTool` ask.
- **Context** is reported as `usage.contextUsed`; at 95% Nod compacts after the turn.
- **Resume**: `--resume <conversation-id>` continues the engine conversation and appends to
the same `events.jsonl`, continuing its `seq`. `conversation.json` holds engine, model and
Expand Down
1 change: 1 addition & 0 deletions NodRuntime/bun.lock

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

3 changes: 2 additions & 1 deletion NodRuntime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.287",
"@github/copilot-sdk": "^1.0.16"
"@github/copilot-sdk": "^1.0.16",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bun": "^1.4.2",
Expand Down
7 changes: 6 additions & 1 deletion NodRuntime/scripts/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
//
// The runtime command defaults to this checkout's source; pass "/path/to/graphcode-nod" to
// smoke a packaged build. Claude needs an anthropic-api-key item in app.graphcode.nod.
// With NOD_PROJECT_PATH (and GRAPHCODE_SOCKET) set, Nod also calls the graphcode MCP
// server's siblings tool against that graph first.
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { join } from "node:path";
Expand All @@ -23,10 +25,13 @@ writeFileSync(join(support, "settings.json"), JSON.stringify({ nod: { engine, sh
const goalFile = join(root, "goal.txt");
writeFileSync(goalFile, "Done when hello.txt contains the line `hello nod` and `cat hello.txt` has printed it");

const graphStep = process.env.NOD_PROJECT_PATH
? "First call the graphcode server's siblings tool and say how many other loops it lists. "
: "";
const state = join(support, "nod", node);
const child = Bun.spawn(
[...runtime.split(" "), "--node", node, "--cwd", work, "--engine", engine, "--loop-type", "goal", "--goal-file", goalFile,
"--prompt", "Use your Write tool (not the shell) to create hello.txt containing the line `hello nod`. Then run `ls -la` and then `cat hello.txt`.",
"--prompt", graphStep + "Use your Write tool (not the shell) to create hello.txt containing the line `hello nod`. Then run `ls -la` and then `cat hello.txt`.",
"--exit-when-idle"],
{ env: { ...process.env, GRAPHCODE_SUPPORT_DIR: support, NOD_STATE: state }, stdout: "inherit", stderr: "inherit" },
);
Expand Down
8 changes: 7 additions & 1 deletion NodRuntime/src/claudeEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@anthropic-ai/claude-agent-sdk";
import { claudeEnvironment } from "./credentials";
import type { Engine, EngineFailure, EngineSession, EngineStart, ToolRequest, TurnCallbacks, TurnResult } from "./engine";
import { claudeMcpServers } from "./mcpServers";
import type { NodAttachment } from "./protocol";
import { summarizeResult } from "./tools";

Expand Down Expand Up @@ -68,6 +69,8 @@ export interface ClaudeEngineOptions {
configDir: string;
/** Claude Code to run; the SDK's bundled build when absent. */
executable?: string;
/** The SDK's `query`; tests pass a fake to see the options a session opens with. */
query?: typeof query;
}

const NO_KEY = "Nod needs an Anthropic API key. Add one in Settings › Agents › Nod.";
Expand Down Expand Up @@ -124,6 +127,9 @@ export class ClaudeEngine implements Engine {
// "user" would read Nod's own config directory, not the human's ~/.claude.
settingSources: ["project", "local"],
systemPrompt: { type: "preset", preset: "claude_code", append: start.systemAppend },
// Only what Nod mounts: the project's .mcp.json minus disabledMCPServers, read by the runtime.
mcpServers: start.mcp ? claudeMcpServers(start.mcp) : {},
strictMcpConfig: true,
canUseTool: (tool, input, { signal }) => this.canUseTool(tool, input, signal),
hooks: {
PreToolUse: [{ hooks: [this.preToolUse] }],
Expand All @@ -133,7 +139,7 @@ export class ClaudeEngine implements Engine {
...(this.options.executable ? { pathToClaudeCodeExecutable: this.options.executable } : {}),
stderr: () => {},
};
this.q = query({ prompt: this.inbox, options });
this.q = (this.options.query ?? query)({ prompt: this.inbox, options });
this.consumer = this.consume(this.q);
}

Expand Down
17 changes: 15 additions & 2 deletions NodRuntime/src/copilotEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { isAbsolute, resolve } from "node:path";
import {
CopilotClient,
RuntimeConnection,
type CopilotClientOptions,
type CopilotSession,
type PermissionRequest,
type PermissionRequestResult,
type SessionConfig,
type SessionEvent,
} from "@github/copilot-sdk";
import type { Engine, EngineFailure, EngineSession, EngineStart, ToolRequest, TurnCallbacks, TurnResult } from "./engine";
import { serverName } from "./mcp";
import { copilotGraphcodeTools, copilotMcpServers, copilotToolPrefix } from "./mcpServers";
import type { NodAttachment } from "./protocol";
import { summarizeResult } from "./tools";

Expand All @@ -29,6 +32,8 @@ export interface CopilotEngineOptions {
githubToken?: string;
/** The Copilot runtime to spawn; the SDK's bundled one when absent. */
cliPath?: string;
/** Tests pass a fake client to see the config a session opens with. */
client?: (options: CopilotClientOptions) => CopilotClient;
}

/**
Expand Down Expand Up @@ -64,6 +69,8 @@ export class CopilotEngine implements Engine {
streaming: true,
systemMessage: start.systemAppend ? { mode: "append", content: start.systemAppend } : undefined,
onPermissionRequest: (request) => this.onPermission(request),
tools: copilotGraphcodeTools(start.mcp?.graphcode ?? []),
mcpServers: copilotMcpServers(start.mcp ?? { graphcode: [], servers: {} }),
hooks: {
onPostToolUse: () => {
const steer = this.turn?.callbacks.takeSteer();
Expand All @@ -84,12 +91,13 @@ export class CopilotEngine implements Engine {

private makeClient(cwd: string): CopilotClient {
const path = this.options.cliPath;
return new CopilotClient({
const options: CopilotClientOptions = {
workingDirectory: cwd,
logLevel: "error",
...(this.options.githubToken ? { gitHubToken: this.options.githubToken, useLoggedInUser: false } : { useLoggedInUser: true }),
...(path ? { connection: RuntimeConnection.forStdio({ path }) } : {}),
});
};
return this.options.client ? this.options.client(options) : new CopilotClient(options);
}

private async currentModel(): Promise<string | undefined> {
Expand Down Expand Up @@ -276,6 +284,11 @@ export function toolRequest(request: PermissionRequest, cwd: string): ToolReques
return { intent: { kind: "fetch", url: str("url") } };
case "mcp":
return { intent: { kind: "mcp", server: str("serverName"), tool: str("toolName") } };
case "custom-tool": {
const name = str("toolName");
if (name.startsWith(copilotToolPrefix)) return { intent: { kind: "mcp", server: serverName, tool: name.slice(copilotToolPrefix.length) } };
return { intent: { kind: "read" } };
}
case "read":
return { intent: { kind: "read" } };
}
Expand Down
3 changes: 3 additions & 0 deletions NodRuntime/src/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NodAttachment, NodEngineKind, NodFailureKind, NodToolStatus } from "./protocol";
import type { McpMount } from "./mcpServers";
import type { ToolIntent } from "./permissions";

export interface EngineStart {
Expand All @@ -10,6 +11,8 @@ export interface EngineStart {
forkFrom?: string;
/** Appended to the engine's own system prompt: the briefing and Nod's identity. */
systemAppend?: string;
/** The graphcode server and the project's `.mcp.json` servers; nothing else is mounted. */
mcp?: McpMount;
}

export interface EngineSession {
Expand Down
4 changes: 4 additions & 0 deletions NodRuntime/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { anthropicAPIKey, githubToken } from "./credentials";
import type { Engine } from "./engine";
import { readBrief } from "./brief";
import { EventLog } from "./eventLog";
import { loadProjectMcpServers } from "./mcpServers";
import { PresenceReporter } from "./presence";
import type { NodEngineKind } from "./protocol";
import { NodRuntime, type LoopType } from "./runtime";
Expand Down Expand Up @@ -111,6 +112,9 @@ async function main(argv: string[]): Promise<number> {
resume: values.resume,
inherit: values.inherit && !values.resume ? readBrief(values.inherit) : undefined,
unattended: values.unattended || undefined,
projectPath: process.env.NOD_PROJECT_PATH || undefined,
messagesOtherLoops: () => loadSettings().messagesOtherLoops,
mcpServers: loadProjectMcpServers(cwd, settings.disabledMCPServers),
});
const control = new ControlSocket(join(stateDir, "control.sock"), (command) => runtime.handle(command));
await control.listen();
Expand Down
2 changes: 1 addition & 1 deletion NodRuntime/src/mcp/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export class DaemonConnection {
while (this.queue.length > 0) {
const event = this.queue.shift()!;
const key = keys.find((k) => k in event);
if (key) return [key, event[key]];
if (key) return [key, event[key]!];
}
if (this.closed) throw this.closed;
const remaining = deadline - Date.now();
Expand Down
144 changes: 144 additions & 0 deletions NodRuntime/src/mcpServers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createSdkMcpServer, tool, type McpServerConfig, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
import type { MCPServerConfig, Tool, ToolResultObject } from "@github/copilot-sdk";
import { z } from "zod";
import { serverName, type GraphcodeTool, type ToolResult } from "./mcp";

/** A server entry from `.mcp.json`, in the shape Claude Code and Copilot CLI both read. */
export type ProjectMcpServer =
| { type?: "stdio"; command: string; args?: string[]; env?: Record<string, string> }
| { type: "http" | "sse"; url: string; headers?: Record<string, string> };

/** What each engine mounts: the built-in graphcode server and the project's own servers. */
export interface McpMount {
graphcode: GraphcodeTool[];
servers: Record<string, ProjectMcpServer>;
}

/** Copilot custom tools share one namespace with its built-ins, so they carry the server's name. */
export const copilotToolPrefix = `${serverName}_`;

/**
* The project's `.mcp.json` servers, nearest file first as the CLIs find it walking up from
* the working directory, with `${VAR}` and `${VAR:-default}` expanded. Names in `disabled`
* are left out, and so is any entry called `graphcode`: the built-in server always wins.
*/
export function loadProjectMcpServers(
cwd: string,
disabled: string[],
env: Record<string, string | undefined> = process.env,
): Record<string, ProjectMcpServer> {
const servers: Record<string, ProjectMcpServer> = {};
const skip = new Set([...disabled, serverName]);
for (let dir = resolve(cwd); ; dir = dirname(dir)) {
const file = join(dir, ".mcp.json");
if (existsSync(file)) {
for (const [name, entry] of Object.entries(readServers(file))) {
if (skip.has(name) || name in servers) continue;
const server = projectServer(entry, env);
if (server) servers[name] = server;
}
}
if (dirname(dir) === dir) return servers;
}
}

function readServers(file: string): Record<string, unknown> {
try {
const parsed = JSON.parse(readFileSync(file, "utf8")) as { mcpServers?: unknown };
return parsed.mcpServers && typeof parsed.mcpServers === "object" ? (parsed.mcpServers as Record<string, unknown>) : {};
} catch {
return {};
}
}

function projectServer(entry: unknown, env: Record<string, string | undefined>): ProjectMcpServer | undefined {
if (!entry || typeof entry !== "object") return undefined;
const e = entry as Record<string, unknown>;
const expand = (text: string) =>
text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name: string, fallback?: string) => env[name] ?? fallback ?? "");
const strings = (value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === "string").map(([k, v]) => [k, expand(v as string)]))
: undefined;
if ((e.type === "http" || e.type === "sse") && typeof e.url === "string") {
const headers = strings(e.headers);
return { type: e.type, url: expand(e.url), ...(headers ? { headers } : {}) };
}
if ((e.type === undefined || e.type === "stdio") && typeof e.command === "string") {
const args = Array.isArray(e.args) ? e.args.filter((a): a is string => typeof a === "string").map(expand) : undefined;
const vars = strings(e.env);
return { type: "stdio", command: expand(e.command), ...(args ? { args } : {}), ...(vars ? { env: vars } : {}) };
}
return undefined;
}

/** A thrown handler — graphcoded down, a bad reply — is the model's to read, not a crash. */
async function run(t: GraphcodeTool, args: Record<string, unknown>): Promise<ToolResult> {
try {
return await t.handler(args ?? {});
} catch (error) {
return { text: error instanceof Error ? error.message : String(error), isError: true };
}
}

/** The Agent SDK's in-process server takes zod shapes; the graphcode tools use flat JSON Schema. */
function zodShape(schema: GraphcodeTool["inputSchema"]): Record<string, z.ZodType> {
const required = new Set(schema.required ?? []);
return Object.fromEntries(
Object.entries(schema.properties).map(([key, value]) => {
const property = value as { type?: string; description?: string };
let field: z.ZodType = property.type === "number" ? z.number() : property.type === "boolean" ? z.boolean() : z.string();
if (property.description) field = field.describe(property.description);
return [key, required.has(key) ? field : field.optional()];
}),
);
}

export function claudeGraphcodeTools(tools: GraphcodeTool[]): SdkMcpToolDefinition[] {
return tools.map((t) =>
tool(
t.name,
t.description,
zodShape(t.inputSchema),
async (args) => {
const result = await run(t, args as Record<string, unknown>);
return { content: [{ type: "text", text: result.text }], ...(result.isError ? { isError: true } : {}) };
},
{ annotations: { readOnlyHint: t.readOnly }, alwaysLoad: true },
),
);
}

export function claudeMcpServers(mount: McpMount): Record<string, McpServerConfig> {
return {
...mount.servers,
[serverName]: createSdkMcpServer({ name: serverName, version: "1", tools: claudeGraphcodeTools(mount.graphcode), alwaysLoad: true }),
};
}

export function copilotGraphcodeTools(tools: GraphcodeTool[]): Tool[] {
return tools.map((t) => ({
name: copilotToolPrefix + t.name,
description: t.description,
parameters: t.inputSchema,
defer: "never",
async handler(args: unknown): Promise<ToolResultObject> {
const result = await run(t, args as Record<string, unknown>);
return result.isError
? { textResultForLlm: result.text, resultType: "failure", error: result.text }
: { textResultForLlm: result.text, resultType: "success" };
},
}));
}

export function copilotMcpServers(mount: McpMount): Record<string, MCPServerConfig> {
return Object.fromEntries(
Object.entries(mount.servers).map(([name, server]): [string, MCPServerConfig] =>
"url" in server
? [name, { type: server.type, url: server.url, ...(server.headers ? { headers: server.headers } : {}), tools: ["*"] }]
: [name, { type: "stdio", command: server.command, args: server.args ?? [], ...(server.env ? { env: server.env } : {}), tools: ["*"] }],
),
);
}
Loading
Loading