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
4 changes: 4 additions & 0 deletions changelog.d/claude-attach-variants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed
- A pane running `claude attach` with options before `attach`, the start of the job's id or part
of its name also shows the background job's conversation, found the way Claude finds the job. A
job that cannot be told for certain keeps the terminal.
78 changes: 68 additions & 10 deletions server/claude-session.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,18 +444,31 @@ it.skipIf(!NATIVE)("uses each process's config dir before the server's store, wi
} finally { delete process.env["CLAUDE_CONFIG_DIR"]; }
});

/** A `claude attach` stand-in: it tells the test its pid and runs until the pane closes. */
const ATTACH_STAND_IN = `require("node:net").connect(Number(process.env.ATTACH_READY), "127.0.0.1", function () { this.end(String(process.pid)); });
process.stdin.resume();
`;

/** A job's state with the fields Claude requires of it, made before any client in this file starts. */
function jobState(job: string, state: Record<string, unknown>): void {
const made = "2026-01-01T00:00:00.000Z";
mkdirSync(join(root, ".claude", "jobs", job), { recursive: true });
writeFileSync(join(root, ".claude", "jobs", job, "state.json"), JSON.stringify({
state: "working", detail: "", template: "bg", intent: `task of ${job}`, sessionId: `${job}-0000-4000-8000-000000000000`,
cwd: root, createdAt: made, updatedAt: made, ...state,
}));
}

