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
32 changes: 24 additions & 8 deletions cli/src/agents/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -24,6 +25,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";
Expand Down Expand Up @@ -65,19 +70,30 @@ export function ensureAgentSession(
const tmuxAlive = hasTmuxSession(identity.tmuxName);
const sessionExists =
spec.canResume &&
!opts.forceFresh &&
(identity.runtime === "codex"
? !!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 <existing>` 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;

if (tmuxAlive) {
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,
Expand All @@ -89,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,
};
Expand Down
9 changes: 8 additions & 1 deletion cli/src/channels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
4 changes: 4 additions & 0 deletions cli/src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
4 changes: 3 additions & 1 deletion cli/src/kanban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ program
.requiredOption("--cwd <path>", "Working directory for the session (the agent's worktree/workspace)")
.option("--model <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 {
Expand All @@ -368,6 +369,7 @@ program
cwd,
model: opts.model,
skipPermissions: opts.skipPermissions,
forceFresh: opts.resume === false,
});
if (opts.json) {
output(result, opts);
Expand Down Expand Up @@ -1176,7 +1178,7 @@ channelCmd

channelCmd
.command("delete")
.description("Delete a channel (does not delete history file)")
.description("Delete a channel and its history log")
.argument("<name>", "Channel name")
.option("-j, --json", "Output as JSON")
.action((name: string, opts) => {
Expand Down
24 changes: 24 additions & 0 deletions cli/src/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});