This repository was archived by the owner on Jul 24, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlaunch.ts
More file actions
545 lines (496 loc) · 31.1 KB
/
Copy pathlaunch.ts
File metadata and controls
545 lines (496 loc) · 31.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
// Native launch — convoy writes ALL agent wiring itself. No `st launch` shell, no `cmdLaunch` import
// (both are being deleted). This reimplements what `st launch` wrote (captured from st 2026-07-07):
// pty.toml, the claude session command (cold-start boot prompt), PERSONA.md / DING-BUS.md / CLAUDE.md,
// the Claude Code hooks, and the `st ding` sidecar. smalltalk keeps ONLY: the bus (`st`), the `st ding`
// binary (spawned as a command, not imported), and the hook SCRIPTS (referenced by path).
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { delimiter, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as tomlParse, stringify as tomlStringify } from "smol-toml";
import { spawnFromPtyFile } from "./host.ts";
import { ensureInstalled } from "./personas.ts";
import { busAgentId, resolvedPersonaPath, sessionId, specPermanent, specPermissionMode, type AgentSpec } from "./agent-spec.ts";
import { harnessDescriptor, type Harness } from "./harness.ts";
import type { PermissionMode, Role } from "./role.ts";
import { pretrustDir, pretrustDirsCodex } from "./trust.ts";
import { CONVOY_DIR, networkLayout, stRootOf } from "./paths.ts";
import { readNetworkConfig, type DingService } from "./network-config.ts";
import { counterContextRefusal } from "./identity.ts";
/** A harness without MCP always runs the ding sidecar; one with MCP honors its declared transport. */
function usesDing(spec: AgentSpec): boolean {
return !harnessDescriptor(spec.harness).supportsMcp || spec.transport === "ding";
}
// The initial boot-ritual PROMPT handed to claude as its first arg — a COLD start. A real prompt
// actually processes on launch and triggers the agent's boot (set available, drain inbox); a blank
// auto-poker just left queued input. Keep these single-quote-safe (no apostrophes) — the launch
// command wraps them in single quotes for `sh -c`.
const BOOT_PROMPT_WORKER =
"You just cold-started. Run your boot ritual now: set your st status to available and drain your inbox (read, act on, and archive each message). Then stand by for work delivered via ding.";
const BOOT_PROMPT_COS =
"You just cold-started. Run your boot ritual (set status available, drain inbox). If this is a fresh network with no populated private cos repo, run your first-run interview now, per your persona.";
/** The CoS gets the first-run-interview variant; every other role gets the worker boot. */
export function bootPrompt(role: Role): string {
return role === "chief-of-staff" ? BOOT_PROMPT_COS : BOOT_PROMPT_WORKER;
}
/** Find an executable by name on PATH (sync, no subprocess), or null. */
function whichSync(cmd: string): string | null {
for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
if (!dir) continue;
try {
const p = join(dir, cmd);
if (statSync(p).isFile()) return p;
} catch {
// not in this dir
}
}
return null;
}
/** Does this dir hold smalltalk's Claude Code hook scripts? */
function hasHooks(dir: string): boolean {
return existsSync(join(dir, "examples", "claude-code", "hooks", "session-start.sh"));
}
/** The smalltalk repo that owns an `st` binary path: `<smalltalk>/bin/st` → its resolved grandparent. Null
* if the path is empty or an unresolvable symlink. Shared by the ST_BIN + `which st` discovery candidates. */
function smalltalkFromStBinary(stBin: string): string | null {
if (!stBin) return null;
try {
return dirname(dirname(realpathSync(stBin))); // <smalltalk>/bin/st → <smalltalk>
} catch {
return null; // unresolvable symlink / missing path
}
}
/** Discover the smalltalk repo (for its hook scripts + the `st` binary the hooks invoke), tolerating a
* fresh install with NO `SMALLTALK_DIR` set. Order: (1) `SMALLTALK_DIR` env; (2) `ST_BIN` env — the
* ABSOLUTE `st` path baked into each agent's hook commands, so it works even when `st` is NOT on PATH
* (Johannes's box: the hooks run via ST_BIN, not PATH — the old check false-failed there); (3) the `st`
* binary on PATH (`<smalltalk>/bin/st` → grandparent); (4) the sibling `../smalltalk` dev checkout.
* Returns the first candidate that actually contains the hooks, else null. */
export function discoverSmalltalkDir(): string | null {
const candidates: string[] = [];
const env = process.env["SMALLTALK_DIR"];
if (env) candidates.push(env);
const fromStBin = smalltalkFromStBinary(process.env["ST_BIN"] ?? ""); // the hooks bake ST_BIN — usable off-PATH
if (fromStBin) candidates.push(fromStBin);
const st = whichSync("st");
if (st) {
const fromPath = smalltalkFromStBinary(st);
if (fromPath) candidates.push(fromPath);
}
candidates.push(join(dirname(dirname(fileURLToPath(import.meta.url))), "..", "smalltalk"));
for (const c of candidates) if (hasHooks(c)) return c;
return null;
}
/** Resolve the hook scripts + the st binary the hooks invoke. Throws loud (naming every discovery path)
* if smalltalk can't be found — so a fresh install fails clearly, not silently at spawn. */
function hookRefs(): { stBin: string; sessionStart: string; preCompact: string; stopFailure: string } {
const root = discoverSmalltalkDir();
if (root === null) {
throw new Error(
"smalltalk hook scripts not found. convoy looks via SMALLTALK_DIR, the `st` binary on PATH " +
"(<smalltalk>/bin/st), and the sibling ../smalltalk. Set SMALLTALK_DIR to your smalltalk clone, " +
"or make sure `st` is on PATH.",
);
}
const hooks = join(root, "examples", "claude-code", "hooks");
return {
stBin: join(root, "bin", "st"),
sessionStart: join(hooks, "session-start.sh"),
preCompact: join(hooks, "pre-compact.sh"),
stopFailure: join(hooks, "stop-failure.sh"),
};
}
/** The agent's main harness session command: a COLD start — no auto-poker — handing the harness the
* INITIAL BOOT-RITUAL PROMPT as its first arg (single-quoted so `sh -c` passes it as one argument).
* claude gates on `--permission-mode`; codex, which runs unattended like every other agent, bypasses
* approvals + sandbox (the parallel to claude's bypass posture). `model` (null → the harness default,
* today's behavior) adds `--model '<id>'` — single-quoted for `sh -c`, and the id is charset-validated
* upstream (isValidModel) so the quotes can't be broken out of. Both harnesses accept `--model`. */
export function harnessCommand(
harness: Harness,
permissionMode: string,
prompt: string,
model?: string | null,
bin?: string | null,
): string {
// `bin` replaces ONLY the binary name; every derived flag still applies, so a wrapper inherits the
// correct-by-construction wiring instead of having to re-derive it (decision 0005). Charset-validated
// upstream (isValidBin) because it lands unquoted in the `sh -c` string.
const cmd = bin || harness;
// The flag SHAPE comes from the harness table, so each harness's real CLI is encoded in one reviewable
// place instead of an if/else whose `else` silently means "claude".
const tail = harnessDescriptor(harness).argv({
permissionMode: permissionMode as PermissionMode,
model: model ?? null,
prompt,
});
return `exec ${cmd}${tail}`;
}
/** The ding-sidecar BINARY for a network's chosen ding service (see network-config.ts `DingService`):
* smalltalk's node `st ding` (default, unset → this) or the rust `ding` (compoundingtech/ding — full
* `st ding` parity, ~0% CPU). Both take IDENTICAL args, so the selector swaps only this prefix. */
export function dingBin(service?: DingService): string {
return service === "rust" ? "ding" : "st ding";
}
/** The ding sidecar command — pokes the agent's claude session when its bus inbox gets mail. Points at
* the stable session id (`<ding-bin> <prefix.agentShort> --identity <bus-id>`). The ding binary is the
* network's chosen ding service (`dingBin`); default node `st ding`. When a network `root` is given we
* bake `--root <net>` (smalltalk #85) into the command line — NOT just the env — so a `pty restart`
* (which replays the stored command) can never drop it and silently fall back to st's install-default
* root (the fleet phantom-poke/non-delivery bug). The rust ding honors `--root` identically. */
export function dingCommand(busId: string, claudeSessionId: string, root?: string | null, service?: DingService): string {
const rootFlag = root ? ` --root ${root}` : "";
return `${dingBin(service)} ${claudeSessionId} --identity ${busId}${rootFlag}`;
}
/** Provision the agent's DURABLE CONTEXT dir (`<member>/context/`) — unless the identity is a counter.
*
* `context/now.md` is the memory a cold-booted agent reconstructs itself from, so it is only safe when
* the name that addresses it means ONE agent for as long as the file exists. A `<role>-<n>` counter does
* not: it re-derives per parent lifetime, so after a restart `worker-2` names a different agent and would
* read its predecessor's now.md as its own memory — and act on it. That failure is silent and arrives
* weeks after the naming choice, which is exactly the kind a warning does not prevent.
*
* So the dir is REFUSED rather than warned about, and the refusal is narrow: the agent still launches and
* still gets a bus folder — convoy just does not hand it a pre-made place to keep memory.
*
* This is a strong DEFAULT, not an invariant. The dir is not convoy's to withhold: `st context write`
* creates it unconditionally, so an agent that externalizes work state makes its own. What this removes
* is the case where a renumbered agent finds one already waiting for it. Real enforcement belongs where
* the dir is created (the bus) — see context/.delta/DELTA-005. Returns the refusal message, else null. */
export function provisionContext(memberDir: string, identity: string): string | null {
const refusal = counterContextRefusal(identity);
if (refusal !== null) {
process.stderr.write(`convoy: ${refusal}\n`);
return refusal;
}
mkdirSync(join(memberDir, "context", "decisions"), { recursive: true });
return null;
}
/** Serialize the per-agent pty.toml (pty's manifest format — NOT a convoy.toml). Pins the session ids
* to `<prefix>.<agentShort>` (claude) and `<prefix>.<agentShort>.ding` so the ding + name refs stay
* stable across respawns; prefix defaults to the short hostname. `opts.spawner` (the bus id of whoever ran
* `convoy add`, from their ST_AGENT) is stamped on the HARNESS session so a crash-ding reaches this agent's
* ACTUAL supervisor, not the whole permanent crew (see up.ts crashDingTargets). */
export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: string | null }): void {
const busId = busAgentId(spec); // the host-prefixed bus identity, e.g. silber.convoy-claude
const root = spec.networkRoot; // the network DIR; ST_ROOT is <root>/smalltalk (the bus), PTY_ROOT is <root>/pty
// The ding SERVICE is a per-network choice, recorded in <net>/convoy.toml (unset → node `st ding`). Read
// it here so the sidecar command baked into the pty.toml is the network's chosen ding (node or rust).
const dingService = root ? readNetworkConfig(root)?.ding : undefined;
const harnessId = sessionId(spec); // e.g. silber.convoy (agentShort strips the -claude/-codex suffix)
const dingId = `${harnessId}.ding`; // e.g. silber.convoy.ding
const permanent = specPermanent(spec);
const stTag = root ? { "st.network": root } : {};
// Crash-ding targeting tags (read by up.ts crashDingTargets), HARNESS session only — never the ding sidecar
// (else a crash double-dings the same busId): `convoy.tier=cos` marks the CoS (the always-ding backstop);
// `convoy.spawner` records who spawned this agent (its supervisor) so a worker crash pages the parent.
const agentTags: Record<string, string> = {
...(spec.role === "chief-of-staff" ? { "convoy.tier": "cos" } : {}),
...(opts?.spawner ? { "convoy.spawner": opts.spawner } : {}),
};
const env: Record<string, string> = { ST_AGENT: busId };
if (root) {
env["ST_ROOT"] = stRootOf(root); // the bus root is <net>/smalltalk, NOT the network dir
env["PTY_ROOT"] = `${root}/pty`;
}
// The config-relocation var moves the harness's WHOLE config (auth/settings/skills) — harness session
// only, never the ding sidecar (which is just `st ding` and doesn't read it). Which var that is comes
// from the harness table: CLAUDE_CONFIG_DIR for claude, CODEX_HOME for codex. Before, this was
// hardcoded to CLAUDE_CONFIG_DIR, so `--config-dir` on a codex session set a variable codex does not
// read — the flag reported success and selected nothing.
// Spec `env` first, derived wiring LAST: ST_AGENT/ST_ROOT/PTY_ROOT are correct-by-construction (AC-1)
// and a hand-written env key must never be able to repoint the agent at another bus.
const specEnv = spec.env ?? {};
const configEnv = harnessDescriptor(spec.harness).configEnv;
const harnessEnv = { ...specEnv, ...env, ...(spec.configDir && configEnv ? { [configEnv]: spec.configDir } : {}) };
const doc: Record<string, unknown> = {
prefix: harnessId,
sessions: {
[harnessDescriptor(spec.harness).sessionKey]: {
id: harnessId,
command: harnessCommand(spec.harness, specPermissionMode(spec), bootPrompt(spec.role), spec.model, spec.bin),
tags: { role: "agent", ...(permanent ? { strategy: "permanent" } : {}), ...stTag, ...agentTags },
env: harnessEnv,
},
...(usesDing(spec)
? {
ding: {
id: dingId,
command: dingCommand(busId, harnessId, root ? stRootOf(root) : null, dingService),
tags: { role: "ding", ...(permanent ? { strategy: "permanent" } : {}), ...stTag },
env,
},
}
: {}),
},
};
const convoyDir = join(dir, CONVOY_DIR);
mkdirSync(convoyDir, { recursive: true });
writeFileSync(join(convoyDir, "pty.toml"), tomlStringify(doc));
excludeFromGit(dir, [`${CONVOY_DIR}/`]); // the whole convoy-authored overlay stays out of git status
}
/** Heal a PRE-#43 pty.toml so its ding survives a `pty restart` (which preserves the command but drops
* the env — the actual ST_ROOT durability engine). Rewrites ONLY the `[sessions.ding]` command to carry
* `--root <net>` (via `dingCommand`), leaving the `[sessions.claude]` harness block — the role boot
* prompt + `--resume` uuid, which are NOT structurally recoverable — VERBATIM. Idempotent (a no-op once
* `--root` is present) and surgical (a literal replace of the ding command string; the rest of the file
* is byte-for-byte untouched). Returns the before/after ding command, or null if nothing changed / there
* is no ding / no root is known. `dryRun` computes the diff without writing. This is convoy#43's
* cold-start counterpart: #43 fixed writePtyToml for NEW agents; this heals the existing FILES so a
* `convoy reload` / cold `convoy up` re-materializes a durable ding instead of an env-only one. */
export function regenerateDingRoot(dir: string, opts?: { dryRun?: boolean }): { before: string; after: string } | null {
const path = join(dir, CONVOY_DIR, "pty.toml"); // `dir` is the workspace; the manifest lives in .convoy/
const text = readFileSync(path, "utf8");
const doc = tomlParse(text) as { sessions?: { ding?: { command?: unknown; env?: Record<string, string>; tags?: Record<string, string> } } };
const ding = doc.sessions?.ding;
if (!ding || typeof ding.command !== "string") return null;
const before = ding.command;
const m = before.match(/st ding (\S+) --identity (\S+)/); // target + bus id, robust to a leading inline env or trailing --root
if (!m) return null;
const [, target, busId] = m;
const net = ding.env?.["ST_ROOT"] ?? ding.tags?.["st.network"];
if (!target || !busId || !net) return null; // incomplete — don't emit a bare/guessed --root
const after = dingCommand(busId, target, net);
if (after === before) return null; // already durable (has --root) — idempotent
const updated = text.replace(`command = "${before}"`, `command = "${after}"`);
if (updated === text) return null; // literal command not found verbatim — refuse to half-write
if (!opts?.dryRun) writeFileSync(path, updated);
return { before, after };
}
/** The Claude Code hooks (SessionStart boot-ritual, PreCompact flush, StopFailure ding) — reference
* smalltalk's kept hook scripts by path. Reproduces st launch's settings.local.json. */
function writeHooks(dir: string): void {
const h = hookRefs();
const cmd = (script: string): string => `ST_BIN=${h.stBin} ${script}`;
const settings = {
$schema: "https://json.schemastore.org/claude-code-settings.json",
hooks: {
SessionStart: [{ hooks: [{ type: "command", async: true, asyncRewake: true, command: cmd(h.sessionStart) }] }],
PreCompact: [{ hooks: [{ type: "command", command: cmd(h.preCompact) }] }],
StopFailure: [{ hooks: [{ type: "command", command: cmd(h.stopFailure) }] }],
},
};
mkdirSync(join(dir, ".claude"), { recursive: true });
writeFileSync(join(dir, ".claude", "settings.local.json"), `${JSON.stringify(settings, null, 2)}\n`);
// convoy-authored — keep it out of the composed repo's git status. Belt-and-suspenders + host-
// independent: some machines have a global gitignore for settings.local.json, most don't.
excludeFromGit(dir, [".claude/settings.local.json"]);
}
/** Add `names` to a repo's git exclude (itself untracked) so the convoy-authored context files never show
* up in `git status` — convoy must not leave a repo it composes into dirty. Idempotent (skips names
* already listed) and best-effort: silently no-ops when `dir` isn't a git repo. NOT a tracked `.gitignore`
* (that would itself dirty the tree) and NOT a behavior change for a repo that legitimately tracks these
* files (an exclude entry for an already-tracked path is a git no-op). Uses `git rev-parse --git-path
* info/exclude` so it resolves the RIGHT file for any layout — a plain repo, a WORKTREE (the shared
* `<common>/.git/info/exclude`, which git actually reads for worktrees), or a submodule. */
function excludeFromGit(dir: string, names: string[]): void {
if (names.length === 0) return;
const gitPath = join(dir, ".git");
let commonGitDir: string;
try {
if (statSync(gitPath).isDirectory()) {
commonGitDir = gitPath; // a plain repo
} else {
// a WORKTREE: `.git` is a file (`gitdir: <path>`); git reads the exclude from the SHARED common
// dir, NOT the per-worktree gitdir — resolve it via the gitdir's `commondir` pointer.
const m = readFileSync(gitPath, "utf8").match(/^gitdir:\s*(.+)$/m);
if (!m || !m[1]) return;
const gitDir = resolve(dir, m[1].trim());
let common = gitDir;
try {
common = resolve(gitDir, readFileSync(join(gitDir, "commondir"), "utf8").trim());
} catch {
// no commondir → the gitdir IS the common dir
}
commonGitDir = common;
}
} catch {
return; // not a git repo — nothing to exclude
}
const infoDir = join(commonGitDir, "info");
const excludePath = join(infoDir, "exclude");
let existing = "";
try {
existing = readFileSync(excludePath, "utf8");
} catch {
// no exclude file yet — we'll create it
}
const lines = new Set(existing.split("\n").map((l) => l.trim()));
const missing = names.filter((n) => !lines.has(n));
if (missing.length === 0) return;
const marker = "# convoy: agent context (local, not committed)";
const block = (lines.has(marker) ? "" : `${marker}\n`) + missing.join("\n") + "\n";
const sep = existing && !existing.endsWith("\n") ? "\n" : "";
mkdirSync(infoDir, { recursive: true });
writeFileSync(excludePath, `${existing}${sep}${block}`);
}
/** Install the persona + ding-bus instructions into the workspace's `.convoy/` overlay dir (moved OUT
* of the repo root so the product repo stays pristine), and wire their `@`-imports through a loader
* Claude Code auto-loads. Loader = `.claude/rules/convoy.md` (cos/Nathan decision): Claude auto-loads
* `.claude/rules/*.md` (empirically verified), it's a DISTINCT file so it never clobbers a repo's own
* `.claude/CLAUDE.md` or rules, and it `@`-imports the `.convoy/` content (path is relative to the
* rules file: `../../.convoy/…`). Result: the workspace ROOT has ZERO visible convoy files — only the
* `.claude/` + `.convoy/` dot-dirs, both git-excluded. Append-only (never clobber a user's file). */
export function writeContextFiles(dir: string, spec: AgentSpec): void {
const convoyDir = join(dir, CONVOY_DIR);
const imports: string[] = [];
const persona = resolvedPersonaPath(spec);
if (persona && existsSync(persona)) {
mkdirSync(convoyDir, { recursive: true });
writeFileSync(join(convoyDir, "PERSONA.md"), readFileSync(persona, "utf8"));
imports.push(`@../../${CONVOY_DIR}/PERSONA.md`);
}
if (usesDing(spec)) {
mkdirSync(convoyDir, { recursive: true });
writeFileSync(join(convoyDir, "DING-BUS.md"), DING_BUS_MD);
imports.push(`@../../${CONVOY_DIR}/DING-BUS.md`);
}
// Loader: .claude/rules/convoy.md — @imports the .convoy/ content. Append-only + no-double-load.
const rulesFile = join(dir, ".claude", "rules", "convoy.md");
const existing = existsSync(rulesFile) ? readFileSync(rulesFile, "utf8") : "";
const missing = imports.filter((i) => !existing.includes(i));
if (missing.length > 0) {
mkdirSync(dirname(rulesFile), { recursive: true });
const sep = existing && !existing.endsWith("\n") ? "\n" : "";
writeFileSync(rulesFile, `${existing}${sep}${missing.join("\n")}\n`);
}
// Keep the whole .convoy/ overlay + the loader (a distinct file, NOT the repo's own rules) out of git.
excludeFromGit(dir, [`${CONVOY_DIR}/`, ".claude/rules/convoy.md"]);
}
/** Write ALL of convoy's agent wiring into the repo `dir` — the persona/ding context files
* (PERSONA.md, DING-BUS.md, CLAUDE.local.md), the Claude Code hooks (.claude/settings.local.json),
* and pty.toml — each of which self-excludes itself from `git status` via .git/info/exclude so convoy
* never dirties a repo it composes into. Extracted from nativeLaunch so the clean-worktree guarantee
* is unit-testable without spawning. Composing this into a clean repo leaves `git status` empty. */
export function writeAgentFiles(dir: string, spec: AgentSpec): void {
writeContextFiles(dir, spec);
writeHooks(dir);
// The spawner = the agent's DECLARED supervisor (from its agent file), else whoever launched it (their bus
// id, from ST_AGENT) — stamped as `convoy.spawner` so a crash-ding reaches this agent's actual supervisor,
// not the whole permanent crew. The declared supervisor is authoritative: in the DECLARATIVE flow `convoy
// up` (the host) does the launch, so ST_AGENT is the HOST, not the parent — falling back to it there is
// exactly what dropped the supervisor-ding (a worker crash then only reached the CoS backstop). ST_AGENT
// stays the fallback for the imperative `convoy run` path (no declared supervisor). Null → cos-only ding.
writePtyToml(dir, spec, { spawner: spec.supervisor ?? process.env["ST_AGENT"] ?? null });
}
/**
* Native launch: write all wiring + spawn the agent's sessions. Replaces the `st launch --fresh`
* shell + spawnFromPtyFile stopgap. Returns the spawned pty session names.
*/
export async function nativeLaunch(spec: AgentSpec): Promise<{ spawned: string[]; failed: string[] }> {
const dir = spec.workingDir ?? process.cwd();
mkdirSync(dir, { recursive: true });
// Pre-trust the agent's repo folder so its cold-started harness never hits the workspace-trust dialog
// (nothing clears it now that the launch command has no auto-poker). Harness-specific: claude checks
// ~/.claude.json, codex checks ~/.codex/config.toml (its --dangerously-bypass flag does NOT skip the
// directory-trust prompt). Best-effort — never blocks launch.
// Trust must be seeded in the config the harness will ACTUALLY read. When `configDir` relocates that
// config, seeding the ambient one leaves the agent to hit the trust dialog on a cold boot — and codex's
// --dangerously-bypass flag does NOT skip it, so the session simply stalls with nobody watching.
if (spec.harness === "codex") pretrustDirsCodex([dir], spec.configDir ?? undefined);
else if (spec.harness === "claude") pretrustDir(dir, spec.configDir ?? undefined);
// opencode / pi seed no trust: convoy has no verified trust-store shape for them (see harness table).
// Footgun-proof: clone role personas if missing (no override).
if (spec.personaOverride === null) {
try {
await ensureInstalled();
} catch {
// non-fatal: writeContextFiles just skips the persona import if it can't resolve
}
}
writeAgentFiles(dir, spec);
// Create the agent's bus member folder BEFORE spawning `st ding`. The ding watcher errors at startup
// if `$ST_ROOT/<agent>/{inbox,archive}` is missing → the worker is never poked → it parks. `st launch`
// used to create these; convoy owns that wiring now, so getting the ORDER right (folder → then ding)
// kills the race at the source. The folder is `<net>/smalltalk/<host>.<identity>/` (ST_ROOT is the
// smalltalk/ subdir; the agent is the host-prefixed bus id). (smalltalk's `st ding mkdir -p` is
// defense-in-depth on top of this.)
if (spec.networkRoot) {
const member = join(stRootOf(spec.networkRoot), busAgentId(spec));
mkdirSync(join(member, "inbox"), { recursive: true });
mkdirSync(join(member, "archive"), { recursive: true });
provisionContext(member, spec.identity);
// Give the agent's workspace a home under the network's worktrees/ — a single view of everything the
// network is working on. With no megarepo, that's a SYMLINK to the agent's repo. Best-effort +
// idempotent (replace a stale link). (Megarepo worktree-cutting is a follow-up.)
if (spec.workingDir) {
const link = join(networkLayout(spec.networkRoot).worktrees, spec.identity);
// Only symlink when the workspace lives ELSEWHERE (no-megarepo case). When the workspace already IS
// the worktree (megarepo model cut it here), skip — else rmSync would delete the worktree.
if (resolve(spec.workingDir) !== resolve(link)) {
try {
mkdirSync(dirname(link), { recursive: true });
rmSync(link, { force: true });
symlinkSync(spec.workingDir, link);
} catch {
// non-fatal — the visibility symlink never blocks a launch
}
}
}
}
return spawnFromPtyFile(dir, spec.networkRoot);
}
// The ding-mode bus instructions installed into each ding agent's dir. Vendored from smalltalk's
// st-launch template (captured 2026-07-07), with the "spawn children" section updated: children are
// now added via `convoy add` (st launch is gone).
const DING_BUS_MD = `# Ding-mode bus instructions
You are connected to smalltalk via ding-mode (no MCP). Bus ops go through the \`st\` CLI. **You will
NOT receive \`<channel>\` blocks — those are MCP-only.** Inbound messages arrive as \`[DING]\` pokes in
your terminal; confirm the actual message via \`st message ls\` + \`st message read\` before acting on a new
one (each poke carries a stable \`[id:<rand6>]\` so you can dedup re-pokes at a glance — see below).
## Boot ritual (on cold start or /clear)
1. \`st status $ST_AGENT --set available\` — set your status so peers see you as active.
2. Drain your inbox backlog: \`st message ls\` to enumerate filenames, then for each: \`st message read
<filename>\`, \`st message reply <filename> -m "<your reply>"\` if a response is warranted, and
\`st message archive <filename>\` to clear. Don't leave inbox items unaddressed.
3. \`st agents --json --enrich\` to see who's around and whether any peers are waiting on you.
## Resume safety — do NOT double-act (important for hosted/respawned agents)
The host (\`convoy up\`) respawns you on a COLD start (no \`--resume\` yet — restart context-preservation
is coming as separate hooks work), so a restart re-runs your boot ritual from scratch. The boot re-drain
(step 2) re-surfaces every inbox item you had not archived yet. If a
drained item is one your resumed context shows you ALREADY acted on — e.g. a delegation "kick" you
already delegated — **archive it WITHOUT re-acting.** Re-reading and re-delegating an already-processed
kick is a double-delegation bug.
Rule: **archive a message the moment you act on it** (not at the end of the task), so a mid-task restart
never leaves an acted-on item to be reprocessed. On resume, for each un-archived item ask "did I already
handle this?" first — only act on genuinely new ones.
## Inbound message handling ([DING] pokes)
New peer messages surface as \`[DING] new smalltalk message: [id:<rand6>] <subject> (from <sender>); check
your inbox\` lines. The \`[id:<rand6>]\` is the message filename's rand6 suffix — it is STABLE across re-pokes
of the SAME message, so you can dedup a re-poke AT A GLANCE: if the id matches one you have already
handled, it is a duplicate poke — skip it, no \`st message ls\` needed. Dedup on the \`[id:<rand6>]\`, NEVER
the subject line: the subject text is display-only and can show stale pixels from a pane-render overlap, so
a subject-based dedup could skip a real message wearing phantom pixels. For a NEW id: \`st message ls\` to
find the filename (it contains that rand6), \`st message read <filename>\`, \`st message reply <filename> -m
"<reply>"\` if warranted (recipient + threading are derived from the message), \`st message archive
<filename>\` to clear.
## Threads stay on the bus
A thread that originated from a \`[DING]\` poke or an inbox message is conversed ONLY via \`st message
send\` / \`st message reply\` — questions, blockers, "I think I'm done" signals, all of it. Your pty REPL
is unattended; your correspondent is your interlocutor. If you would pause to ask "should I do X?", send
it via \`st message reply\` instead. Only address the REPL when a human directly typed there.
## Spawning children — use \`convoy add\` (ding is the default)
This machine is ding-only. Spawn every child agent with convoy (NOT the removed \`st launch\`):
\`\`\`sh
convoy add <role> --identity <child-id> [--permanent] [--persona <path>]
\`\`\`
\`convoy add\` is ding-by-default and writes the child its own DING-BUS.md + CLAUDE.md + hooks, so the
ding contract propagates through every level of a cos → supervisor → worker tree. Pass \`--mcp\` only if
you explicitly want MCP (you don't, on this machine). Use \`convoy up <network>\` to host the network.
## CLI inventory
Bus ops:
- \`st message send <to> [-m <body>] [--subject S] [--in-reply-to F] [--tags T,T] [--priority P]\`
- \`st message reply <filename> -m <body> [--subject S]\`
- \`st message ls [<identity>] [--archive] [--count | --json] [--from ID]\`
- \`st message read [<identity>] <filename> [--raw | --json] [--archive]\`
- \`st message archive [<identity>] <filename>\`
- \`st message thread [<identity>] <filename> [--tree]\`
Peer discovery + state:
- \`st agents [--status STATE] [--json [--enrich]]\`
- \`st status [<identity>] [--set <state>]\`
Working state (lossless-restart):
- \`st context read [<identity>] [--decisions | --full]\`
- \`st context write [<identity>]\` (reads new content from stdin)
- \`st context append [<identity>] --decision "<text>" --why "<text>"\`
Spawning children: \`convoy add <role> --identity <id> [--permanent]\` (see above).
Every command supports \`--help\`.
`;