talking-stick 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -122,6 +122,7 @@ Commands:
122
122
  tt list [path]
123
123
  tt join [path] [--force-new]
124
124
  tt leave [path]
125
+ tt kick <agent_id> [path] [--reason TEXT] [--force]
125
126
  tt wait [path] [--timeout 30s]
126
127
  tt try [path]
127
128
  tt state [path]
@@ -2,7 +2,7 @@ import { runStdioServer } from "../index.js";
2
2
  import { runGuardCommand } from "./guardian.js";
3
3
  import { runInstallCommand, runInstallSkillCommand, runSelfUpdateCommand, runUninstallCommand, runUninstallSkillCommand } from "./install-commands.js";
4
4
  import { handleNotesCommand } from "./notes-commands.js";
5
- import { handleEventsCommand, handleJoinCommand, handleLeaveCommand, handleListCommand, handleStateCommand, handleWhoAmICommand } from "./room-commands.js";
5
+ import { handleEventsCommand, handleJoinCommand, handleKickCommand, handleLeaveCommand, handleListCommand, handleStateCommand, handleWhoAmICommand } from "./room-commands.js";
6
6
  import { handleAssignCommand, handlePassCommand, handleReleaseCommand, handleTakeCommand, handleWaitCommand } from "./turn-commands.js";
7
7
  export const COMMAND_REGISTRY = [
8
8
  {
@@ -104,6 +104,15 @@ export const COMMAND_REGISTRY = [
104
104
  description: "Leave this agent's room membership.",
105
105
  handler: ({ runtime, parsed }) => handleLeaveCommand(requireRuntime(runtime), parsed)
106
106
  },
107
+ {
108
+ name: "kick",
109
+ needsRuntime: true,
110
+ startupMaintenance: true,
111
+ internal: false,
112
+ usage: "tt kick <agent_id> [path] [--reason TEXT] [--force]",
113
+ description: "Kick an idle member out of the room.",
114
+ handler: ({ runtime, parsed }) => handleKickCommand(requireRuntime(runtime), parsed)
115
+ },
107
116
  {
108
117
  name: "state",
109
118
  needsRuntime: true,
@@ -1,7 +1,7 @@
1
1
  import { deriveCliIdentity, resolveCliIdentity } from "./identity.js";
2
2
  import { removeCliSession, removeCliSessionsForRoom, resolveCliSessionPath } from "../index.js";
3
3
  import { stopGuardian } from "./guardian.js";
4
- import { parseOptionalInteger } from "./parser.js";
4
+ import { getStringOption, hasOption, parseOptionalInteger } from "./parser.js";
5
5
  import { formatRelativeTime, printResult } from "./output.js";
6
6
  import { resolveSessionForReads, upsertSessionFromJoin } from "./session.js";
7
7
  export function handleListCommand(runtime, parsed) {
@@ -31,7 +31,11 @@ export function handleJoinCommand(runtime, parsed) {
31
31
  });
32
32
  upsertSessionFromJoin(identity, joined);
33
33
  printResult(parsed, joined, () => {
34
- return `Joined ${joined.canonical_path} as ${joined.agent_id}`;
34
+ const lines = [`Joined ${joined.canonical_path} as ${joined.agent_id}`];
35
+ if (joined.warning) {
36
+ lines.push(`Warning: ${joined.warning}`);
37
+ }
38
+ return lines.join("\n");
35
39
  });
36
40
  }
37
41
  export function handleLeaveCommand(runtime, parsed) {
@@ -56,6 +60,35 @@ export function handleLeaveCommand(runtime, parsed) {
56
60
  return `Left ${session.canonical_path}; ${result.remaining_members} ${memberLabel}.`;
57
61
  });
58
62
  }
63
+ export function handleKickCommand(runtime, parsed) {
64
+ const [target, ...rest] = parsed.positionals;
65
+ if (!target) {
66
+ throw new Error("Missing required argument: <agent_id>");
67
+ }
68
+ const sessionParsed = { ...parsed, positionals: rest };
69
+ const identity = deriveCliIdentity(sessionParsed);
70
+ const session = resolveSessionForReads(runtime, sessionParsed, identity);
71
+ const result = runtime.commands.kickMember(identity, {
72
+ room_id: session.room_id,
73
+ target_agent_id: target,
74
+ force: hasOption(parsed, "force"),
75
+ reason: getStringOption(parsed, "reason")
76
+ });
77
+ const sessionPath = resolveCliSessionPath();
78
+ if (result.status === "room_deleted") {
79
+ removeCliSessionsForRoom(sessionPath, session.room_id);
80
+ }
81
+ else {
82
+ removeCliSession(sessionPath, result.kicked_agent_id, session.room_id);
83
+ }
84
+ printResult(parsed, result, () => {
85
+ if (result.status === "room_deleted") {
86
+ return `Kicked ${result.kicked_agent_id}; room deleted.`;
87
+ }
88
+ const memberLabel = result.remaining_members === 1 ? "member remains" : "members remain";
89
+ return `Kicked ${result.kicked_agent_id}; ${result.remaining_members} ${memberLabel}.`;
90
+ });
91
+ }
59
92
  export function handleStateCommand(runtime, parsed) {
60
93
  const identity = deriveCliIdentity(parsed);
61
94
  const session = resolveSessionForReads(runtime, parsed, identity);
package/dist/commands.js CHANGED
@@ -24,6 +24,15 @@ export class TalkingStickCommands {
24
24
  room_id: input.room_id
25
25
  });
26
26
  }
