From 2879e7c0d30d079002ad1a6e89902d5cd11b6f76 Mon Sep 17 00:00:00 2001 From: Flint <263629284+tps-flint@users.noreply.github.com> Date: Sat, 16 May 2026 21:41:43 -0700 Subject: [PATCH] feat(branch): tps branch init --agent persists agentId in conf (ops-hs19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a --agent flag to `tps branch init` that persists the local agent identity in branch.conf.json. The branch daemon reads conf.agentId via the existing getLocalAgentId() precedence (TPS_AGENT_ID env → conf.agentId → hostname fragment) so subsequent `tps branch start` invocations pick up the right identity without needing a per-launch env var. ## Problem The branch daemon's incoming-mail handler routes by `body.to` if a maildir for that name already exists; else it falls back to `localAgentId`. When `localAgentId` is computed from `hostname()` (the default when neither TPS_AGENT_ID env nor conf.agentId is set), this produces an inconsistent first-message-fallback pattern: - First message for `body.to=reed` arrives → `~/.tps/mail/reed/` doesn't exist yet → falls back to `~/.tps/mail/tps-reed/` (the hostname). - The user runs `tps mail check reed` (creating `~/.tps/mail/reed/`). - Second message for `body.to=reed` arrives → `inboxExists("reed")` is now true → lands at `~/.tps/mail/reed/`. Result: two maildirs for the same logical agent, mail split across them, watcher consuming only one path. Surfaced live on tps-reed during Reed Phase 2 dogfood 2026-05-16. Filed as ops-hs19. ## Fix `tps branch init --agent reed --listen 33744 --host localhost --transport ws` writes `{agentId: "reed"}` to `branch.conf.json`. The daemon's `getLocalAgentId()` already reads conf.agentId at position 2 in its precedence chain, so no daemon-side code change is needed. The path is end-to-end consistent: first message and all subsequent messages route to `~/.tps/mail//`. ## Tests - New: `writeBranchConf persists agentId when --agent supplied` - New: `writeBranchConf omits agentId when not supplied (back-compat)` - Existing 5 branch tests pass. ## Migration for existing branch offices ```sh # On the VM: jq '. + {agentId: ""}' ~/.tps/branch.conf.json > /tmp/c.json \ && mv /tmp/c.json ~/.tps/branch.conf.json tps branch stop && tps branch start ``` Applied on tps-reed today; PING-PONG verified mail now lands at /reed/ consistently from message #1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- packages/cli/bin/tps.ts | 3 ++- packages/cli/src/commands/branch.ts | 15 +++++++++++---- packages/cli/test/branch.test.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/cli/bin/tps.ts b/packages/cli/bin/tps.ts index 0ea2f96..c2e33bf 100755 --- a/packages/cli/bin/tps.ts +++ b/packages/cli/bin/tps.ts @@ -843,7 +843,7 @@ async function main() { const action = rest[0] as "init" | "start" | "stop" | "status" | "log" | undefined; const valid = ["init", "start", "stop", "status", "log"]; if (!action || !valid.includes(action)) { - console.error("Usage:\n tps branch init [--listen ] [--host ] [--transport ws|tcp]\n tps branch start\n tps branch stop\n tps branch status\n tps branch log [--lines N] [--follow]"); + console.error("Usage:\n tps branch init [--listen ] [--host ] [--transport ws|tcp] [--agent ]\n tps branch start\n tps branch stop\n tps branch status\n tps branch log [--lines N] [--follow]"); process.exit(1); } const { runBranch } = await import("../src/commands/branch.js"); @@ -852,6 +852,7 @@ async function main() { port: typeof cli.flags.listen === "number" ? Number(cli.flags.listen) : undefined, host: cli.flags.host, transport: cli.flags.transport === "tcp" ? "tcp" : cli.flags.transport === "ws" ? "ws" : undefined, + agent: cli.flags.agent, force: cli.flags.force, lines: typeof cli.flags.lines === "number" ? Number(cli.flags.lines) : undefined, follow: !!cli.flags.follow, diff --git a/packages/cli/src/commands/branch.ts b/packages/cli/src/commands/branch.ts index d06178f..1786392 100644 --- a/packages/cli/src/commands/branch.ts +++ b/packages/cli/src/commands/branch.ts @@ -21,6 +21,7 @@ export interface BranchArgs { port?: number; host?: string; transport?: "ws" | "tcp"; + agent?: string; force?: boolean; lines?: number; follow?: boolean; @@ -63,7 +64,7 @@ function processAlive(pid: number): boolean { } } -function writeBranchConf(port: number, host: string, transport: "ws" | "tcp", agentsDir?: string): void { +export function writeBranchConf(port: number, host: string, transport: "ws" | "tcp", agentsDir?: string, agentId?: string): void { writeFileSync( confPath(), JSON.stringify( @@ -72,6 +73,7 @@ function writeBranchConf(port: number, host: string, transport: "ws" | "tcp", ag host, transport, agentsDir, + agentId, createdAt: new Date().toISOString(), }, null, @@ -81,7 +83,7 @@ function writeBranchConf(port: number, host: string, transport: "ws" | "tcp", ag ); } -function readBranchConf(): { port: number; host: string; transport: "ws" | "tcp"; agentsDir?: string } { +function readBranchConf(): { port: number; host: string; transport: "ws" | "tcp"; agentsDir?: string; agentId?: string } { const p = confPath(); if (!existsSync(p)) throw new Error("branch.conf.json not found. Run `tps branch init` first."); const raw = JSON.parse(readFileSync(p, "utf-8")); @@ -90,7 +92,8 @@ function readBranchConf(): { port: number; host: string; transport: "ws" | "tcp" port: Number(raw.port), host: String(raw.host || ""), transport: t, - agentsDir: raw.agentsDir ? String(raw.agentsDir) : undefined + agentsDir: raw.agentsDir ? String(raw.agentsDir) : undefined, + agentId: raw.agentId ? String(raw.agentId) : undefined }; } @@ -125,7 +128,11 @@ async function runInit(args: BranchArgs): Promise { const port = args.port ?? 6458; const advertiseHost = args.host ?? hostname(); const transport = args.transport ?? "ws"; - writeBranchConf(port, advertiseHost, transport, undefined); + // agentId resolves the maildir for branch-delivered messages. Default to + // the value at runBranch start time (TPS_AGENT_ID env → conf.agentId → + // hostname). Persisting at init time lets `tps branch start` pick it up + // without re-setting TPS_AGENT_ID in the environment. + writeBranchConf(port, advertiseHost, transport, undefined, args.agent); const pubkeyB64 = Buffer.from(kp.encryption.publicKey).toString("base64url"); const sigPubkeyB64 = Buffer.from(kp.signing.publicKey).toString("base64url"); diff --git a/packages/cli/test/branch.test.ts b/packages/cli/test/branch.test.ts index 34bc71c..b832eb3 100644 --- a/packages/cli/test/branch.test.ts +++ b/packages/cli/test/branch.test.ts @@ -2,7 +2,8 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { buildJoinToken, isAlreadyJoined } from "../src/commands/branch.js"; +import { readFileSync } from "node:fs"; +import { buildJoinToken, isAlreadyJoined, writeBranchConf } from "../src/commands/branch.js"; describe("tps branch init", () => { let tmpDir: string; @@ -57,6 +58,32 @@ describe("tps branch init", () => { writeFileSync(join(dir, "host.json"), JSON.stringify({ hostId: "existing" })); expect(isAlreadyJoined(dir)).toBe(true); }); + + test("writeBranchConf persists agentId when --agent supplied (ops-hs19)", () => { + process.env.TPS_ROOT = tmpDir; + try { + writeBranchConf(33744, "localhost", "ws", undefined, "reed"); + const conf = JSON.parse(readFileSync(join(tmpDir, "branch.conf.json"), "utf-8")); + expect(conf.agentId).toBe("reed"); + expect(conf.port).toBe(33744); + expect(conf.host).toBe("localhost"); + expect(conf.transport).toBe("ws"); + } finally { + delete process.env.TPS_ROOT; + } + }); + + test("writeBranchConf omits agentId when not supplied (back-compat)", () => { + process.env.TPS_ROOT = tmpDir; + try { + writeBranchConf(6458, "tps-host", "ws"); + const conf = JSON.parse(readFileSync(join(tmpDir, "branch.conf.json"), "utf-8")); + expect(conf.agentId).toBeUndefined(); + expect(conf.port).toBe(6458); + } finally { + delete process.env.TPS_ROOT; + } + }); }); describe("MSG_JOIN_COMPLETE", () => {