/**
* A pane whose foreground runs `claude attach <job>` with the native build's argv, and no PID record:
* the renamed Node runs `attach` in the workspace folder as its script, so the arguments stay as typed.
* A pane whose foreground runs `claude <args>` with the native build's argv, and no PID record: the
* renamed Node runs `attach` in the workspace folder as its script, so the arguments stay as typed.
*/
async function attachPane(job: string, before?: (pane: string) => Promise<void>): Promise<{ pane: string; pid: number }> {
async function attachPane(args: readonly string[], before?: (pane: string) => Promise<void>): Promise<{ pane: string; pid: number }> {
const created = await herdrRpc<Awaited<ReturnType<typeof workspaceCreate>>>("workspace.create", { cwd: root, label: "ovis-test-claude-attach", focus: false });
workspaces.push(created.workspace.workspace_id);
const paneId = created.root_pane.pane_id;
await before?.(paneId);
writeFileSync(join(root, "attach"), `require("node:net").connect(Number(process.env.ATTACH_READY), "127.0.0.1", function () { this.end(String(process.pid)); });
process.stdin.resume();
`);
writeFileSync(join(root, "attach"), ATTACH_STAND_IN);
let ready: (pid: number) => void = () => {};
const signal = new Promise<number>((resolve) => { ready = resolve; });
const listener = Bun.listen({
Expand All @@ -465,7 +478,7 @@ process.stdin.resume();
const timeout = setTimeout(() => ready(0), 10_000);
try {
await herdrRpc("pane.send_text", {
pane_id: paneId, text: `cd ${JSON.stringify(root)} && exec env -u CLAUDE_CONFIG_DIR ATTACH_READY=${listener.port} ${JSON.stringify(join(root, "claude"))} attach ${job}\n`,
pane_id: paneId, text: `cd ${JSON.stringify(root)} && exec env -u CLAUDE_CONFIG_DIR -u CLAUDE_CODE_SESSION_KIND ATTACH_READY=${listener.port} ${[join(root, "claude"), ...args].map((arg) => JSON.stringify(arg)).join(" ")}\n`,
});
const pid = await signal;
if (!pid) throw new Error("claude attach stand-in did not signal readiness");
Expand All @@ -484,7 +497,7 @@ it.skipIf(process.platform !== "linux")("follows a `claude attach <job>` pane to
transcript(resumed);
mkdirSync(join(root, ".claude", "jobs", job), { recursive: true });
writeFileSync(join(root, ".claude", "jobs", job, "state.json"), JSON.stringify({ sessionId: started, resumeSessionId: resumed, state: "running" }));
const attach = await attachPane(job);
const attach = await attachPane(["attach", job]);
// an attach client keeps no PID record of its own: only the job's state names what it shows
expect(existsSync(join(root, ".claude", "sessions", `${attach.pid}.json`))).toBe(false);
const conversation = await read(attach.pane);
Expand All @@ -502,7 +515,7 @@ it.skipIf(process.platform !== "linux")("shows the attached job's conversation,
mkdirSync(join(root, ".claude", "jobs", job), { recursive: true });
writeFileSync(join(root, ".claude", "jobs", job, "state.json"), JSON.stringify({ sessionId: shown, state: "running" }));
// a reused pane: the Claude that ran here before reported its session through the hook
const attach = await attachPane(job, async (paneId) => {
const attach = await attachPane(["attach", job], async (paneId) => {
await herdrRpc("pane.report_agent", { pane_id: paneId, source: "manual", agent: "claude", state: "idle" });
await herdrRpc("pane.report_agent_session", {
pane_id: paneId, source: "herdr:claude", agent: "claude", seq: 1, agent_session_id: FIRST,
Expand All @@ -521,7 +534,7 @@ it.skipIf(process.platform !== "linux")("keeps the terminal of a reused pane att
mkdirSync(join(root, ".claude", "jobs", job), { recursive: true });
writeFileSync(join(root, ".claude", "jobs", job, "state.json"), JSON.stringify({ command: "sleep 100", state: "running" }));
// the Claude that ran here before reported its session through the hook, and herdr still has it
const attach = await attachPane(job, async (paneId) => {
const attach = await attachPane(["attach", job], async (paneId) => {
await herdrRpc("pane.report_agent", { pane_id: paneId, source: "manual", agent: "claude", state: "idle" });
await herdrRpc("pane.report_agent_session", {
pane_id: paneId, source: "herdr:claude", agent: "claude", seq: 1, agent_session_id: FIRST,
Expand All @@ -532,3 +545,48 @@ it.skipIf(process.platform !== "linux")("keeps the terminal of a reused pane att
// the job shows no conversation: the earlier Claude's would be another session's
expect((await read(attach.pane)).source).toBe("scrollback");
});

it.skipIf(process.platform !== "linux")("follows a pane whose `claude` takes options before `attach` (#1645)", async () => {
const job = "6b7c8d9e";
const shown = "2a3b4c5d-6e7f-4809-9a1b-2c3d4e5f6a7b";
transcript(shown);
jobState(job, { sessionId: shown, state: "working" });
// `-r <value>` is a root option Claude reads before `attach` (--resume), and one the renamed Node
// takes too (--require), so the stand-in still runs `attach` with the arguments as typed
writeFileSync(join(root, "resumed.js"), "");
const attach = await attachPane(["-r", "./resumed.js", "attach", job]);
const conversation = await read(attach.pane);
expect(conversation.source).toBe("claude-transcript");
expect(conversation.turns.at(-1)?.parts[0]).toMatchObject({ text: `Answer ${shown}` });
});

it.skipIf(process.platform !== "linux")("follows a pane attached by the start of a job's id or by part of its name (#1645)", async () => {
const byId = "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f";
const byName = "4d5e6f7a-8b9c-4d0e-9f1a-2b3c4d5e6f7a";
transcript(byId);
transcript(byName);
jobState("3a9b8c7d", { sessionId: byId, intent: "check the build", state: "working" });
jobState("2b3c4d5e", { sessionId: byName, name: "Contract release notes", intent: "draft the notes", state: "working" });
const prefix = await read((await attachPane(["attach", "3a9b"])).pane);
expect(prefix.source).toBe("claude-transcript");
expect(prefix.turns.at(-1)?.parts[0]).toMatchObject({ text: `Answer ${byId}` });
const named = await read((await attachPane(["attach", "release notes"])).pane);
expect(named.source).toBe("claude-transcript");
expect(named.turns.at(-1)?.parts[0]).toMatchObject({ text: `Answer ${byName}` });
});

it.skipIf(process.platform !== "linux")("keeps the terminal of a reused pane whose `claude attach` names no one job (#1645)", async () => {
// both jobs start with 8e9f: Claude opens neither, and neither does the pane
jobState("8e9f0a1b", { sessionId: SECOND, state: "working" });
jobState("8e9f2c3d", { sessionId: NEXT, state: "working" });
const attach = await attachPane(["attach", "8e9f"], async (paneId) => {
await herdrRpc("pane.report_agent", { pane_id: paneId, source: "manual", agent: "claude", state: "idle" });
await herdrRpc("pane.report_agent_session", {
pane_id: paneId, source: "herdr:claude", agent: "claude", seq: 1, agent_session_id: FIRST,
});
});
const info = await herdrRpc<{ agent: { agent_session?: { value?: unknown } } }>("agent.get", { target: attach.pane });
expect(info.agent.agent_session?.value).toBe(FIRST);
// neither the earlier Claude's session, nor a guess between the two jobs
expect((await read(attach.pane)).source).toBe("scrollback");
});
Loading
Loading