From d1fb6bf7fc6967ef2c05ee038909e107b84fa35b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rog=C3=A9rio=20Chaves?= Date: Sat, 13 Jun 2026 17:01:44 +0200 Subject: [PATCH 1/4] fix(cli): delete a channel's history log on channel delete channel delete removed the metadata but left the append-only .jsonl log, so a channel re-created with the same name replayed stale history. Remove the log too; covers every delete path (CLI, reset tooling, room teardown). --- cli/src/channels.test.ts | 9 ++++++++- cli/src/channels.ts | 4 ++++ cli/src/kanban.ts | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cli/src/channels.test.ts b/cli/src/channels.test.ts index 4532476e..5f5b2807 100644 --- a/cli/src/channels.test.ts +++ b/cli/src/channels.test.ts @@ -107,10 +107,17 @@ describe("channel CRUD", () => { assert.equal(getChannel("nonexistent", base), undefined); }); - test("deleteChannel removes metadata", () => { + test("deleteChannel removes metadata and the history log", () => { createChannel("general", {}, base); + sendMessage("general", { cardId: null, handle: "me" }, "hi", base); + const log = channelLogPath("general", base); + assert.ok(existsSync(log)); + assert.equal(deleteChannel("general", base), true); assert.equal(listChannels(base).length, 0); + // The log is gone, so a channel re-created with the same name starts fresh + // instead of replaying stale history. + assert.ok(!existsSync(log)); assert.equal(deleteChannel("general", base), false); }); diff --git a/cli/src/channels.ts b/cli/src/channels.ts index 2fd36451..6ddab48e 100644 --- a/cli/src/channels.ts +++ b/cli/src/channels.ts @@ -232,6 +232,10 @@ export function deleteChannel(name: string, baseDir: string = defaultBaseDir()): file.channels = file.channels.filter((c) => c.name !== clean); if (file.channels.length === before) return false; saveChannelsFile(file, baseDir); + // Remove the append-only log too. Otherwise a channel re-created with the + // same name re-attaches to the old log and replays stale history. + const logPath = channelLogPath(clean, baseDir); + if (existsSync(logPath)) unlinkSync(logPath); return true; } diff --git a/cli/src/kanban.ts b/cli/src/kanban.ts index 5a9a5a0e..a7df636b 100644 --- a/cli/src/kanban.ts +++ b/cli/src/kanban.ts @@ -1176,7 +1176,7 @@ channelCmd channelCmd .command("delete") - .description("Delete a channel (does not delete history file)") + .description("Delete a channel and its history log") .argument("", "Channel name") .option("-j, --json", "Output as JSON") .action((name: string, opts) => { From 81337642600d076246f5b5206d9624eb7db25b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rog=C3=A9rio=20Chaves?= Date: Sat, 13 Jun 2026 18:18:07 +0200 Subject: [PATCH 2/4] feat(cli): kanban launch --no-resume for fresh ephemeral agents Recycled readable slugs (a room's swarm reusing names like dax/enzo) share a deterministic session id via uuidv5(slug), so the default resume reloads a stale or unrelated prior conversation under that id (or dies with 'No conversation found'). --no-resume forces a fresh session regardless, via a forceFresh LaunchOption that gates the resume check. --- cli/src/agents/launch.ts | 5 +++++ cli/src/kanban.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/cli/src/agents/launch.ts b/cli/src/agents/launch.ts index 68ebc76d..d15a8a2c 100644 --- a/cli/src/agents/launch.ts +++ b/cli/src/agents/launch.ts @@ -24,6 +24,10 @@ export interface LaunchOptions { skipPermissions?: boolean; /// Override the agent binary (tests). bin?: string; + /// Force a fresh session even if a prior one could be resumed. For ephemeral + /// agents (e.g. a room's swarm) whose readable slug is recycled: resuming + /// would reload a stale or unrelated conversation under the same id. + forceFresh?: boolean; } export type LaunchAction = "noop-running" | "launched" | "resumed"; @@ -65,6 +69,7 @@ export function ensureAgentSession( const tmuxAlive = hasTmuxSession(identity.tmuxName); const sessionExists = spec.canResume && + !opts.forceFresh && (identity.runtime === "codex" ? !!findCodexRollout(opts.cwd) : !!findSessionJsonl(identity.sessionId)); diff --git a/cli/src/kanban.ts b/cli/src/kanban.ts index a7df636b..21d2b42e 100644 --- a/cli/src/kanban.ts +++ b/cli/src/kanban.ts @@ -358,6 +358,7 @@ program .requiredOption("--cwd ", "Working directory for the session (the agent's worktree/workspace)") .option("--model ", "Model alias or full name") .option("--no-skip-permissions", "Do NOT pass --dangerously-skip-permissions") + .option("--no-resume", "Always start a fresh session, never resume a prior one under this slug (for ephemeral agents)") .option("-j, --json", "Output as JSON") .action((slug: string, opts) => { try { @@ -368,6 +369,7 @@ program cwd, model: opts.model, skipPermissions: opts.skipPermissions, + forceFresh: opts.resume === false, }); if (opts.json) { output(result, opts); From 2775ef7b1f87a465c12807036407c370ddb750c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rog=C3=A9rio=20Chaves?= Date: Sat, 13 Jun 2026 18:40:29 +0200 Subject: [PATCH 3/4] fix(cli): forceFresh mints a unique session id, not uuidv5(slug) --no-resume alone still passed claude --session-id , which collides with a recycled slug's prior transcript: claude errors 'Session ID already in use' and the card<->session link breaks. Under forceFresh, mint a random session id for the launch (stable readable tmux name preserved) so an ephemeral recycled-slug agent always starts cleanly and is deliverable. --- cli/src/agents/launch.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/cli/src/agents/launch.ts b/cli/src/agents/launch.ts index d15a8a2c..d0698f88 100644 --- a/cli/src/agents/launch.ts +++ b/cli/src/agents/launch.ts @@ -10,6 +10,7 @@ import { upsertCard, isoNow } from "../cards.js"; import { generateKsuid } from "../ksuid.js"; import { Link, ManualOverrides } from "../types.js"; import { runtimeSpec } from "./runtime.js"; +import { randomUUID } from "node:crypto"; export interface LaunchOptions { /// Working directory for the session (the agent's workspace / worktree root). @@ -74,6 +75,16 @@ export function ensureAgentSession( ? !!findCodexRollout(opts.cwd) : !!findSessionJsonl(identity.sessionId)); + // A forced-fresh ephemeral launch must NOT reuse the stable uuidv5(slug) + // session id: a recycled slug collides with its own prior transcript, so + // `claude --session-id ` without --resume errors "Session ID + // already in use" and the card<->session link breaks. Mint a unique id for + // that launch so it starts cleanly. The readable tmux name stays stable. + const launchIdentity: AgentIdentity = + opts.forceFresh && !tmuxAlive + ? { ...identity, sessionId: randomUUID() } + : identity; + let action: LaunchAction; let command: string | undefined; @@ -81,8 +92,8 @@ export function ensureAgentSession( action = "noop-running"; } else { const args = spec.buildArgs({ - sessionId: identity.sessionId, - slug: identity.slug, + sessionId: launchIdentity.sessionId, + slug: launchIdentity.slug, resume: sessionExists, skipPermissions: skipPerms, model: opts.model, @@ -94,19 +105,19 @@ export function ensureAgentSession( // Both runtimes' hooks correlate events to this agent via this env var, so // the daemon/bridge key on our stable session id regardless of the id the // runtime mints internally. - const env = { ...(opts.env ?? {}), KANBAN_SESSION_ID: identity.sessionId, KANBAN_SLUG: identity.slug }; - const res = createTmuxSession(identity.tmuxName, opts.cwd, command, env); + const env = { ...(opts.env ?? {}), KANBAN_SESSION_ID: launchIdentity.sessionId, KANBAN_SLUG: launchIdentity.slug }; + const res = createTmuxSession(launchIdentity.tmuxName, opts.cwd, command, env); if (!res.ok) { throw new Error(`Failed to create tmux session "${identity.tmuxName}": ${res.error}`); } } - const card = upsertAgentCard(identity, opts.cwd); + const card = upsertAgentCard(launchIdentity, opts.cwd); return { action, - identity, - sessionId: identity.sessionId, - tmuxName: identity.tmuxName, + identity: launchIdentity, + sessionId: launchIdentity.sessionId, + tmuxName: launchIdentity.tmuxName, command, card, }; From 20d3586f6f1a09c23a339065d65327fbcb3e50fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rog=C3=A9rio=20Chaves?= Date: Sun, 14 Jun 2026 08:32:37 +0200 Subject: [PATCH 4/4] test(cli): cover forceFresh fresh-launch + unique session id --- cli/src/launch.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cli/src/launch.test.ts b/cli/src/launch.test.ts index 6f3a4c27..e4a57c23 100644 --- a/cli/src/launch.test.ts +++ b/cli/src/launch.test.ts @@ -99,4 +99,28 @@ describe("headless agent launch/resume (real tmux)", skipIfNoTmux, () => { const card = readLinks()[0]; assert.equal(card.sessionLink?.sessionPath, join(projDir, `${identity.sessionId}.jsonl`)); }); + + test("forceFresh ignores a resumable transcript and mints a unique session id", () => { + // A prior session's transcript exists under this slug's stable id, so the + // default would resume it. A recycled ephemeral slug (a room reusing names) + // must not: forceFresh skips the resume AND mints a unique id, so it can't + // collide with the existing id ("Session ID already in use") and stays + // deliverable. The readable tmux name stays stable. + const projDir = join(claudeHome, "projects", "some-encoded-cwd"); + mkdirSync(projDir, { recursive: true }); + writeFileSync(join(projDir, `${identity.sessionId}.jsonl`), '{"type":"user"}\n'); + + const result = ensureAgentSession(identity, { + cwd: workspace, + bin: "true", + forceFresh: true, + }); + assert.equal(result.action, "launched"); + assert.doesNotMatch(result.command!, /--resume/); + assert.notEqual(result.sessionId, identity.sessionId); + assert.match(result.command!, new RegExp(`--session-id ${result.sessionId}`)); + assert.equal(result.tmuxName, identity.tmuxName); + execSync(`tmux has-session -t ${identity.tmuxName}`); + assert.equal(readLinks()[0].sessionLink?.sessionId, result.sessionId); + }); });