27
+ kickMember(identity, input) {
28
+ return this.service.kickMember({
29
+ agent_id: identity.agent_id,
30
+ room_id: input.room_id,
31
+ target_agent_id: input.target_agent_id,
32
+ force: input.force,
33
+ reason: input.reason
34
+ });
35
+ }
27
36
  waitForTurn(identity, input) {
28
37
  return this.service.waitForTurn({
29
38
  agent_id: identity.agent_id,
package/dist/identity.js CHANGED
@@ -41,7 +41,7 @@ export function deriveMcpHarnessIdentity(options = {}) {
41
41
  const signal = detectHarnessSignal(env);
42
42
  if (signal) {
43
43
  const processRef = resolveSignalProcessRef(signal, parentPid, parentInspection, inspector);
44
- const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId);
44
+ const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector);
45
45
  const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
46
46
  return {
47
47
  agent_id: agentId,
@@ -103,7 +103,7 @@ export function deriveHarnessCliIdentity(options = {}) {
103
103
  return null;
104
104
  const processRef = resolveSignalProcessRef(signal, parentPid, parentInspection, inspector);
105
105
  const username = options.username ?? safeUsername();
106
- const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId);
106
+ const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector);
107
107
  const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
108
108
  return {
109
109
  agent_id: agentId,
@@ -124,17 +124,47 @@ function harnessAgentId(harness, sessionId, hostId, username) {
124
124
  sanitizeIdentityComponent(username)
125
125
  ])}`;
126
126
  }
127
- function resolveHarnessSessionId(signal, env, parentPid, parentInspection, username, hostId) {
127
+ function resolveHarnessSessionId(signal, env, parentPid, parentInspection, username, hostId, inspector) {
128
128
  if (signal.sessionId)
129
129
  return `harness:${signal.sessionId}`;
130
130
  const terminalId = resolveTerminalSessionId(env);
131
131
  if (terminalId)
132
132
  return terminalId;
133
+ const harnessRoot = findHarnessRootInAncestry(signal.harness, parentPid, parentInspection, inspector);
134
+ if (harnessRoot) {
135
+ return `pid:${harnessRoot.pid}@${harnessRoot.startTime}`;
136
+ }
133
137
  if (parentInspection?.startTime) {
134
138
  return `pid:${parentPid}@${parentInspection.startTime}`;
135
139
  }
136
140
  return `userhost:${sanitizeIdentityComponent(username)}@${hostId}`;
137
141
  }
142
+ // Walks the process ancestry (inclusive of startPid) looking for the deepest
143
+ // process whose command matches the named harness. Anchoring session id to
144
+ // that root keeps `tt` invocations stable whether they're spawned directly
145
+ // by the harness (MCP subprocess) or through intermediate shells (CLI shell-out).
146
+ function findHarnessRootInAncestry(harness, startPid, startInspection, inspector, maxDepth = 10) {
147
+ let result = null;
148
+ let currentPid = startPid;
149
+ let currentInspection = startInspection;
150
+ for (let i = 0; i < maxDepth; i++) {
151
+ if (currentPid == null || currentPid <= 1)
152
+ break;
153
+ if (currentInspection === undefined) {
154
+ currentInspection = inspector.inspect(currentPid);
155
+ }
156
+ if (!currentInspection)
157
+ break;
158
+ const label = deriveCommandLabel(currentInspection.command);
159
+ if (HARNESS_COMMAND_MAPPING[label] === harness &&
160
+ currentInspection.startTime) {
161
+ result = { pid: currentPid, startTime: currentInspection.startTime };
162
+ }
163
+ currentPid = currentInspection.ppid ?? null;
164
+ currentInspection = undefined;
165
+ }
166
+ return result;
167
+ }
138
168
  const TERMINAL_SESSION_ENV_VARS = [
139
169
  "ITERM_SESSION_ID",
140
170
  "CMUX_TAB_ID",
@@ -55,6 +55,16 @@ export function createMcpServer(service = new TalkingStickService()) {
55
55
  room_id: z.string().min(1)
56
56
  }
57
57
  }, async (input, extra) => toolJson(() => commands.leaveRoom(resolveConnectionIdentity(extra.sessionId), input)));
58
+ server.registerTool("kick_member", {
59
+ title: "Kick Member",
60
+ description: "Remove an idle member from a room. Without force, only succeeds if the target's process is detected gone past the silence-grace window.",
61
+ inputSchema: {
62
+ room_id: z.string().min(1),
63
+ target_agent_id: z.string().min(1),
64
+ force: z.boolean().optional(),
65
+ reason: z.string().optional()
66
+ }
67
+ }, async (input, extra) => toolJson(() => commands.kickMember(resolveConnectionIdentity(extra.sessionId), input)));
58
68
  server.registerTool("wait_for_turn", {
59
69
  title: "Wait For Turn",
60
70
  description: "Poll until the caller can claim the stick or takeover is available.",
package/dist/service.js CHANGED
@@ -128,6 +128,92 @@ export class TalkingStickService {
128
128
  };
129
129
  });
130
130
  }
131
+ kickMember(input) {
132
+ assertNonEmpty(input.agent_id, "agent_id");
133
+ assertNonEmpty(input.room_id, "room_id");
134
+ assertNonEmpty(input.target_agent_id, "target_agent_id");
135
+ if (input.target_agent_id === input.agent_id) {
136
+ throw new ProtocolError("cannot_kick_self", "Use leave_room to remove yourself.", { to_agent_id: input.target_agent_id });
137
+ }
138
+ const now = this.now();
139
+ const timestamp = now.toISOString();
140
+ this.purgeExpiredIdleRooms(now);
141
+ return withImmediateTransaction(this.db, () => {
142
+ const room = this.requireRoom(input.room_id);
143
+ this.touchMember(input.room_id, input.agent_id, timestamp);
144
+ const target = this.getMember(input.room_id, input.target_agent_id);
145
+ if (!target) {
146
+ throw new ProtocolError("unknown_target", "Target agent is not a member of this room.", { to_agent_id: input.target_agent_id });
147
+ }
148
+ if (!input.force) {
149
+ const liveness = this.getMemberProcessLiveness(target);
150
+ if (!this.isGonePersistent(target, liveness, now)) {
151
+ throw new ProtocolError("target_active", "Target is still active. Pass force=true to kick anyway.", { to_agent_id: input.target_agent_id });
152
+ }
153
+ }
154
+ const targetWasOwner = room.owner === input.target_agent_id;
155
+ const targetWasReservedFor = room.reserved_for === input.target_agent_id;
156
+ this.db
157
+ .prepare("DELETE FROM room_members WHERE room_id = ? AND agent_id = ?")
158
+ .run(input.room_id, input.target_agent_id);
159
+ this.appendEvent({
160
+ room_id: input.room_id,
161
+ turn_id: room.turn_id,
162
+ event_type: "kick",
163
+ from_agent_id: input.agent_id,
164
+ to_agent_id: input.target_agent_id,
165
+ handoff: null,
166
+ reason: input.reason ?? null,
167
+ created_at: timestamp
168
+ });
169
+ const remainingMembers = this.getMembers(input.room_id);
170
+ if (remainingMembers.length === 0 ||
171
+ !remainingMembers.some((remaining) => this.isMemberActive(remaining, now))) {
172
+ this.deleteRoom(input.room_id);
173
+ return {
174
+ status: "room_deleted",
175
+ room_id: input.room_id,
176
+ canonical_path: room.canonical_path,
177
+ kicked_agent_id: input.target_agent_id,
178
+ remaining_members: 0,
179
+ target_was_owner: targetWasOwner,
180
+ target_was_reserved_for: targetWasReservedFor
181
+ };
182
+ }
183
+ const nextOwner = targetWasOwner ? null : room.owner;
184
+ const nextReservedFor = targetWasReservedFor ? null : room.reserved_for;
185
+ const nextState = room.state === "closed"
186
+ ? "closed"
187
+ : nextOwner
188
+ ? "owned"
189
+ : nextReservedFor
190
+ ? "reserved"
191
+ : "idle";
192
+ this.db
193
+ .prepare(`
194
+ UPDATE path_rooms
195
+ SET owner = ?,
196
+ reserved_for = ?,
197
+ pending_handoff_event_seq = ?,
198
+ lease_id = ?,
199
+ lease_expires_at = ?,
200
+ claim_expires_at = ?,
201
+ state = ?,
202
+ updated_at = ?
203
+ WHERE room_id = ?
204
+ `)
205
+ .run(nextOwner, nextReservedFor, targetWasOwner ? null : room.pending_handoff_event_seq, targetWasOwner ? null : room.lease_id, targetWasOwner ? null : room.lease_expires_at, targetWasReservedFor ? null : room.claim_expires_at, nextState, timestamp, input.room_id);
206
+ return {
207
+ status: "kicked",
208
+ room_id: input.room_id,
209
+ canonical_path: room.canonical_path,
210
+ kicked_agent_id: input.target_agent_id,
211
+ remaining_members: remainingMembers.length,
212
+ target_was_owner: targetWasOwner,
213
+ target_was_reserved_for: targetWasReservedFor
214
+ };
215
+ });
216
+ }
131
217
  async waitForTurn(input) {
132
218
  assertNonEmpty(input.agent_id, "agent_id");
133
219
  assertNonEmpty(input.room_id, "room_id");
@@ -596,7 +682,11 @@ export class TalkingStickService {
596
682
  if (forceNew) {
597
683
  const exactRoom = this.findRoomByCanonicalPath(resolved.canonical_context_path);
598
684
  if (exactRoom) {
599
- return { room: exactRoom, joinedExistingRoom: true };
685
+ return {
686
+ room: exactRoom,
687
+ joinedExistingRoom: true,
688
+ warning: `force_new had no effect: a room already exists at ${exactRoom.canonical_path}. force_new only creates a nested room when an ancestor room exists; same-path duplicates are not supported. To get a fresh room for a separate topic, join a distinct subpath.`
689
+ };
600
690
  }
601
691
  return {
602
692
  room: this.createRoom(resolved.canonical_context_path, timestamp),
@@ -44,7 +44,7 @@ Important distinction: the marker directory is an **ambient-presence enablement
44
44
  Two immediate surfaces, both driven by the local SQLite store:
45
45
 
46
46
  - **Shell prompt fragment** — a `tt status --prompt` subcommand that prints a short PS1-safe string (or nothing). Wired into Bash `PROMPT_COMMAND`, Zsh `precmd`, Fish `fish_prompt`.
47
- - **Background room event stream** — an extension of `tt events`, most likely `tt events --follow`, that emits one JSON line per room event to stdout and can resume from a stored `event_seq`.
47
+ - **Background room event stream** — an extension of `tt events`, most likely `tt events --follow`, that emits one line per room event to stdout and can resume from a stored `event_seq`. Harness-detected runs default to JSON lines; plain human CLI runs default to readable text.
48
48
 
49
49
  The existing `tt wait` command keeps its current meaning: claimant-side wait for `your_turn` / `takeover_available`. Ambient presence should not overload `wait` into a second, room-wide event API.
50
50
 
@@ -108,10 +108,10 @@ Shell integration snippets ship under `integrations/shell/` with a `tt prompt in
108
108
 
109
109
  ### `tt events --follow`
110
110
 
111
- Line-oriented room event stream. Stdout is JSON lines, one event per line. Stderr is for diagnostics only.
111
+ Line-oriented room event stream. Stdout is one event per line. Stderr is for diagnostics only.
112
112
 
113
113
  ```
114
- tt events [path] --follow [--after <event_seq>] [--event <types>] [--json|--pretty]
114
+ tt events [path] --follow [--after <event_seq>] [--event <types>] [--json|--text]
115
115
  ```
116
116
 
117
117
  Flags:
@@ -119,7 +119,9 @@ Flags:
119
119
  - `--follow` — continue polling for new room events instead of returning a bounded page.
120
120
  - `--after` — resume after the last seen `event_seq`.
121
121
  - `--event` — comma-separated filter over raw room event types.
122
- - `--json` / `--pretty` — output format.
122
+ - `--json` / `--text` — output format override.
123
+
124
+ Default output follows identity. If the CLI detects a supported harness identity (`TT_HARNESS_AGENT_ID`, `CLAUDECODE`, `CODEX_THREAD_ID`, `GEMINI_CLI`, or `OPENCODE`), the stream emits JSON lines suitable for background harness glue. If no harness is detected and the CLI falls back to a human identity, the stream emits human-readable text. Humans should not need to pipe through `jq` just to make room activity readable; scripts can still pass `--json`.
123
125
 
124
126
  The stream should align with the core room event log, not invent a second taxonomy. For the MVP, the on-the-wire event types should be the existing `RoomEvent` types:
125
127
 
@@ -165,7 +167,7 @@ The skill body covers:
165
167
  - **Identity in spawned shells.** This is the real fork in the road. If a harness can cheaply export its protocol identity into child shells, participant-mode shell helpers are viable. If not, observer mode should ship first and participant mode moves to a later release.
166
168
  - **Event granularity.** Coarse events (room-event log only) minimize context pollution; fine events (every lease poke, every presence blip) enable richer UX but flood. Start with raw room events plus caller-centric `tt wait`.
167
169
  - **Skill activation reliability.** Skills load on description match or bootstrap, not on `cd`. The repo marker plus an `AGENTS.md` / `CLAUDE.md` line is the most reliable trigger we have without harness-specific hooks.
168
- - **Cross-harness event format.** The event stream must be plain JSON lines — no dependency on any one harness's notification shape. Harnesses read lines; they map to their own notification system.
170
+ - **Cross-harness event format.** The machine event stream must be plain JSON lines — no dependency on any one harness's notification shape. Harnesses read lines; they map to their own notification system. Human CLI output remains readable by default unless `--json` is requested.
169
171
  - **Current task in ambient status.** Showing the owner's current task would be high-signal, but it should come from the handoff that granted the current turn, not from guessed free text. That likely requires the core room projection to retain the granting handoff pointer or a current-task snapshot. Good follow-up; not a v1 requirement for the prompt fragment.
170
172
  - **Non-interactive shells.** This is intentionally deferred, not dropped. `PS1` only covers interactive shells; harness command runners need a different hook. A future shell prelude or harness-specific command hook should render ambient state for invoked commands. If it can prove participant identity, it may render participant-local status; otherwise it should emit observer-only conversation status. Treat this as a follow-on stage, not as part of the first shippable surface.
171
173
  - **Multiple rooms per repo.** Out of scope for v1; assume one active room per workspace path. The CLI surface should not preclude multi-room later.
@@ -0,0 +1,290 @@
1
+ # Out-of-Band Signaling Between Harnesses
2
+
3
+ **Status:** Design proposal — not yet scheduled. Intended for cross-harness review (Codex + Claude Code).
4
+ **Related:** [ambient-presence.md](../ambient-presence.md), [talking-stick-plan.md](../talking-stick-plan.md)
5
+
6
+ ## Purpose
7
+
8
+ The talking stick today enforces **in-band, single-speaker** coordination: the holder is the only participant whose work actually mutates the workspace, and other participants either wait or observe. That is correct for write authority. It is too restrictive for *signaling*.
9
+
10
+ There are real situations where a non-holder needs to reach the holder — or needs to be reached — *without* taking the stick:
11
+
12
+ - The non-holder is watching the holder's work and notices a problem (wrong file, broken assumption, looming merge conflict). It should be able to say so without forcing a takeover.
13
+ - A new participant joins the room mid-turn. The holder may want to greet, hand off, or just acknowledge. Today the holder finds out only when they next call `get_room_state`.
14
+ - A participant leaves the room mid-task. The holder should know not to `pass_stick` to a harness that is no longer participating.
15
+ - A holder finds an issue that another harness should address next. It should be able to page that harness before the formal handoff so the recipient is not surprised at claim time.
16
+ - An operator drops a note ("we're scoping down — stop after the test passes"). The holder should see it before the next handoff boundary.
17
+ - The watcher itself is an LLM ("guardian") spawned to keep the holder honest; its only job is to tail the room and raise its hand on specific conditions.
18
+ - A release/pass event can be useful as an early wake-up signal for a waiting harness, even though `wait_for_turn` remains the authority that decides whether the harness may claim.
19
+
20
+ This document proposes the smallest primitive set that lets harnesses exchange these signals over the existing room-event log, plus the harness-side glue (background watcher + stdout-line notification) that makes them feel ambient instead of poll-driven.
21
+
22
+ It is a layer on top of [ambient-presence.md](../ambient-presence.md). Where ambient-presence proposes `tt events --follow` as a one-way *observer* stream for waiting agents, this document extends the same stream to be the channel for *directed* signals into an active turn, and defines what those signals look like.
23
+
24
+ ## Vision
25
+
26
+ Vignette A — guardian catches a wrong turn:
27
+
28
+ 1. Codex holds the stick, working on `src/auth/session.ts`.
29
+ 2. Claude Code runs `tt events --follow` in the background under its Monitor tool. It is observer-only on the room.
30
+ 3. A `note_added` event arrives with severity `page` plus a capped body preview: *"You're editing session.ts but the bug is in token.ts — see line 84."*
31
+ 4. Claude Code's harness surfaces the line to the user, who can choose to interrupt Codex or let it self-correct on next read of room notes.
32
+ 5. Codex finishes, calls `release_stick`, picks up the note via existing `list_notes`, acknowledges, hands off.
33
+
34
+ Vignette B — join awareness mid-turn:
35
+
36
+ 1. Claude Code holds the stick on a long refactor.
37
+ 2. A human runs `tt join` from a second terminal to observe.
38
+ 3. A `member_joined` event arrives on Claude's background watcher.
39
+ 4. Claude's watcher rule says: *member_joined is informational, not an interrupt — write it to the boundary buffer, not the loud Monitor stream.*
40
+ 5. At next handoff prep, Claude reads the buffered events and notes "Wojtek joined two minutes ago" in the handoff body.
41
+
42
+ Vignette C — operator pages the active holder:
43
+
44
+ 1. The current holder is in the middle of a long edit.
45
+ 2. The operator posts `tt notes add --severity page --target <holder> "Scope down: stop after the parser test passes."`
46
+ 3. The holder's page channel emits one loud JSON line with the note id, author, severity, and capped preview.
47
+ 4. The holder may act immediately or acknowledge at handoff. The page does not grant or revoke write authority.
48
+
49
+ Vignette D — a participant leaves before handoff:
50
+
51
+ 1. Claude Code holds the stick and originally expected to pass the next turn to Gemini for review.
52
+ 2. Gemini exits the room, or its membership is marked inactive after the implementation-defined debounce window.
53
+ 3. Claude's buffer channel records `member_left` with `from_agent_id = gemini:...` and a reason.
54
+ 4. At handoff prep, Claude sees the buffered leave event and does **not** `pass_stick` to Gemini. It either releases to the normal sequence or chooses a different active recipient with an explicit reason.
55
+
56
+ Vignette E — the holder pages a future recipient:
57
+
58
+ 1. Codex holds the stick and finds a regression that Claude should address after Codex finishes the current edit.
59
+ 2. Codex posts `add_note` with `severity: "page"` and `target_agent_id = "claude:..."`: *"When I pass back, please start with tests/cli.test.ts; the install dry-run expectation is stale."*
60
+ 3. Claude's page channel receives the note while Claude is still a non-holder. Claude may read and prepare, but still must not mutate the workspace until it owns the stick.
61
+ 4. Codex later passes or releases with a handoff that references the same `note_id`, so the formal turn boundary and the earlier page line reconcile.
62
+
63
+ Vignette F — a third harness joins an existing pair:
64
+
65
+ 1. Codex and Claude have been alternating on a feature.
66
+ 2. OpenCode joins the room to take over UI verification.
67
+ 3. Both active watchers see `member_joined` in their buffer channels. It is not page-worthy by default, but it changes the social shape of the next handoff.
68
+ 4. The current holder can mention the new participant in the next handoff, avoid hard-passing between only the original two harnesses, or explicitly pass to OpenCode if that is the right next owner.
69
+
70
+ Vignette G — handoff as an early wake-up signal *(deferred future work, captured here for context):*
71
+
72
+ 1. Claude is waiting and, absent any other signal, would wake from its own scheduler in two minutes.
73
+ 2. Codex releases the stick ten seconds later.
74
+ 3. A future wait-helper sees the `release` event immediately and asks Claude to run a short `wait_for_turn` probe.
75
+ 4. `wait_for_turn` still decides whether Claude may claim. The event line is only an advisory wake-up, and this optimization remains deferred until wait intent is modeled explicitly.
76
+
77
+ ## Scope
78
+
79
+ In scope:
80
+
81
+ - Extending the `RoomEvent` taxonomy so the existing `event_seq` log carries presence and notes, not just stick mutations.
82
+ - Defining a "page" semantic on top of notes so harnesses can distinguish *interrupt-worthy* from *buffer-until-boundary*.
83
+ - Specifying the stdout-line watcher contract that lets a harness convert events into harness-native notifications.
84
+ - Quantifying the token cost of running such a watcher continuously.
85
+
86
+ Explicitly out of scope:
87
+
88
+ - Any new write authority for non-holders. Notes/pages do not grant the stick. Takeover remains the only way to seize write authority and is unchanged.
89
+ - A second event log, second cursor concept, or second identity model. Everything reuses `event_seq`, `agent_id`, and the existing room-resolution rules.
90
+ - Push transports (websockets, MCP resource subscriptions). Pull-based long-poll over SQLite is sufficient for v1; see *Tradeoffs*.
91
+ - Any harness-specific notification format. Harness-detected runs get machine-readable JSON lines; plain human CLI runs get human-readable text by default. `--json` and `--text` remain explicit overrides.
92
+ - Event-driven stick claiming. `wait_for_turn` remains the authoritative wait/claim path in v1; using the event stream to wake waiters is deferred until wait intent is modeled explicitly.
93
+
94
+ ## Architecture
95
+
96
+ Four layers, building on what exists.
97
+
98
+ ### Layer 1 — Extended event taxonomy
99
+
100
+ Today `RoomEvent.event_type` is `"claim" | "release" | "pass" | "takeover" | "close"`. Notes (`addNote`) live in a separate `notes` table and emit nothing into `room_events`. Member join/leave does not emit events at all. That means the long-poll stream has nothing to say about anything except stick handoffs.
101
+
102
+ Proposed additions to `event_type`:
103
+
104
+ | New event | Emitted when | Fields beyond the common ones |
105
+ |-------------------|----------------------------------------------------|------------------------------------------------|
106
+ | `member_joined` | `joinPath` adds a member or reactivates one | `to_agent_id` = joiner |
107
+ | `member_left` | `leaveRoom` succeeds, or a member is GC'd inactive | `from_agent_id` = leaver, `reason` |
108
+ | `note_added` | `addNote` succeeds | `note_id`, `severity`, `target_agent_id?`, `body_preview?` |
109
+ | `note_resolved` | A future `resolve_note` (or implicit on takeover) | `note_id` |
110
+
111
+ Rationale for putting notes into the event log rather than inventing a parallel notes-stream:
112
+
113
+ - Single cursor. Watchers already need `event_seq` to resume after disconnect; folding notes in means no second cursor and no race between two streams.
114
+ - Replay parity. Rebuilding room state from the event log already requires reading every mutation; adding notes to that stream means a fresh observer can reconstruct "what does the holder need to know?" without a second query.
115
+ - Audit shape. The event log is append-only and ordered. Notes already are too. The shapes match.
116
+
117
+ The persisted `note_added` event carries metadata: `note_id`, `severity`, optional `target_agent_id`, and a capped `body_preview` for page delivery. The full body still lives in the `notes` table and is fetched via `list_notes`. This keeps persisted event payloads bounded while making a page line actionable without a second foreground tool call.
118
+
119
+ ### Layer 2 — Note severity and targeting
120
+
121
+ Notes today are flat: any member can post one, the holder reads them at handoff boundaries. To support out-of-band signaling we add two optional fields on `AddNoteInput`:
122
+
123
+ ```ts
124
+ interface AddNoteInput {
125
+ agent_id: AgentId;
126
+ room_id: string;
127
+ body: string;
128
+ turn_id?: number;
129
+ severity?: "info" | "page"; // NEW — defaults to "info"
130
+ target_agent_id?: AgentId; // NEW — null/undefined = whole room
131
+ }
132
+ ```
133
+
134
+ Semantics:
135
+
136
+ - `severity: "info"` (default) — buffer until the recipient's next safe boundary. Watchers should NOT interrupt the active turn for these.
137
+ - `severity: "page"` — recipient's watcher SHOULD interrupt the active turn. Use sparingly. The protocol does not enforce attention; it provides the signal and lets the receiving harness decide.
138
+ - `target_agent_id` — addresses a specific member. If absent, the note is room-wide. The current holder is implicitly a target for any unaddressed page.
139
+
140
+ The protocol does **not** define what "interrupt" means in any specific harness. That is each harness's call. The protocol guarantees only: the event arrives, the severity is preserved, and the cursor advances.
141
+
142
+ ### Layer 3 — `tt events --follow` as the harness channel
143
+
144
+ Already proposed in [ambient-presence.md](../ambient-presence.md) §`tt events --follow`. We adopt it verbatim and extend it with the new event types. Restating the contract for completeness:
145
+
146
+ ```
147
+ tt events [path] --follow
148
+ [--after <event_seq>]
149
+ [--event <type[,type...]>]
150
+ [--severity info|page]
151
+ [--target self|any|<agent_id>]
152
+ [--json|--text]
153
+ ```
154
+
155
+ Stdout is line-oriented and flushed after each event. When the CLI detects one of the supported harness identities (`TT_HARNESS_AGENT_ID`, `CLAUDECODE`, `CODEX_THREAD_ID`, `GEMINI_CLI`, or `OPENCODE`), default stdout is one JSON object per line. When no harness is detected and the CLI falls back to a human identity, default stdout is human-readable text. `--json` forces JSON lines for scripts; `--text` forces human-readable text even from a harness. Stderr is diagnostics only. Exit on `SIGTERM`/`SIGHUP` with a final flush.
156
+
157
+ The new `--severity` and `--target` flags filter `note_added` events specifically. A guardian-style harness uses two logical channels:
158
+
159
+ ```
160
+ # Page channel — loud. One line here means "interrupt the holder now."
161
+ tt events --follow --event note_added --severity page --target self --json
162
+
163
+ # Buffer channel — quiet. Write to a local cursor/log and read at the next safe boundary.
164
+ tt events --follow --event member_joined,member_left,note_added --severity info --target any --json
165
+ ```
166
+
167
+ The distinction is not just severity in the JSON payload. Some harness glue, notably Claude Code's Monitor tool, treats *every stdout line* from a watched process as a conversation notification. Page output is suitable for that loud path. Buffer output is not; it should be drained into a local cursor/log and summarized by the foreground agent at handoff or another safe boundary.
168
+
169
+ ### Wait pattern — not changed by this plan
170
+
171
+ This proposal does **not** replace `wait_for_turn` with event-stream notifications for stick availability.
172
+
173
+ The current queue mechanics rely on `wait_for_turn` as both the claim authority and the wait-intent heartbeat. In the service today, `wait_for_turn` updates `last_wait_at`, and normal `release_stick` only reserves the stick for a candidate whose wait is recent according to `waiterGraceMs`. A participant that probes once and then sleeps only on event lines for minutes can therefore change the normal reservation behavior.
174
+
175
+ The v1 skill should continue to teach direct `wait_for_turn` long-polls. A future event-driven wait helper can use the same event stream as an advisory wakeup channel, but it first needs an explicit wait-intent design, for example `waiting_since` / `wait_intent_expires_at` renewed by a helper. That follow-up helper would still run `wait_for_turn` to claim; event lines would be a bell, not the lock.
176
+
177
+ ### Layer 4 — Harness-side: background process + stdout-line notification
178
+
179
+ The actual integration in Claude Code:
180
+
181
+ 1. Foreground agent starts the page channel in the background and attaches Monitor to that process only.
182
+ 2. Foreground agent starts the buffer channel separately, without Monitor, writing JSON lines plus the last seen `event_seq` to a local cursor/log.
183
+ 3. Page lines are injected into the conversation immediately. Buffer lines are read deliberately at handoff prep or another safe boundary.
184
+ 4. The agent still uses `wait_for_turn` for turn ownership; these channels are notification surfaces only.
185
+
186
+ Equivalents in other harnesses:
187
+
188
+ - **Codex** — spawn `tt events --follow` as a child process; map stdout lines to `attach` events on the active task. Same shape, different transport name.
189
+ - **OpenCode / Gemini** — long-poll via shell subprocess; whatever the harness calls "background output" is the right hook.
190
+ - **Plain shell (human operator)** — `tt events --follow` in a tmux pane, with human-readable output by default; add `--json` only when piping to a script.
191
+
192
+ The protocol does not need to know which harness is on the other end. The contract is: line in, notification out.
193
+
194
+ ## Token-cost analysis
195
+
196
+ Concrete numbers, since this was the explicit question.
197
+
198
+ **Idle cost: zero.** A backgrounded `tt events --follow` is a child process. It consumes no model tokens while running. The harness keeps a process handle, not a context-window slot.
199
+
200
+ **Per-event cost: small and proportional.** Each page line that Monitor surfaces becomes a notification message in the conversation. A typical page line is on the order of 100–300 tokens with a capped body preview; each `member_joined` buffer line is under 80 tokens and should not enter the conversation until the agent chooses to summarize buffered context.
201
+
202
+ **Annual budget for a busy room:** at, say, 50 events per active hour (very high — typical rooms see far fewer), that is ~5 000 tokens per hour of room activity surfaced into the holder's context. By comparison, a single `get_room_state` call already costs several hundred tokens, and most agents call it on every turn. The watcher is cheap.
203
+
204
+ **Where it actually gets expensive:**
205
+
206
+ - If `note_added` events inline full bodies. Don't — keep full bodies in `list_notes`; page output gets only a capped preview.
207
+ - If watchers don't filter. A holder doesn't need its own `claim` events echoed back. Filter via `--event` and `--target`.
208
+ - If many idle agents all run watchers on the same room. The cost is per-agent-context, not per-room. With N agents, N watchers, N copies of each event in N contexts. Acceptable for small N (≤4 typical), worth revisiting if rooms grow.
209
+ - If the watcher is replaced with a polling loop that calls `get_room_events` every few seconds. That defeats the design — the foreground agent burns tokens making the polling decisions. The watcher's whole point is to push that decision to a child process and only spend tokens on actual events.
210
+
211
+ **On long-poll vs. push:** the watcher process can implement long-poll internally (block on SQLite for up to N seconds, emit on change, re-block). That makes the *process* efficient. But from the *foreground agent's* perspective, push and long-poll are identical — both surface as a stdout line when something happens. So the choice is a server-side performance question, not a token question. v1 can use a 1-second SQLite poll inside `tt events --follow` and still cost zero foreground tokens between events.
212
+
213
+ ## Concrete surface changes
214
+
215
+ ### Service / DB
216
+
217
+ 1. Add `member_joined`, `member_left`, `note_added`, `note_resolved` to the `event_type` enum in `src/types.ts`. The SQLite `room_events.event_type` column is already free text, but migration 5 should add nullable metadata columns for note events: `note_id`, `severity`, `target_agent_id`, and `body_preview`.
218
+ 2. `joinPath`, `leaveRoom`, `addNote` all call `appendEvent(...)` in their respective transactions. They already run inside the same transaction as the state mutation, so atomicity is free.
219
+ 3. Add optional `severity: "info" | "page"` and `target_agent_id` columns to the `notes` table. Default severity `info`. Existing rows back-fill to `info`, no `target`.
220
+ 4. New service method `resolveNote({ agent_id, room_id, note_id })` that flips `resolved_at` / `resolved_by_agent_id` and emits `note_resolved`. Optional for v1 but cheap.
221
+
222
+ ### CLI
223
+
224
+ 1. `tt notes add --severity page --target <agent_id> "body"` — pass-through of new fields.
225
+ 2. `tt events --follow [--after N] [--event T,...] [--severity ...] [--target ...] [--json|--text]` — per Layer 3. Default output follows detected identity: supported harness envs get JSON lines; human fallback gets readable text. `--target self` requires participant identity; observer-only shells must use `--target any` or an explicit agent id.
226
+ 3. `tt notes resolve <note_id>` — wraps `resolveNote`. Optional for v1.
227
+
228
+ ### MCP
229
+
230
+ 1. `add_note` tool gains optional `severity` and `target_agent_id` parameters.
231
+ 2. `get_room_events` already accepts `after_event_seq` and `limit`; no signature change needed for the new event types — they are additive on the discriminated union.
232
+ 3. New MCP tool `resolve_note` — optional for v1.
233
+
234
+ ### Skill
235
+
236
+ The shipped `skills/talking-stick/SKILL.md` gets a section: *"While you hold the stick, you may receive `note_added` events with severity `page`. Read the page preview, call `list_notes` if you need the full body, decide whether to act now or at the next handoff boundary, and resolve it when addressed."* Include the mirror instruction for non-holders: *"To get the holder's attention without taking the stick, use `add_note` with severity `page`."*
237
+
238
+ The skill's wait guidance should remain direct `wait_for_turn` long-polling. Event-stream wakeups for stick availability are future work and require explicit wait-intent state before they can replace the current polling cadence.
239
+
240
+ ## Tradeoffs and open questions
241
+
242
+ - **Why notes-with-severity instead of a separate `messages` primitive?** Notes already are durable, addressable, and resolvable. Adding two fields is cheaper than a parallel messaging table, and the harness-side UX is identical. The risk is conceptual creep: notes today are "things the holder should consider before handoff," and pages stretch that toward "things the holder must consider now." Worth naming explicitly so the skill reflects it.
243
+ - **Should `member_joined` be page-able by default?** No. Joins are too frequent (humans `cd` and out, harnesses restart). Default to `info`. A specific guardian setup can choose to elevate joins by spawning a second `tt events --follow --event member_joined` stream and rendering it loudly.
244
+ - **Heartbeat-stale and takeover-available as events.** Tempting — the watcher could fire one line when the current holder goes stale and another when takeover unlocks. But these are derived states, not log entries; if we synthesize them into the event stream we either need a separate "synthetic events" cursor or we mix derived and persisted events on the same `event_seq`. Recommendation for v1: do not synthesize. Agents that care should continue to long-poll `wait_for_turn`; a future event-driven wait helper can handle derived deadlines after wait intent is modeled explicitly.
245
+ - **Backpressure.** `tt events --follow` writes to stdout. If the harness Monitor stops draining (paused conversation, hit a tool error), the pipe will block. The watcher should use a small bounded write buffer and drop-with-warning rather than blocking forever; design parity with `tail -F`.
246
+ - **Authentication of `target_agent_id`.** Anyone in the room can post a note targeted at anyone else. That matches the existing notes contract (any member can post). If we ever need permissioning, it is a separate concern from this design.
247
+ - **Crash recovery.** Watcher process dies → harness restarts it with `--after <last_seen_event_seq>`. The harness must persist the last-seen seq across restarts; for Claude Code that means the agent writes it to a known location (a memory entry, or a `.talking-stick/` cursor file) before the watcher exits cleanly. Worth specifying in the skill.
248
+ - **Multiple watchers per harness.** Layer 4 suggests two logical streams (page channel + buffer channel). That can be two child processes per agent, or one wrapper process that routes output to separate destinations. The important invariant is routing: page can be Monitor-injected; buffer should not be.
249
+ - **Resolution semantics.** Does a `pass`/`release` auto-resolve outstanding pages? Probably not — the next holder may still need them. But we should mark them as "delivered to holder X at turn Y" so the page does not re-page on every turn. Either a `delivered_at` column on notes, or a per-turn dedup at the harness side. v1 recommendation: dedup at the harness side using `note_id`, no schema change.
250
+
251
+ ## What this plan does NOT yet specify
252
+
253
+ This document is a design proposal for review, not an implementation specification. The following decisions are deliberately deferred to post-review so that reviewer pushback can shape them. Treat each as a real gap an implementer would hit on day one:
254
+
255
+ 1. **Exact migration 5 DDL.** The repo already has a `schema_migrations` runner in `src/db.ts`. Implementation still needs the exact `ALTER TABLE` sequence for `notes` and `room_events`, plus downgrade expectations for older binaries that see unknown event types.
256
+ 2. **`member_left` trigger sites.** Explicit `leaveRoom` is straightforward. Inactivity/GC is not: members can become inactive through liveness checks and opportunistic cleanup. Implementation must decide which transitions emit a durable `member_left`, which reason strings are valid (`"left"`, `"inactive"`, `"gc"`), and how to avoid repeated leave/reactivate noise.
257
+ 3. **`member_joined` debounce semantics.** `joinPath` upserts and touches members today, so harness reconnects, repeated `tt join` invocations, and idle CLI sessions can all retrigger the join path for the same `agent_id`. Implementation must define when a `member_joined` event is durable (first insert vs. inactive-after-meaningful-absence) and what reason strings distinguish first-join from reactivation. Without this, page-tier subscribers to joins get retrigger noise on every reconnect.
258
+ 4. **Skill prose, in full.** The "Skill" subsection above paraphrases the addition. The actual shipped skill text is what every harness reads on every relevant turn, so it needs to be drafted, reviewed, and kept as tight as the rest of `skills/talking-stick/SKILL.md`.
259
+ 5. **Page dedup persistence.** The plan recommends harness-side dedup of pages by `note_id` plus a cursor file for crash recovery. But the dedup set itself is in-memory; after a watcher restart the dedup set is empty, and a still-unresolved page can re-fire on the next event from the buffered tail. Either the cursor file must persist the dedup set too, or the server must offer a "since last delivery to <agent_id>" filter. Pick one before relying on dedup.
260
+ 6. **Test plan.** Not enumerated. At minimum: schema migration on a populated v0.1.x database, event ordering when `addNote` and `release_stick` race, filter correctness on `tt events --follow` for each `--event` / `--severity` / `--target` combination, resume-after-cursor with new event types interleaved with existing ones, and behavior when a watcher's stdout is blocked.
261
+
262
+ These are not blockers for the design discussion — they are inputs to the implementation pass. The architectural decision to keep stick waiting on `wait_for_turn` is intentional; event-driven waiting belongs in a separate wait-intent design.
263
+
264
+ ## Staged rollout
265
+
266
+ 1. **Schema + service:** add new event types, emit on `joinPath`/`leaveRoom`/`addNote`. No CLI/MCP changes yet. Watchers that already follow the event log start seeing the new events immediately.
267
+ 2. **`tt events --follow` extended:** add `--severity` and `--target` filters. CLI tests for filter shape and resume-after-cursor.
268
+ 3. **`add_note` severity + targeting:** schema change to `notes`, plumbed through service, CLI, MCP. Skill updated.
269
+ 4. **Skill rewrite:** holder-side and watcher-side guidance, including page-vs-buffer routing and the reminder that `wait_for_turn` remains the ownership path.
270
+ 5. **Optional: `resolve_note` + `note_resolved` event.** Lets pages stop re-paging across handoffs without harness-side dedup.
271
+ 6. **Optional: derived-event synthesis** (`takeover_available`, `lease_stale`) as a follow-up document if observer demand justifies it.
272
+
273
+ ## What we are not building
274
+
275
+ - No new transport. No websockets, no MCP resource subscriptions in v1 (see [ambient-presence.md](../ambient-presence.md) "Out of scope").
276
+ - No write authority changes. Pages are signals, not commands.
277
+ - No automatic takeover on page. Takeover stays a deliberate act gated on `claim_expires_at`.
278
+ - No harness-specific notification format. Harnesses consume JSON lines; humans get readable text by default.
279
+ - No event-driven replacement for `wait_for_turn`. Stick availability remains governed by the existing wait/claim path until a separate wait-intent design exists.
280
+
281
+ ## Summary
282
+
283
+ The talking stick already has the right primitives for *what* coordinates (rooms, leases, handoffs) and the right primitives for *who* coordinates (agent identity, membership). It is missing primitives for *what flows alongside the work in progress*. This proposal closes that gap with the minimum viable additions:
284
+
285
+ - Four new event types so the existing log carries presence and notes.
286
+ - Two new fields on notes so non-holders can distinguish a hint from a page.
287
+ - One existing CLI surface (`tt events --follow`) extended with two filter flags.
288
+ - A documented harness pattern (page channel + quiet buffer channel) that costs zero idle tokens and proportional per-event tokens.
289
+
290
+ Everything else — transports, write-authority changes, derived events, push channels — is deferred until the simple version is in use and we know what is actually missing.
@@ -0,0 +1,78 @@
1
+ # Talking Stick 0.1.3
2
+
3
+ Date: 2026-04-28
4
+
5
+ Patch release covering one stale-state cleanup tool and one bugfix that
6
+ prevents the duplicate-member problem the cleanup tool addresses.
7
+
8
+ ## Added
9
+
10
+ ### `tt kick` / `kick_member`
11
+
12
+ Rooms occasionally accumulate ghost members — agents that registered, then
13
+ disappeared without leaving cleanly. Until now there was no first-class way to
14
+ evict them. `tt kick <agent_id>` (CLI) and the `kick_member` MCP tool fill that
15
+ gap.
16
+
17
+ ```bash
18
+ tt kick codex:6e030b4c # only succeeds if target is gone
19
+ tt kick codex:6e030b4c --force # force-remove a still-active member
20
+ tt kick codex:6e030b4c --reason "stale guardian"
21
+ ```
22
+
23
+ Default behavior:
24
+
25
+ - caller must be an active member of the room
26
+ - target must not be the caller (use `tt leave` for self-removal)
27
+ - target must be detected `gone` past the existing silence-grace window
28
+ (`2 * heartbeatIntervalMs`); otherwise the call rejects with `target_active`
29
+
30
+ `--force` / `force: true` bypasses the idleness check for cases where liveness
31
+ detection is wrong (PID reuse, suspended processes) or the operator explicitly
32
+ wants to remove a still-running member.
33
+
34
+ State transitions mirror `leave_room`: if the target was the owner, ownership
35
+ and lease state are cleared; if the target was the reservation, the reservation
36
+ is cleared; if no active members remain, the room is deleted. Each successful
37
+ kick records a `kick` room event with `from_agent_id`, `to_agent_id`, and
38
+ optional `reason` so other agents tailing `get_room_events` see the cleanup.
39
+
40
+ New `ProtocolErrorCode`s: `unknown_target`, `target_active`, `cannot_kick_self`.
41
+
42
+ The bundled skill points agents at `kick_member` for cleaning up `inactive`
43
+ ghost members visible in `tt state`, with `force: true` reserved for explicit
44
+ operator instruction.
45
+
46
+ ## Fixed
47
+
48
+ ### Stable codex agent ids across MCP and shelled-out CLI
49
+
50
+ Codex sometimes runs `tt` directly as an MCP subprocess and sometimes shells
51
+ out via Bash to invoke the `tt` CLI. Both paths derive the same `codex:<hash>`
52
+ agent id when codex exposes `CODEX_THREAD_ID`, but when only
53
+ `CODEX_MANAGED_BY_NPM=1` is set the previous logic anchored session id on the
54
+ immediate parent pid:
55
+
56
+ - MCP subprocess: parent = codex root → stable
57
+ - shelled-out CLI: parent = bash subshell → fresh pid every invocation
58
+
59
+ The result was that long codex sessions accumulated multiple `codex:<hash>`
60
+ members in a single room (one per shell-out), each registering as a new
61
+ `human_guardian` and lingering until liveness expired them.
62
+
63
+ `resolveHarnessSessionId` now walks process ancestry when the harness env
64
+ signal exists but no explicit session id is exposed, and uses the deepest
65
+ matching ancestor's `pid+startTime` as the anchor. The harness root is stable
66
+ across MCP subprocess and shell-out CLI invocations, so they collapse to one
67
+ agent id.
68
+
69
+ This is a CLI/MCP-time fix; it does not retroactively merge duplicate members
70
+ already recorded in a room. Use `tt kick` (above) for that.
71
+
72
+ ## Verification
73
+
74
+ - `npm run typecheck`
75
+ - `npm test` — 222 tests across 14 files
76
+ - `npm run build`
77
+ - `git diff --check`
78
+ - `npm pack --dry-run --ignore-scripts`
@@ -0,0 +1,68 @@
1
+ # Talking Stick 0.1.4
2
+
3
+ Date: 2026-04-30
4
+
5
+ Patch release that clarifies the existing `force_new` semantics on `join_path`,
6
+ makes the resulting warnings visible to human CLI users, and stabilizes the
7
+ test suite.
8
+
9
+ ## Fixed
10
+
11
+ ### `force_new` no-op on exact-path joins now surfaces a warning
12
+
13
+ `join_path` has always had two shapes for `force_new=true`: it creates a nested
14
+ room when a different ancestor room exists, and it joins the existing room
15
+ when one already exists at the exact `canonical_path` (because
16
+ `path_rooms.canonical_path` is `UNIQUE`). The second shape is a deliberate
17
+ no-op — `force_new` cannot duplicate a room at the same canonical path — but
18
+ prior versions returned the existing room silently with `joined_existing_room:
19
+ true` and no further signal. Operators and harnesses who flipped `--force-new`
20
+ expecting a fresh room had no way to tell their request had no effect.
21
+
22
+ `join_path` now returns a `warning` field on this no-op path explaining what
23
+ happened and pointing the caller at the only remedy: join a distinct subpath
24
+ (for example a topic-scoped subdirectory) to get a fresh room. The nested-room
25
+ warning that has always existed is unchanged. Both warnings are now also
26
+ rendered in the default `tt join` text output, not just `--json`.
27
+
28
+ In practice this means a Claude Code or Codex harness asking for a new "topic
29
+ room" at the same path now sees an explicit signal that the API only knows
30
+ about path-scoped rooms, instead of silently working off the existing room.
31
+
32
+ The bundled skill's *While waiting* section also gains a more proactive
33
+ framing: the wait window is the right place to re-read the holder's last
34
+ handoff, follow its `artifacts[]`, investigate, and surface findings via
35
+ `add_note` — not idle sleep.
36
+
37
+ ### Contention test no longer races the room-purge clock
38
+
39
+ `tests/talking-stick.test.ts > only one process can claim an idle room under
40
+ contention` reproduced as a `room_not_found` failure when the wall-clock date
41
+ crossed the `idleRoomTtlMs` window relative to the parent test's fake clock
42
+ fixed at 2026-04-22. The contention worker spawned in
43
+ `tests/fixtures/claim-worker.ts` constructed its `TalkingStickService` with
44
+ the real `Date.now()`, so the worker's `purgeExpiredIdleRooms` call evicted
45
+ the room that the parent had just created under the fake clock. The fix
46
+ threads the parent's fake-clock ISO timestamp into the worker so the service
47
+ shares the same `now()` source, and the test passes deterministically
48
+ regardless of wall-clock date.
49
+
50
+ ### Identity-resolver memoization test no longer asserts internal call counts
51
+
52
+ `tests/mcp-server.test.ts > createConnectionIdentityResolver > memoizes
53
+ derived identity per session and re-derives on override` was written when
54
+ `deriveMcpHarnessIdentity` made one `inspector.inspect` call per derive.
55
+ Since 0.1.3's ancestry-walk fix (commit `ab4e843`), a single derive may walk
56
+ multiple parent processes. The resolver-level memoization is still correct;
57
+ the test was asserting the wrong invariant. Updated to assert call-count
58
+ *deltas* across resolver invocations (no growth on memoized hits, growth on
59
+ fresh sessions and overrides) instead of absolute counts, so the test
60
+ remains robust to changes in ancestry-walk depth.
61
+
62
+ ## Verification
63
+
64
+ - `npm run typecheck`
65
+ - `npm test` — 225 tests across 14 files, all passing under Node 24.11.0
66
+ - `npm run build`
67
+ - `git diff --check`
68
+ - `npm pack --dry-run --ignore-scripts`
@@ -316,7 +316,7 @@ Resolution:
316
316
  2. Resolve the preferred workspace root.
317
317
  3. Walk up from the canonical `context_path` to the preferred workspace root looking for an existing room.
318
318
  4. If found and `force_new = false`: join the deepest existing ancestor room.
319
- 5. If found and `force_new = true`: create a nested room at the canonical `context_path`, returning a warning that an ancestor room exists. If a room already exists at that exact path, join it.
319
+ 5. If found and `force_new = true`: create a nested room at the canonical `context_path`, returning a warning that an ancestor room exists. If a room already exists at the exact `canonical_context_path`, join it and return a warning that `force_new` was a no-op. `force_new` only creates *nested* rooms; it never duplicates a room at the same canonical path because `path_rooms.canonical_path` is unique. Callers that want a fresh room for a separate topic must join a distinct subpath.
320
320
  6. If not found: create a new room at the preferred workspace root.
321
321
 
322
322
  The response includes the resolved `room_id`, the `canonical_path` the agent actually joined (which may differ from the request path when workspace root resolution or ancestor lookup redirected the call), the effective room policy (including `heartbeat_interval_ms`), and a `handoff_template` hint describing the expected handoff shape. For the MVP this template is static server-wide; room-specific prompting can be added later if real workflows need it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "MCP coordination server for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: talking-stick
3
- description: Use when working in a repo that coordinates multiple agent harnesses with Talking Stick (`tt` / `talking-stick`), or when the user asks you to avoid parallel work, wait your turn, pass structured handoffs, or coordinate with Claude, Codex, Gemini, or OpenCode in the same workspace. Also use when a workspace contains a `.talking-stick/` marker or when the MCP tools `list_rooms`, `join_path`, `leave_room`, `wait_for_turn`, `heartbeat`, `release_stick`, `pass_stick`, `takeover_stick`, `get_room_state`, `get_room_events`, `add_note`, or `list_notes` are available.
3
+ description: Use when working in a repo that coordinates multiple agent harnesses with Talking Stick (`tt` / `talking-stick`), or when the user asks you to avoid parallel work, wait your turn, pass structured handoffs, or coordinate with Claude, Codex, Gemini, or OpenCode in the same workspace. Also use when a workspace contains a `.talking-stick/` marker or when the MCP tools `list_rooms`, `join_path`, `leave_room`, `kick_member`, `wait_for_turn`, `heartbeat`, `release_stick`, `pass_stick`, `takeover_stick`, `get_room_state`, `get_room_events`, `add_note`, or `list_notes` are available.
4
4
  ---
5
5
 
6
6
  This skill teaches a harness how to behave in a Talking Stick workspace.
@@ -26,7 +26,7 @@ Do not use this skill for ordinary single-agent work in repos that are not using
26
26
 
27
27
  ### 1. Check that Talking Stick is actually available
28
28
 
29
- Prefer the Talking Stick MCP tools when they are available. If they are not available but the `tt` CLI is on `PATH`, use the CLI instead (`tt list`, `tt join`, `tt leave`, `tt wait`, `tt state`, `tt release`, `tt pass`, `tt assign`, `tt take`). Do not treat missing MCP tools alone as proof that coordination is unavailable.
29
+ Prefer the Talking Stick MCP tools when they are available. If they are not available but the `tt` CLI is on `PATH`, use the CLI instead (`tt list`, `tt join`, `tt leave`, `tt kick`, `tt wait`, `tt state`, `tt release`, `tt pass`, `tt assign`, `tt take`). Do not treat missing MCP tools alone as proof that coordination is unavailable.
30
30
 
31
31
  If coordination is required and neither the MCP tools nor the `tt` CLI are available, say so briefly and ask the user whether they want to install or enable Talking Stick first. Do not pretend coordination is active.
32
32
 
@@ -91,7 +91,7 @@ If you do not have the stick:
91
91
  - it is fine to read, plan, review, or help the user think — or any other work that does not mutate shared state
92
92
  - tell the user who currently holds or is reserved the turn when that is useful
93
93
 
94
- If you notice something the current owner should know a subtle invariant near code they are about to touch, a related bug you spotted while reading, a pointer to a doc — leave a note with `add_note` instead of sitting on it until your next turn. Notes do not grant permission to edit shared files; they are observations and pointers, not coordination bypasses.
94
+ The wait is for *active* non-mutating work, not idle sleep. Re-read the holder's last handoff, follow up on its `artifacts[]`, investigate the area they are touching, and rethink the plan from your own angle. If you find something the holder should know — a missed invariant, a related bug, a sharper plan — leave a note with `add_note` rather than sitting on it until your next turn. Notes do not grant permission to edit shared files; they are observations and pointers, not coordination bypasses. The point: while you wait you can still move the work forward by feeding the holder, not by stalling.
95
95
 
96
96
  When you do take the stick, first read the attached handoff and load any useful `artifacts[]`, then run `list_notes` once so you see what other members left for you. The owner's turn is the right place to act on a note, not to debate it with its author mid-turn.
97
97
 
@@ -178,12 +178,15 @@ In every other case: after `release_stick` or `pass_stick`, go straight back int
178
178
 
179
179
  If the operator tells you to drop out of coordination, call `leave_room` or `tt leave`. Rooms with no active members are deleted instead of kept as history, and long-idle rooms may be purged on later invocations.
180
180
 
181
+ If the room state shows ghost members from past sessions whose processes are gone (visible as `inactive last seen ...` in `tt state`), call `kick_member` / `tt kick <agent_id>` to evict them. This is the right tool when liveness has already decided the target is dead — pass `force: true` only when the operator explicitly tells you to remove a still-active member.
182
+
181
183
  ## Recovery and Inspection
182
184
 
183
185
  Use these reads when you need context:
184
186
 
185
187
  - `list_rooms`: discover active rooms under a path
186
188
  - `leave_room`: explicitly remove your membership from a room
189
+ - `kick_member`: evict an idle member whose process is gone (use `force: true` only on operator instruction)
187
190
  - `get_room_state`: authoritative current room projection
188
191
  - `get_room_events`: replay recent claims, releases, passes, and takeovers
189
192