Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.

Commit ae77ec8

Browse files
myobieclaude
andauthored
convoy: per-network ding-service selector (node st ding | rust ding) (#99)
Adds a `ding` field to <net>/convoy.toml letting a network choose its ding sidecar: "node" (smalltalk's `st ding`, the default — unset → node, so every existing user is unchanged) or "rust" (compoundingtech/ding — full st-ding parity, ~0% CPU). Both take IDENTICAL args, so the selector swaps ONLY the binary prefix; --identity/--root and the whole flag surface are untouched. - network-config.ts: DingService type + `ding` field, read/write + validation. - launch.ts: dingBin()/dingCommand(service) — writePtyToml reads the net's choice and bakes the right ding binary into each agent's pty.toml. The pre-#43 ding-heal path stays node-only (it only ever matches `st ding` tomls; a rust ding is new and already carries --root, so heal skips it). - convoy init --ding node|rust records the choice (mirrors --megarepo), also surfaced in the command table (completions + flag allow-list). Coordinated with ding-rust-claude: the rust `ding` is a confirmed drop-in (same positional session-id, same --identity/--root, zero new required config). Claude-Session: https://claude.ai/code/session_01MCzqQKSpPiNX2ketyubByS Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1c7f2a1 commit ae77ec8

7 files changed

Lines changed: 117 additions & 13 deletions

File tree

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ function printHelp(): void {
137137
"SUBCOMMANDS:\n" +
138138
" ls (default) list the convoy's members [--tree = spawn-parentage tree + cross-machine liveness (synced status-mtime + host) --stale-after <ms> --live-only --json --network]\n" +
139139
" doctor setup-readiness suite: prove your setup can do real agent work [--quick = preflight only; --full = real CoS→sup→worker org proof (slower)]\n" +
140-
" init [name|dir] interactive, narrated: stand up a network (name → megarepo → CoS), NAME lives at <home>/<name> [--megarepo <path> --quiet --json --yes --no-channel]\n" +
140+
" init [name|dir] interactive, narrated: stand up a network (name → megarepo → CoS), NAME lives at <home>/<name> [--megarepo <path> --ding node|rust --quiet --json --yes --no-channel]\n" +
141141
" add <role> DECLARE an agent — write its agent file into the synced catalog; NO launch (convoy up runs it) [--identity --host --harness claude|codex --model <id> --transport ding|mcp --mcp --network --dir --persona --permanent --dry-run --force]\n" +
142142
" render <id> materialize an agent's worktree overlay from its catalog agent file — NO launch, NO bus (declarative: add=declare · render=materialize · up=reconcile) [--dir <workspace> --network --dry-run]\n" +
143143
" cos --repo <d> bootstrap a Chief of Staff\n" +

src/command-table.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import { HARNESSES, TRANSPORTS } from "./agent-spec.ts";
1616
import { ROLE_SPELLINGS } from "./role.ts";
17+
import { DING_SERVICES } from "./network-config.ts";
1718

1819
/** A `--flag`. `kind` decides whether it consumes the next token — the same distinction `unknownFlag`
1920
* makes. `values` is a closed set of completions for the argument; `takesPath` means the argument is a
@@ -93,6 +94,7 @@ export const COMMANDS: readonly CommandSpec[] = [
9394
flags: [
9495
{ name: "name", desc: "Network name", kind: "value" },
9596
{ name: "megarepo", desc: "Megarepo path agents cut worktrees off", kind: "value", takesPath: true },
97+
{ name: "ding", desc: "Ding sidecar service (node = st ding, rust = compoundingtech/ding)", kind: "value", values: DING_SERVICES },
9698
{ name: "quiet", desc: "No prompts or narration", kind: "bool" },
9799
{ name: "yes", desc: "Accept defaults non-interactively", kind: "bool" },
98100
{ name: "no-channel", desc: "Skip creating the default channel", kind: "bool" },

src/commands.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
1010
import { run } from "./exec.ts";
1111
import { flagAllowList } from "./command-table.ts";
1212
import { CONVOY_DIR, DEFAULT_NETWORK_NAME, defaultConvoyNetwork, isNetworkName, networkDirForName, networkDirOfStRoot, networkLayout, stRootOf } from "./paths.ts";
13-
import { networkNameFromDir, readNetworkConfig, writeNetworkConfig } from "./network-config.ts";
13+
import { DING_SERVICES, isDingService, networkNameFromDir, readNetworkConfig, writeNetworkConfig, type DingService } from "./network-config.ts";
1414
import { defaultBinDir, installClis } from "./install-cli.ts";
1515
import { Bus, isLive, type Agent } from "./bus.ts";
1616
import { PtyHost, spawnFromPtyFile, workspaceOfPtyfile, type SupervisedSession } from "./host.ts";
@@ -533,7 +533,7 @@ async function askYesNo(question: string, def: boolean): Promise<boolean> {
533533
return a ? a.startsWith("y") : def;
534534
}
535535

536-
/** `convoy init [name|dir] [--megarepo <path>] [--quiet|--json] [--yes] [--no-channel]` — stand up a
536+
/** `convoy init [name|dir] [--megarepo <path>] [--ding node|rust] [--quiet|--json] [--yes] [--no-channel]` — stand up a
537537
* correctly-structured network. INTERACTIVE + NARRATED by default (Nathan): on a TTY it walks the user
538538
* through network name → megarepo → CoS, and tells them what it's doing at each step. `--quiet`/`--json`
539539
* (or a non-TTY, e.g. scripts/evals) skips prompts + narration; `--yes` accepts defaults non-interactively;
@@ -581,15 +581,28 @@ export async function cmdInit(args: string[]): Promise<number> {
581581
megarepo = abs;
582582
}
583583

584+
// 2b. Ding service — which ding sidecar this net runs: node `st ding` (default) or the rust `ding`.
585+
// --ding wins; else preserve a prior config. A machine-runtime choice, so it is NOT prompted for.
586+
let ding: DingService | undefined = priorCfg?.ding;
587+
const dingInput = optValue(args, "--ding");
588+
if (dingInput) {
589+
if (!isDingService(dingInput)) {
590+
err(`invalid --ding "${dingInput}" (want: ${DING_SERVICES.join(" | ")})`);
591+
return 1;
592+
}
593+
ding = dingInput;
594+
}
595+
584596
// 3. Create the structure + config, narrating each step.
585597
say("→ Creating the network structure (smalltalk/ = the bus · catalog/ = agent files (desired state) · pty/ = runtime · worktrees/ = workspaces)…");
586598
mkdirSync(layout.stRoot, { recursive: true });
587599
mkdirSync(catalogDir(dir), { recursive: true }); // the catalog — `convoy add` writes agent files here (desired state); convoy declares it to `fabric sync` (below) so it propagates cross-machine
588600
mkdirSync(layout.ptyRoot, { recursive: true });
589601
mkdirSync(layout.worktrees, { recursive: true });
590602
say("→ Recording the network config (convoy.toml)…");
591-
writeNetworkConfig(dir, { name: networkNameFromDir(dir), ...(megarepo ? { megarepo } : {}) });
603+
writeNetworkConfig(dir, { name: networkNameFromDir(dir), ...(megarepo ? { megarepo } : {}), ...(ding ? { ding } : {}) });
592604
if (megarepo) say(` megarepo: ${megarepo}`);
605+
if (ding) say(` ding: ${ding}${ding === "rust" ? " (compoundingtech/ding — rust)" : " (smalltalk st ding — node)"}`);
593606
say("→ Initializing the smalltalk bus…");
594607
const stArgs = ["init", layout.stRoot];
595608
if (hasFlag(args, "--no-channel")) stArgs.push("--no-channel");

src/launch.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { bootPrompt, dingCommand, discoverSmalltalkDir, harnessCommand, regenerateDingRoot, writeAgentFiles, writeContextFiles, writePtyToml } from "./launch.ts";
77
import type { AgentSpec } from "./agent-spec.ts";
8+
import { stRootOf } from "./paths.ts";
9+
import { writeNetworkConfig } from "./network-config.ts";
810

911
describe("native launch command builders (cold-start boot-prompt)", () => {
1012
it("harnessCommand claude: exec claude with the mode + boot prompt, NO poker, NO --resume", () => {
@@ -59,6 +61,20 @@ describe("native launch command builders (cold-start boot-prompt)", () => {
5961
// no root → no flag (unchanged behavior; falls back to ST_ROOT env / install default)
6062
expect(dingCommand("convoy-claude", "silber.convoy", null)).toBe("st ding silber.convoy --identity convoy-claude");
6163
});
64+
65+
it("dingCommand: service='rust' swaps the binary to `ding` (identical args); 'node'/undefined → `st ding`", () => {
66+
// rust ding = drop-in: same positional session-id, same --identity/--root — only the prefix changes.
67+
expect(dingCommand("convoy-claude", "silber.convoy", "/net/smalltalk", "rust")).toBe(
68+
"ding silber.convoy --identity convoy-claude --root /net/smalltalk",
69+
);
70+
// node + undefined both → `st ding` (the default; every existing user is unchanged).
71+
expect(dingCommand("convoy-claude", "silber.convoy", "/net/smalltalk", "node")).toBe(
72+
"st ding silber.convoy --identity convoy-claude --root /net/smalltalk",
73+
);
74+
expect(dingCommand("convoy-claude", "silber.convoy", "/net/smalltalk")).toBe(
75+
"st ding silber.convoy --identity convoy-claude --root /net/smalltalk",
76+
);
77+
});
6278
});
6379

6480
describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => {
@@ -112,6 +128,29 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => {
112128
}
113129
});
114130

131+
it("network convoy.toml ding='rust' bakes the rust `ding` binary into the sidecar; default → node `st ding`", () => {
132+
const net = mkdtempSync(join(tmpdir(), "convoy-ding-net-"));
133+
const dir = mkdtempSync(join(tmpdir(), "convoy-ptytoml-ding-"));
134+
try {
135+
// default (network has no `ding` recorded) → node st ding, unchanged.
136+
writeNetworkConfig(net, { name: "ournet" });
137+
writePtyToml(dir, spec({ networkRoot: net }));
138+
expect(readFileSync(join(dir, ".convoy", "pty.toml"), "utf8")).toContain(
139+
`st ding silber.convoy --identity silber.convoy-claude --root ${stRootOf(net)}`,
140+
);
141+
142+
// network chooses rust → the sidecar swaps to `ding` (same args), fully — no `st ding` left.
143+
writeNetworkConfig(net, { name: "ournet", ding: "rust" });
144+
writePtyToml(dir, spec({ networkRoot: net }));
145+
const toml = readFileSync(join(dir, ".convoy", "pty.toml"), "utf8");
146+
expect(toml).toContain(`ding silber.convoy --identity silber.convoy-claude --root ${stRootOf(net)}`);
147+
expect(toml).not.toContain("st ding");
148+
} finally {
149+
rmSync(net, { recursive: true, force: true });
150+
rmSync(dir, { recursive: true, force: true });
151+
}
152+
});
153+
115154
it("--config-dir sets CLAUDE_CONFIG_DIR on the HARNESS session env only, not the ding sidecar", () => {
116155
const dir = mkdtempSync(join(tmpdir(), "convoy-ptytoml-cfg-"));
117156
try {

src/launch.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { harnessDescriptor, type Harness } from "./harness.ts";
1515
import type { PermissionMode, Role } from "./role.ts";
1616
import { pretrustDir, pretrustDirsCodex } from "./trust.ts";
1717
import { CONVOY_DIR, networkLayout, stRootOf } from "./paths.ts";
18+
import { readNetworkConfig, type DingService } from "./network-config.ts";
1819
import { counterContextRefusal } from "./identity.ts";
1920

2021
/** A harness without MCP always runs the ding sidecar; one with MCP honors its declared transport. */
@@ -134,14 +135,22 @@ export function harnessCommand(
134135
return `exec ${cmd}${tail}`;
135136
}
136137

138+
/** The ding-sidecar BINARY for a network's chosen ding service (see network-config.ts `DingService`):
139+
* smalltalk's node `st ding` (default, unset → this) or the rust `ding` (compoundingtech/ding — full
140+
* `st ding` parity, ~0% CPU). Both take IDENTICAL args, so the selector swaps only this prefix. */
141+
export function dingBin(service?: DingService): string {
142+
return service === "rust" ? "ding" : "st ding";
143+
}
144+
137145
/** The ding sidecar command — pokes the agent's claude session when its bus inbox gets mail. Points at
138-
* the stable session id (`st ding <prefix.agentShort> --identity <bus-id>`). `st ding` stays a
139-
* smalltalk runtime binary. When a network `root` is given we bake `--root <net>` (smalltalk #85) into
140-
* the command line — NOT just the env — so a `pty restart` (which replays the stored command) can never
141-
* drop it and silently fall back to st's install-default root (the fleet phantom-poke/non-delivery bug). */
142-
export function dingCommand(busId: string, claudeSessionId: string, root?: string | null): string {
146+
* the stable session id (`<ding-bin> <prefix.agentShort> --identity <bus-id>`). The ding binary is the
147+
* network's chosen ding service (`dingBin`); default node `st ding`. When a network `root` is given we
148+
* bake `--root <net>` (smalltalk #85) into the command line — NOT just the env — so a `pty restart`
149+
* (which replays the stored command) can never drop it and silently fall back to st's install-default
150+
* root (the fleet phantom-poke/non-delivery bug). The rust ding honors `--root` identically. */
151+
export function dingCommand(busId: string, claudeSessionId: string, root?: string | null, service?: DingService): string {
143152
const rootFlag = root ? ` --root ${root}` : "";
144-
return `st ding ${claudeSessionId} --identity ${busId}${rootFlag}`;
153+
return `${dingBin(service)} ${claudeSessionId} --identity ${busId}${rootFlag}`;
145154
}
146155

147156
/** Provision the agent's DURABLE CONTEXT dir (`<member>/context/`) — unless the identity is a counter.
@@ -177,6 +186,9 @@ export function provisionContext(memberDir: string, identity: string): string |
177186
export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: string | null }): void {
178187
const busId = busAgentId(spec); // the host-prefixed bus identity, e.g. silber.convoy-claude
179188
const root = spec.networkRoot; // the network DIR; ST_ROOT is <root>/smalltalk (the bus), PTY_ROOT is <root>/pty
189+
// The ding SERVICE is a per-network choice, recorded in <net>/convoy.toml (unset → node `st ding`). Read
190+
// it here so the sidecar command baked into the pty.toml is the network's chosen ding (node or rust).
191+
const dingService = root ? readNetworkConfig(root)?.ding : undefined;
180192
const harnessId = sessionId(spec); // e.g. silber.convoy (agentShort strips the -claude/-codex suffix)
181193
const dingId = `${harnessId}.ding`; // e.g. silber.convoy.ding
182194
const permanent = specPermanent(spec);
@@ -216,7 +228,7 @@ export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: st
216228
? {
217229
ding: {
218230
id: dingId,
219-
command: dingCommand(busId, harnessId, root ? stRootOf(root) : null),
231+
command: dingCommand(busId, harnessId, root ? stRootOf(root) : null, dingService),
220232
tags: { role: "ding", ...(permanent ? { strategy: "permanent" } : {}), ...stTag },
221233
env,
222234
},

src/network-config.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, it, expect } from "vitest";
2-
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
2+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
import { execFileSync } from "node:child_process";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
@@ -32,6 +32,23 @@ describe("network-config (<net>/convoy.toml)", () => {
3232
expect(readNetworkConfig(d)).toEqual({ name: "big", megarepo: "/repos/mono" });
3333
});
3434

35+
it("write + read round-trips the ding service; an invalid value is ignored (falls back to node default)", () => {
36+
const d = tmp();
37+
// unset → absent from the parsed config (callers treat undefined as node `st ding`).
38+
writeNetworkConfig(d, { name: "default" });
39+
expect(readNetworkConfig(d)).toEqual({ name: "default" });
40+
41+
// node + rust both round-trip.
42+
writeNetworkConfig(d, { name: "default", ding: "node" });
43+
expect(readNetworkConfig(d)).toEqual({ name: "default", ding: "node" });
44+
writeNetworkConfig(d, { name: "ournet", ding: "rust" });
45+
expect(readNetworkConfig(d)).toEqual({ name: "ournet", ding: "rust" });
46+
47+
// a hand-edited garbage value is dropped (not surfaced as a bogus DingService).
48+
writeFileSync(networkConfigPath(d), 'name = "x"\nding = "banana"\n');
49+
expect(readNetworkConfig(d)).toEqual({ name: "x" });
50+
});
51+
3552
it("read is null when the file is missing or nameless", () => {
3653
const d = tmp();
3754
expect(readNetworkConfig(d)).toBeNull(); // no file yet

src/network-config.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,28 @@ import { readFileSync, writeFileSync } from "node:fs";
88
import { basename, join } from "node:path";
99
import { parse as tomlParse, stringify as tomlStringify } from "smol-toml";
1010

11+
/** Which ding sidecar a network runs — "node" (smalltalk's `st ding`, the default for everyone) or
12+
* "rust" (compoundingtech/ding: full `st ding` parity, ~0% CPU). Both take identical args, so the
13+
* choice swaps ONLY the binary. Unset → "node". */
14+
export type DingService = "node" | "rust";
15+
16+
/** The two `DingService` spellings, for validation + completions (a runtime tuple, not just the type). */
17+
export const DING_SERVICES: readonly DingService[] = ["node", "rust"];
18+
1119
export interface NetworkConfig {
1220
/** The network's name (defaults to the dir basename). */
1321
name: string;
1422
/** Absolute path to the megarepo agents cut worktrees off, if the network uses one (else workspaces
1523
* are symlinked into worktrees/). Optional — added by the megarepo model. */
1624
megarepo?: string;
25+
/** The ding sidecar this network runs (see `DingService`). A per-NETWORK choice, not per-agent: the
26+
* ding binary is a runtime dependency of the box hosting the net. Unset → "node" (`st ding`). */
27+
ding?: DingService;
28+
}
29+
30+
/** True iff `v` is a valid `DingService` spelling. */
31+
export function isDingService(v: unknown): v is DingService {
32+
return v === "node" || v === "rust";
1733
}
1834

1935
/** The config file location for a network dir: `<dir>/convoy.toml`. */
@@ -31,7 +47,11 @@ export function readNetworkConfig(dir: string): NetworkConfig | null {
3147
try {
3248
const doc = tomlParse(readFileSync(networkConfigPath(dir), "utf8")) as Partial<NetworkConfig>;
3349
if (typeof doc.name !== "string" || doc.name === "") return null;
34-
return { name: doc.name, ...(typeof doc.megarepo === "string" && doc.megarepo ? { megarepo: doc.megarepo } : {}) };
50+
return {
51+
name: doc.name,
52+
...(typeof doc.megarepo === "string" && doc.megarepo ? { megarepo: doc.megarepo } : {}),
53+
...(isDingService(doc.ding) ? { ding: doc.ding } : {}),
54+
};
3555
} catch {
3656
return null;
3757
}
@@ -41,5 +61,6 @@ export function readNetworkConfig(dir: string): NetworkConfig | null {
4161
export function writeNetworkConfig(dir: string, config: NetworkConfig): void {
4262
const doc: Record<string, unknown> = { name: config.name };
4363
if (config.megarepo) doc["megarepo"] = config.megarepo;
64+
if (config.ding) doc["ding"] = config.ding;
4465
writeFileSync(networkConfigPath(dir), tomlStringify(doc));
4566
}

0 commit comments

Comments
 (0)