Skip to content
Open
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.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<username>:chat:<id>` 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:<username>:chat:<id>` 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.

Expand Down Expand Up @@ -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:<session>` 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.
Expand Down
2 changes: 2 additions & 0 deletions skills/talking-stick/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

## Recovery
Expand Down
21 changes: 20 additions & 1 deletion src/cli/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
let members: RoomMember[] = [];
let owner: string | null = null;
let ownerSince: string | null = null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion src/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\` in cmux (or \`tt standby --wake manual --json\` outside cmux, requiring manual resume) instead of leaving, unless the operator says to leave.

## Claude

Expand Down
44 changes: 40 additions & 4 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,15 +417,33 @@ 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,
canonical_path: room.canonical_path,
remaining_members: remainingMembers.length
};
}
if (!this.hasActiveTurnTakingMember(remainingMembers, now)) {
if (!this.shouldKeepRoom(remainingMembers, now)) {
this.deleteRoom(input.room_id);
return {
status: "room_deleted",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading