From 69c5826357e98776d4b9c7b8b4a85268c4d13640 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Mon, 14 Sep 2026 21:30:56 -0400 Subject: [PATCH 1/2] Keep operator chat rooms open after agents leave A running tt chat console now keeps its room when the last agent leaves or is kicked, so the operator can stay and agents rejoin the same room. The last console closing an agent-less room deletes it, and a console whose process is gone never retains a room. Chat explains when an addressed agent has left, and the skill tells agents to finish with tt standby instead of tt leave while an operator console is present. --- CHANGELOG.md | 4 + README.md | 2 +- skills/talking-stick/SKILL.md | 2 + src/cli/chat.ts | 21 +++- src/instructions.ts | 2 +- src/service.ts | 44 ++++++++- tests/chat.test.ts | 174 ++++++++++++++++++++++++++++++++-- 7 files changed, 236 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d56d277..0caf020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ changes will be called out under **Breaking changes**. ## Unreleased +### Changed + +- **Operator chat keeps its room open.** A running `tt chat` console keeps the room when the last agent leaves or is kicked, so the operator can stay and agents rejoin the same room. The room is deleted when the last console closes an agent-less room, and a crashed console never keeps a room alive. Addressing an agent that left explains that it can't receive messages until it rejoins. The skill and bundled instructions tell agents to finish with `tt standby --wake cmux` instead of `tt leave` while an operator console is present. + ## [0.15.0] — 2026-09-15 Full notes: [`docs/releases/0.15.0.md`](docs/releases/0.15.0.md). diff --git a/README.md b/README.md index 3138dd6..1d54dc6 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ Names use consistent harness colors in the conversation and participant list: Cl `tt chat [path] --history N` loads up to N recent conversation entries (default 20, maximum 500); `--history 0` starts without history. `--events` also shows turn events at startup. Agents must keep their normal `tt wait` receive process active to respond live. Broadcasts do not wake a harness in standby; a directed message may use its registered wake endpoint. A message being stored in the room is not an acknowledgement that an agent has read it. -Each console uses a separate `human::chat:` identity. Agents reply to the sender ID from the received message or a unique display name. Replies addressed to the console ring the terminal bell. The console is an observer: it cannot acquire the stick, receive a handoff, make a lone agent eligible for an automatic claim, or keep an abandoned room alive. Opening and closing the console do not emit agent join/leave wakes. Message text is stripped of terminal escape sequences before display. +Each console uses a separate `human::chat:` identity. Agents reply to the sender ID from the received message or a unique display name. Replies addressed to the console ring the terminal bell. The console is an observer: it cannot acquire the stick, receive a handoff, or make a lone agent eligible for an automatic claim. A running console does keep its room open: when the last agent leaves, the conversation stays up so agents can rejoin the same room, and the room is deleted once the last console closes. A crashed console (its process is gone) never keeps a room alive. Agents that see a console in the room finish with `tt standby` instead of `tt leave`, so a directed `@agent` message can wake them; an agent that has left can't receive messages until it rejoins. Opening and closing the console do not emit agent join/leave wakes. Message text is stripped of terminal escape sequences before display. `[path]` defaults to the current working directory. Omit it for normal in-repo coordination; pass it only when you intentionally want a different or nested room. diff --git a/skills/talking-stick/SKILL.md b/skills/talking-stick/SKILL.md index dc1feed..60a36d5 100644 --- a/skills/talking-stick/SKILL.md +++ b/skills/talking-stick/SKILL.md @@ -113,6 +113,8 @@ After handoff: - only an external/operator signal remains: run `tt standby --wake cmux --json` and let the model turn end; - the shared objective is proven complete: stop and report the result. +When an operator chat console is in the room (a `human:*:chat:*` member in `tt join` or `tt state`), don't `tt leave` at completion, even after unanimous AGREE. Run `tt standby --wake cmux --json` so the operator can wake you with a directed chat message; a member that left can't be messaged or woken. Leave only when the operator tells you to. The room stays open while the console is running, even with no agents in it. + Completion requires a final verdict, no pending assignment or next action, closed questions, and recorded verification. Do not stop merely because one implementation turn ended. ## Recovery diff --git a/src/cli/chat.ts b/src/cli/chat.ts index 16d8fb6..ec18f5a 100644 --- a/src/cli/chat.ts +++ b/src/cli/chat.ts @@ -122,6 +122,8 @@ export async function runChatSession( }); const roomId = joined.room_id; + // Agents seen leaving, so addressing one explains why it can't be reached. + const departedAgents = new Set(); let members: RoomMember[] = []; let owner: string | null = null; let ownerSince: string | null = null; @@ -293,7 +295,17 @@ export async function runChatSession( refreshMembers(); const resolved = resolveChatRecipient(to, members, selfId); if ("error" in resolved) { - print(`! ${sanitizeChatText(resolved.error)}`); + const selector = to.toLowerCase(); + const departed = [...departedAgents].filter( + (agentId) => + agentId.toLowerCase().startsWith(selector) || + nameOf(agentId).toLowerCase().startsWith(selector) + ); + print( + departed.length > 0 + ? `! ${sanitizeChatText(departed.map((agentId) => nameOf(agentId)).join(", "))} left the room and can't receive messages until it rejoins.` + : `! ${sanitizeChatText(resolved.error)}` + ); return; } targets = resolved.agent_ids; @@ -349,6 +361,13 @@ export async function runChatSession( // lines stay compact underneath the message they follow. let lastPrinted: "message" | "system" | "info" = "info"; const printEvent = (event: RoomEvent) => { + if (event.event_type === "leave" && event.from_agent_id) { + departedAgents.add(event.from_agent_id); + } else if (event.event_type === "kick" && event.to_agent_id) { + departedAgents.add(event.to_agent_id); + } else if (event.event_type === "join" && event.from_agent_id) { + departedAgents.delete(event.from_agent_id); + } if (terminal) { transcript.appendEvent(event); if ( diff --git a/src/instructions.ts b/src/instructions.ts index 07ffcfc..b9a6360 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -80,7 +80,7 @@ Working agreement: 2. Plan first: debate adversarially in the room, challenge proposals, converge in writing, then implement. Prefer TDD/BDD when behavior can be specified first. 3. Review independently: reproduce material peer claims and re-run relevant tests before agreeing. Every participating member has an independent voice and an evidence-backed veto. 4. Test before handoff. Record changes, evidence, risks, and the concrete next action. -5. After the last action, every participating member independently reviews and explicitly AGREEs or vetoes. Any further action invalidates prior approvals and restarts final review. Close or leave only on unanimous AGREE. +5. After the last action, every participating member independently reviews and explicitly AGREEs or vetoes. Any further action invalidates prior approvals and restarts final review. Close or leave only on unanimous AGREE. If an operator chat console is in the room, stay reachable with \`tt standby --wake cmux --json\` instead of leaving, unless the operator says to leave. ## Claude diff --git a/src/service.ts b/src/service.ts index b3f2345..1014b0f 100644 --- a/src/service.ts +++ b/src/service.ts @@ -417,7 +417,25 @@ export class TalkingStickService { .run(input.room_id, input.agent_id); const remainingMembers = this.getMembers(input.room_id); - if (isObserverMember(member) && room.owner !== input.agent_id && room.reserved_for !== input.agent_id) { + if ( + isObserverMember(member) && + room.owner !== input.agent_id && + room.reserved_for !== input.agent_id + ) { + // The last console closing an agent-less room cleans it up now. Any + // remaining agent rows, even stale ones, are left to idle purge. + if ( + !remainingMembers.some((remaining) => !isObserverMember(remaining)) && + !this.hasLiveObserver(remainingMembers) + ) { + this.deleteRoom(input.room_id); + return { + status: "room_deleted", + room_id: input.room_id, + canonical_path: room.canonical_path, + remaining_members: 0 + }; + } return { status: "left", room_id: input.room_id, @@ -425,7 +443,7 @@ export class TalkingStickService { remaining_members: remainingMembers.length }; } - if (!this.hasActiveTurnTakingMember(remainingMembers, now)) { + if (!this.shouldKeepRoom(remainingMembers, now)) { this.deleteRoom(input.room_id); return { status: "room_deleted", @@ -554,7 +572,7 @@ export class TalkingStickService { }); const remainingMembers = this.getMembers(input.room_id); - if (!this.hasActiveTurnTakingMember(remainingMembers, now)) { + if (!this.shouldKeepRoom(remainingMembers, now)) { this.deleteRoom(input.room_id); return { status: "room_deleted", @@ -4181,7 +4199,7 @@ export class TalkingStickService { private shouldRetainIdleRoom(member: RoomMemberRow, now: Date): boolean { if (isObserverMember(member)) { - return false; + return this.getMemberProcessLiveness(member) === "alive"; } const liveness = this.getMemberProcessLiveness(member); if (liveness === "alive") { @@ -4284,6 +4302,24 @@ export class TalkingStickService { ); } + // An operator chat console keeps its room open after the agents leave, so + // the operator can wait for them to come back. Only a console whose exact + // process is verifiably alive counts; a crashed console retains nothing. + private hasLiveObserver(members: RoomMemberRow[]): boolean { + return members.some( + (member) => + isObserverMember(member) && + this.getMemberProcessLiveness(member) === "alive" + ); + } + + private shouldKeepRoom(members: RoomMemberRow[], now: Date): boolean { + return ( + this.hasActiveTurnTakingMember(members, now) || + this.hasLiveObserver(members) + ); + } + private priorOwnerReleaseCooldownMs(): number { return Math.max(this.policy.waiterGraceMs * 6, 60_000); } diff --git a/tests/chat.test.ts b/tests/chat.test.ts index 770eab0..f730a61 100644 --- a/tests/chat.test.ts +++ b/tests/chat.test.ts @@ -5,8 +5,11 @@ import { PassThrough } from "node:stream"; import { afterEach, describe, expect, test } from "vitest"; import { TalkingStickCommands } from "../src/commands.js"; import { deriveHumanCliIdentity } from "../src/identity.js"; -import { TalkingStickService } from "../src/service.js"; -import type { RoomEvent } from "../src/types.js"; +import { + TalkingStickService, + type ProcessLiveness +} from "../src/service.js"; +import type { ProcessMetadata, RoomEvent } from "../src/types.js"; import { agentColor, buildNameResolver, @@ -355,8 +358,105 @@ describe("human_chat observer membership", () => { expect( service.leaveRoom({ agent_id: "claude:solo", room_id: joined.room_id }) ).toMatchObject({ - status: "room_deleted" + status: "left", + remaining_members: 1 + }); + }); + + test("a live console keeps the room after the last agent leaves or is kicked", () => { + const { root, service } = setupService({ observerLiveness: "alive" }); + const joined = service.joinPath({ agent_id: "codex:aa", context_path: root }); + service.joinPath({ agent_id: "claude:bb", context_path: root }); + service.joinPath({ + agent_id: "human:op:chat:1", + context_path: root, + process_metadata: observerIdentity().process_metadata }); + + service.leaveRoom({ agent_id: "codex:aa", room_id: joined.room_id }); + expect( + service.kickMember({ + agent_id: "human:op:chat:1", + room_id: joined.room_id, + target_agent_id: "claude:bb", + force: true + }) + ).toMatchObject({ status: "kicked" }); + expect( + service.getRoomState({ room_id: joined.room_id }).members.map( + (member) => member.agent_id + ) + ).toEqual(["human:op:chat:1"]); + + // An agent coming back lands in the same room the operator kept open. + expect( + service.joinPath({ agent_id: "codex:aa", context_path: root }) + ).toMatchObject({ room_id: joined.room_id, joined_existing_room: true }); + }); + + test("a dead console does not keep an abandoned room", () => { + const { root, service } = setupService({ observerLiveness: "gone" }); + const joined = service.joinPath({ agent_id: "codex:aa", context_path: root }); + service.joinPath({ + agent_id: "human:op:chat:1", + context_path: root, + process_metadata: observerIdentity().process_metadata + }); + expect( + service.leaveRoom({ agent_id: "codex:aa", room_id: joined.room_id }) + ).toMatchObject({ status: "room_deleted" }); + }); + + test("the last console closing an agent-less room deletes it", () => { + const { root, service } = setupService({ observerLiveness: "alive" }); + const joined = service.joinPath({ agent_id: "codex:aa", context_path: root }); + for (const id of ["human:op:chat:1", "human:op:chat:2"]) { + service.joinPath({ + agent_id: id, + context_path: root, + process_metadata: observerIdentity().process_metadata + }); + } + service.leaveRoom({ agent_id: "codex:aa", room_id: joined.room_id }); + expect( + service.leaveRoom({ agent_id: "human:op:chat:1", room_id: joined.room_id }) + ).toMatchObject({ status: "left", remaining_members: 1 }); + expect( + service.leaveRoom({ agent_id: "human:op:chat:2", room_id: joined.room_id }) + ).toMatchObject({ status: "room_deleted" }); + }); + + test("idle purge keeps a room with a live console and drops one without", () => { + const now = { value: new Date("2026-09-01T00:00:00.000Z") }; + for (const [liveness, survives] of [ + ["alive", true], + ["gone", false] + ] as const) { + const root = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), "tt-chat-purge-")) + ); + now.value = new Date("2026-09-01T00:00:00.000Z"); + const service = new TalkingStickService({ + dbPath: path.join(root, ".state", "rooms.sqlite"), + now: () => now.value, + processLivenessChecker: (metadata) => + metadata.session_kind === "human_chat" ? liveness : "gone" + }); + cleanups.push(() => { + service.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + const joined = service.joinPath({ + agent_id: "human:op:chat:1", + context_path: root, + process_metadata: observerIdentity().process_metadata + }); + now.value = new Date("2026-10-01T00:00:00.000Z"); + expect(service.listRooms({ context_path: root }).rooms.length).toBe( + survives ? 1 : 0 + ); + expect(joined.room_id).toBeTruthy(); + } }); test("an observer leaving keeps the room and emits no leave event", () => { @@ -386,6 +486,60 @@ describe("human_chat observer membership", () => { }); describe("tt chat session", () => { + test("stays open after the last agent leaves and reconnects with a returning agent", async () => { + const { root, service } = setupService({ observerLiveness: "alive" }); + const joined = service.joinPath({ agent_id: "codex:aa", context_path: root }); + + const input = new PassThrough(); + const output = new PassThrough(); + let transcript = ""; + output.on("data", (chunk) => { + transcript += chunk.toString(); + }); + + const session = runChatSession({ + runtime: { commands: new TalkingStickCommands(service), close: () => {} }, + identity: createChatIdentity(), + context_path: root, + input, + output, + terminal: false, + color: false, + history: 10, + show_turn_events: false, + poll_ms: 5 + }); + await until(() => transcript.includes("In the room: codex")); + + service.leaveRoom({ agent_id: "codex:aa", room_id: joined.room_id }); + await until(() => transcript.includes("codex left")); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(transcript).not.toContain("The room has closed."); + + input.write("@codex are you there?\n"); + await until(() => + transcript.includes( + "! codex left the room and can't receive messages until it rejoins." + ) + ); + + service.joinPath({ agent_id: "codex:aa", context_path: root }); + service.sendMessage({ + agent_id: "codex:aa", + room_id: joined.room_id, + body: "back again" + }); + await until(() => transcript.includes("back again")); + + input.write("/quit\n"); + await session; + expect( + service.getRoomState({ room_id: joined.room_id }).members.map( + (member) => member.agent_id + ) + ).toEqual(["codex:aa"]); + }); + test("shows recent history, streams agent messages, sends, and detaches on /quit", async () => { const { root, service } = setupService(); const joined = service.joinPath({ @@ -467,7 +621,7 @@ describe("tt chat session", () => { test.each([false, true])( "room deletion explains the exit (TTY=%s)", async (terminal) => { - const { root, service } = setupService(); + const { root, service } = setupService({ observerLiveness: "gone" }); const joined = service.joinPath({ agent_id: "codex:aa", context_path: root @@ -688,13 +842,21 @@ test.each([ } ); -function setupService() { +function setupService(options: { observerLiveness?: ProcessLiveness } = {}) { const root = fs.realpathSync.native( fs.mkdtempSync(path.join(os.tmpdir(), "tt-chat-")) ); const service = new TalkingStickService({ dbPath: path.join(root, ".state", "rooms.sqlite"), - policy: { waitForEventsPollMs: 1 } + policy: { waitForEventsPollMs: 1 }, + ...(options.observerLiveness + ? { + processLivenessChecker: (metadata: ProcessMetadata) => + metadata.session_kind === "human_chat" + ? options.observerLiveness! + : "unknown" + } + : {}) }); cleanups.push(() => { service.close(); From 810fd31e56b667231a0eedd4150e960544ab6519 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Mon, 14 Sep 2026 21:33:36 -0400 Subject: [PATCH 2/2] Verify console retention and clarify standby limits --- README.md | 6 +++--- skills/talking-stick/SKILL.md | 2 +- src/instructions.ts | 2 +- tests/chat.test.ts | 30 +++++++++++++++++++++++++++--- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1d54dc6..bfac5fd 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Once installed, each agent harness has a skill that tells it to coordinate throu ``` tt list — which rooms exist under a path tt join — join the room for this workspace -tt leave — explicitly leave a room; deletes it when no active members remain +tt leave — explicitly leave a room; deletes it when no active agents or live consoles remain tt wait — long-poll for ownership and room events; cursor is saved automatically tt wait --park — stay coordinated without auto-claiming idle rooms tt standby — park, return immediately, and optionally wake the cmux surface later @@ -277,7 +277,7 @@ Names use consistent harness colors in the conversation and participant list: Cl `tt chat [path] --history N` loads up to N recent conversation entries (default 20, maximum 500); `--history 0` starts without history. `--events` also shows turn events at startup. Agents must keep their normal `tt wait` receive process active to respond live. Broadcasts do not wake a harness in standby; a directed message may use its registered wake endpoint. A message being stored in the room is not an acknowledgement that an agent has read it. -Each console uses a separate `human::chat:` identity. Agents reply to the sender ID from the received message or a unique display name. Replies addressed to the console ring the terminal bell. The console is an observer: it cannot acquire the stick, receive a handoff, or make a lone agent eligible for an automatic claim. A running console does keep its room open: when the last agent leaves, the conversation stays up so agents can rejoin the same room, and the room is deleted once the last console closes. A crashed console (its process is gone) never keeps a room alive. Agents that see a console in the room finish with `tt standby` instead of `tt leave`, so a directed `@agent` message can wake them; an agent that has left can't receive messages until it rejoins. Opening and closing the console do not emit agent join/leave wakes. Message text is stripped of terminal escape sequences before display. +Each console uses a separate `human::chat:` identity. Agents reply to the sender ID from the received message or a unique display name. Replies addressed to the console ring the terminal bell. The console is an observer: it cannot acquire the stick, receive a handoff, or make a lone agent eligible for an automatic claim. A running console does keep its room open: when the last agent leaves, the conversation stays up so agents can rejoin the same room, and an agent-less room is deleted once the last console closes. A crashed console (its process is gone) never keeps a room alive. Agents that see a console in the room finish with `tt standby` instead of `tt leave`, so a directed `@agent` message can wake them when a verified cmux endpoint is registered. Outside cmux, manual standby requires the operator to resume the harness; an agent that has left can't receive messages until it rejoins. Opening and closing the console do not emit agent join/leave wakes. Message text is stripped of terminal escape sequences before display. `[path]` defaults to the current working directory. Omit it for normal in-repo coordination; pass it only when you intentionally want a different or nested room. @@ -311,7 +311,7 @@ Use `tt whoami --explain` to see which identity path the CLI chose. - **Structured handoffs.** `tt release` and `tt pass` carry a typed `Handoff` with required `status` / `next_action` and optional `artifacts[]` pointing at specific files and line ranges. - **Fair handoff selection.** Normal release prefers a recent waiter that is new or has gone longest without holding the stick; if the best-known candidate is between wait polls, a short grace window prevents immediate recycling to a less-fair claimant. - **No immediate take-backs.** If release leaves a handoff idle, the prior owner waits through the short grace window before reclaiming while another member exists. -- **Ephemeral rooms.** `tt leave` removes membership, rooms with no active members are physically deleted, and long-idle rooms with no recent activity or provably live member process are purged opportunistically on later invocations. The default idle retention is seven days. +- **Ephemeral rooms.** `tt leave` removes membership, rooms with no active agents or live consoles are physically deleted, and long-idle rooms with no recent activity or provably live member process are purged opportunistically on later invocations. The default idle retention is seven days. - **Conservative harness identity upgrades.** A verified `harness:` identity may replace a provisional `pid:`, `term:`, or `userhost:` identity only when both belong to the same harness process. Distinct verified sessions coexist; one cannot delete another merely because their short-lived `tt` subprocesses share a parent harness. - **Fencing tokens.** `lease_id` + `turn_id` make stale writes impossible — an agent who lost their turn cannot commit anything under the room's name. - **Liveness-aware recovery.** Dead or crashed holders are detected with OS-level process checks; claim-timeout takeover skips the prior owner when another active member is waiting. diff --git a/skills/talking-stick/SKILL.md b/skills/talking-stick/SKILL.md index 60a36d5..daeaddd 100644 --- a/skills/talking-stick/SKILL.md +++ b/skills/talking-stick/SKILL.md @@ -113,7 +113,7 @@ After handoff: - only an external/operator signal remains: run `tt standby --wake cmux --json` and let the model turn end; - the shared objective is proven complete: stop and report the result. -When an operator chat console is in the room (a `human:*:chat:*` member in `tt join` or `tt state`), don't `tt leave` at completion, even after unanimous AGREE. Run `tt standby --wake cmux --json` so the operator can wake you with a directed chat message; a member that left can't be messaged or woken. Leave only when the operator tells you to. The room stays open while the console is running, even with no agents in it. +When an operator chat console is in the room (a `human:*:chat:*` member in `tt join` or `tt state`), don't `tt leave` at completion, even after unanimous AGREE. In cmux, run `tt standby --wake cmux --json` so the operator can wake you with a directed chat message. Outside cmux, use `tt standby --wake manual --json` and explain that the operator must resume the harness manually. A member that left can't be messaged or woken. Leave only when the operator tells you to. The room stays open while the console is running, even with no agents in it. Completion requires a final verdict, no pending assignment or next action, closed questions, and recorded verification. Do not stop merely because one implementation turn ended. diff --git a/src/instructions.ts b/src/instructions.ts index b9a6360..25e808c 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -80,7 +80,7 @@ Working agreement: 2. Plan first: debate adversarially in the room, challenge proposals, converge in writing, then implement. Prefer TDD/BDD when behavior can be specified first. 3. Review independently: reproduce material peer claims and re-run relevant tests before agreeing. Every participating member has an independent voice and an evidence-backed veto. 4. Test before handoff. Record changes, evidence, risks, and the concrete next action. -5. After the last action, every participating member independently reviews and explicitly AGREEs or vetoes. Any further action invalidates prior approvals and restarts final review. Close or leave only on unanimous AGREE. If an operator chat console is in the room, stay reachable with \`tt standby --wake cmux --json\` instead of leaving, unless the operator says to leave. +5. After the last action, every participating member independently reviews and explicitly AGREEs or vetoes. Any further action invalidates prior approvals and restarts final review. Close or leave only on unanimous AGREE. If an operator chat console is in the room, stay reachable with \`tt standby --wake cmux --json\` in cmux (or \`tt standby --wake manual --json\` outside cmux, requiring manual resume) instead of leaving, unless the operator says to leave. ## Claude diff --git a/tests/chat.test.ts b/tests/chat.test.ts index f730a61..c71ded1 100644 --- a/tests/chat.test.ts +++ b/tests/chat.test.ts @@ -302,7 +302,7 @@ describe("chat status line", () => { }); describe("human_chat observer membership", () => { - test("an observer is invisible to turn scheduling and room retention", async () => { + test("an observer retains the room without participating in turn scheduling", async () => { const { root, service } = setupService(); const joined = service.joinPath({ agent_id: "claude:solo", @@ -394,8 +394,8 @@ describe("human_chat observer membership", () => { ).toMatchObject({ room_id: joined.room_id, joined_existing_room: true }); }); - test("a dead console does not keep an abandoned room", () => { - const { root, service } = setupService({ observerLiveness: "gone" }); + test.each(["gone", "unknown"] as const)("a %s console does not keep an abandoned room", (observerLiveness) => { + const { root, service } = setupService({ observerLiveness }); const joined = service.joinPath({ agent_id: "codex:aa", context_path: root }); service.joinPath({ agent_id: "human:op:chat:1", @@ -407,6 +407,30 @@ describe("human_chat observer membership", () => { ).toMatchObject({ status: "room_deleted" }); }); + test("the last owner leaving clears ownership while retaining chat history", async () => { + const { root, service } = setupService({ observerLiveness: "alive" }); + const joined = service.joinPath({ agent_id: "codex:aa", context_path: root }); + service.joinPath({ + agent_id: "human:op:chat:1", + context_path: root, + process_metadata: observerIdentity().process_metadata + }); + const turn = await service.waitForTurn({ + agent_id: "codex:aa", room_id: joined.room_id, + max_wait_ms: 0, allow_solo_claim: true + }); + expect(turn.status).toBe("your_turn"); + service.sendMessage({ agent_id: "codex:aa", room_id: joined.room_id, body: "preserve me" }); + service.leaveRoom({ agent_id: "codex:aa", room_id: joined.room_id }); + expect(service.getRoomState({ room_id: joined.room_id }).room).toMatchObject({ + state: "idle", owner: null, lease_expires_at: null + }); + expect(service.joinPath({ agent_id: "codex:aa", context_path: root }).room_id).toBe(joined.room_id); + expect(service.getRoomEvents({ room_id: joined.room_id }).some( + (event) => event.payload?.body === "preserve me" + )).toBe(true); + }); + test("the last console closing an agent-less room deletes it", () => { const { root, service } = setupService({ observerLiveness: "alive" }); const joined = service.joinPath({ agent_id: "codex:aa", context_path: root });