talking-stick 0.1.0-alpha.4 → 0.1.0-alpha.6

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Talking Stick contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  An MCP coordination server that lets multiple AI coding agents share a single workspace without stepping on each other. One agent holds the stick at a time; handoffs carry structured context so the next agent doesn't have to re-derive it.
4
4
 
5
- **Version:** 0.1.0-alpha.4. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, and OpenCode out of the box.
5
+ **Version:** 0.1.0-alpha.6. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, and OpenCode out of the box.
6
6
 
7
7
  ## Quickstart
8
8
 
@@ -27,7 +27,7 @@ That's it. The next time two agents `cd` into the same repo, they see each other
27
27
 
28
28
  | Method | Command | Notes |
29
29
  |---|---|---|
30
- | **From npm** | `npm i -g talking-stick` | Published as `0.1.0-alpha.4`. Requires Node ≥ 22. |
30
+ | **From npm** | `npm i -g talking-stick` | Published as `0.1.0-alpha.6`. Requires Node ≥ 22. |
31
31
  | **From GitHub** | `npm i -g github:mostlydev/talking-stick` | Tracks the `master` branch; builds on install via the `prepare` hook. |
32
32
  | **From source** | `git clone … && npm install && npm link` | For contributors. |
33
33
 
@@ -65,6 +65,7 @@ Once installed, each agent harness sees these tools:
65
65
  ```
66
66
  list_rooms — which rooms exist under a path
67
67
  join_path — join the room for this workspace
68
+ leave_room — explicitly leave a room; deletes it when no active members remain
68
69
  wait_for_turn — block until the stick is available, with takeover signals
69
70
  heartbeat — prove liveness while holding the stick
70
71
  release_stick — normal handoff to the next fair waiter, with structured Handoff
@@ -127,6 +128,7 @@ The same `tt` binary also works as a human CLI, useful for watching or participa
127
128
  tt whoami [--explain] # show the resolved CLI identity
128
129
  tt list [path] # list rooms
129
130
  tt join [path] [--force-new] # join the room for path
131
+ tt leave [path] # leave the room for path
130
132
  tt wait [path] [--timeout 30s] # block until your turn
131
133
  tt try [path] # non-blocking claim attempt
132
134
  tt state [path] # full room state
@@ -152,14 +154,15 @@ Human CLI commands use a stable identity like `human:<username>`. When `tt wait`
152
154
 
153
155
  ### CLI identity
154
156
 
155
- By default, `tt` behaves like a human CLI and resolves to `human:<username>`, even when you run it from a shell embedded inside Claude Code, Codex, Gemini, or OpenCode.
157
+ By default, `tt` behaves like a human CLI and resolves to `human:<username>` only when no harness environment is detected.
156
158
 
157
- Harness-aware CLI identity is now explicit:
159
+ Harness-aware CLI identity is resolved before the human fallback:
158
160
 
159
- - Set `TT_HARNESS_EXPORT=1` if you want `tt` to derive a harness-style identity from the current environment and process ancestry.
161
+ - Known harness environment markers such as `CLAUDECODE=1`, `CODEX_THREAD_ID`, `GEMINI_CLI=1`, or `OPENCODE=1` make `tt` derive a harness-style identity automatically.
160
162
  - Set `TT_HARNESS_AGENT_ID=<agent-id>` if the harness wants to export the exact agent id directly.
163
+ - Set `TT_HARNESS_EXPORT=1` only when you need ancestry-based harness detection without a known harness environment marker.
161
164
 
162
- If neither variable is set, `tt` stays on the human CLI path. That keeps ordinary shell usage predictable and avoids silently turning a human terminal into a harness participant.
165
+ If no harness signal is present, `tt` stays on the human CLI path. That keeps ordinary shell usage predictable while preventing harness-launched shells from silently joining rooms as `human:<username>`.
163
166
 
164
167
  Use `tt whoami --explain` to see which identity path the CLI chose.
165
168
 
@@ -168,6 +171,8 @@ Use `tt whoami --explain` to see which identity path the CLI chose.
168
171
  - **Workspace-root room resolution.** An agent at any depth under `/repo/` joins the `/repo/` room automatically. Nested rooms require explicit `force_new`.
169
172
  - **Structured handoffs.** `release_stick` and `pass_stick` carry a typed `Handoff` with required `status` / `next_action` and optional `artifacts[]` pointing at specific files and line ranges.
170
173
  - **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.
174
+ - **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.
175
+ - **Ephemeral rooms.** `leave_room`/`tt leave` removes membership, rooms with no active members are physically deleted, and long-idle rooms are purged opportunistically on later invocations.
171
176
  - **Fencing tokens.** `lease_id` + `turn_id` make stale writes impossible — an agent who lost their turn cannot commit anything under the room's name.
172
177
  - **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.
173
178
  - **Multi-process safe.** Shared SQLite with WAL mode, `BEGIN IMMEDIATE` writes, 250 ms polling for `wait_for_turn`. No daemon required.
@@ -204,4 +209,4 @@ See [`CHANGELOG.md`](CHANGELOG.md) for a per-version summary; full release notes
204
209
 
205
210
  ## License
206
211
 
207
- Unlicensed WIP. To be decided before the first release.
212
+ MIT. See [LICENSE](LICENSE).
@@ -25,10 +25,17 @@ export function resolveCliIdentity(parsed, env = process.env) {
25
25
  detail: "Resolved from explicit TT_HARNESS_AGENT_ID export."
26
26
  };
27
27
  }
28
+ if (env.TT_HARNESS_EXPORT === "1" || env.TT_HARNESS_EXPORT?.toLowerCase() === "true") {
29
+ return {
30
+ identity: harnessIdentity,
31
+ source: "harness_cli_exported_detection",
32
+ detail: "Resolved as harness CLI because TT_HARNESS_EXPORT enabled harness-aware detection."
33
+ };
34
+ }
28
35
  return {
29
36
  identity: harnessIdentity,
30
- source: "harness_cli_exported_detection",
31
- detail: "Resolved as harness CLI because TT_HARNESS_EXPORT enabled harness-aware detection."
37
+ source: "harness_cli_env_detection",
38
+ detail: "Resolved as harness CLI from known harness environment variables."
32
39
  };
33
40
  }
34
41
  if (env.TT_HARNESS_EXPORT?.trim()) {
@@ -143,9 +143,7 @@ function runInheritIo(command, args) {
143
143
  function reportInstallResults(results, mode) {
144
144
  let anyFailed = false;
145
145
  for (const result of results) {
146
- if (result.skipped)
147
- continue;
148
- const status = result.ok ? "ok" : "FAIL";
146
+ const status = formatInstallStatus(result.status);
149
147
  process.stdout.write(`[${result.harness}] ${status}: ${result.message}\n`);
150
148
  if (!result.ok)
151
149
  anyFailed = true;
@@ -154,3 +152,6 @@ function reportInstallResults(results, mode) {
154
152
  throw new Error(`${mode} completed with failures.`);
155
153
  }
156
154
  }
155
+ function formatInstallStatus(status) {
156
+ return status.replaceAll("_", "-");
157
+ }
@@ -1,4 +1,5 @@
1
1
  import { SUPPORTED_HARNESSES } from "../install.js";
2
+ import { isKnownHarnessCliEnv } from "./identity.js";
2
3
  import { hasOption } from "./parser.js";
3
4
  export function printResult(parsed, result, renderText) {
4
5
  if (shouldUseJson(parsed)) {
@@ -12,12 +13,13 @@ export function shouldUseJson(parsed, env = process.env) {
12
13
  return true;
13
14
  if (hasOption(parsed, "text"))
14
15
  return false;
15
- // Auto-JSON when invoked from a harness, using the same opt-in gate as
16
- // identity resolution.
16
+ // Auto-JSON when invoked from a harness, using the same detection as
17
+ // identity resolution. TT_HARNESS_EXPORT remains an explicit opt-in for
18
+ // ancestry-only detection where no harness env marker is present.
17
19
  const exportFlag = env.TT_HARNESS_EXPORT;
18
20
  if (exportFlag === "1" || exportFlag?.toLowerCase() === "true")
19
21
  return true;
20
- if (env.TT_HARNESS_AGENT_ID?.trim())
22
+ if (isKnownHarnessCliEnv(env))
21
23
  return true;
22
24
  return false;
23
25
  }
@@ -119,6 +121,7 @@ Commands:
119
121
  tt whoami [--explain]
120
122
  tt list [path]
121
123
  tt join [path] [--force-new]
124
+ tt leave [path]
122
125
  tt wait [path] [--timeout 30s]
123
126
  tt try [path]
124
127
  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, handleListCommand, handleStateCommand, handleWhoAmICommand } from "./room-commands.js";
5
+ import { handleEventsCommand, handleJoinCommand, 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
  {
@@ -95,6 +95,15 @@ export const COMMAND_REGISTRY = [
95
95
  description: "Join the room for a workspace path.",
96
96
  handler: ({ runtime, parsed }) => handleJoinCommand(requireRuntime(runtime), parsed)
97
97
  },
98
+ {
99
+ name: "leave",
100
+ needsRuntime: true,
101
+ startupMaintenance: true,
102
+ internal: false,
103
+ usage: "tt leave [path]",
104
+ description: "Leave this agent's room membership.",
105
+ handler: ({ runtime, parsed }) => handleLeaveCommand(requireRuntime(runtime), parsed)
106
+ },
98
107
  {
99
108
  name: "state",
100
109
  needsRuntime: true,
@@ -1,4 +1,6 @@
1
1
  import { deriveCliIdentity, resolveCliIdentity } from "./identity.js";
2
+ import { removeCliSession, removeCliSessionsForRoom, resolveCliSessionPath } from "../index.js";
3
+ import { stopGuardian } from "./guardian.js";
2
4
  import { parseOptionalInteger } from "./parser.js";
3
5
  import { formatRelativeTime, printResult } from "./output.js";
4
6
  import { resolveSessionForReads, upsertSessionFromJoin } from "./session.js";
@@ -32,6 +34,28 @@ export function handleJoinCommand(runtime, parsed) {
32
34
  return `Joined ${joined.canonical_path} as ${joined.agent_id}`;
33
35
  });
34
36
  }
37
+ export function handleLeaveCommand(runtime, parsed) {
38
+ const identity = deriveCliIdentity(parsed);
39
+ const session = resolveSessionForReads(runtime, parsed, identity);
40
+ const result = runtime.commands.leaveRoom(identity, {
41
+ room_id: session.room_id
42
+ });
43
+ const sessionPath = resolveCliSessionPath();
44
+ if (result.status === "room_deleted") {
45
+ removeCliSessionsForRoom(sessionPath, session.room_id);
46
+ }
47
+ else {
48
+ removeCliSession(sessionPath, identity.agent_id, session.room_id);
49
+ }
50
+ stopGuardian(session.guardian_pid ?? null, session.guardian_process_started_at ?? null);
51
+ printResult(parsed, result, () => {
52
+ if (result.status === "room_deleted") {
53
+ return `Left ${session.canonical_path}; room deleted.`;
54
+ }
55
+ const memberLabel = result.remaining_members === 1 ? "member remains" : "members remain";
56
+ return `Left ${session.canonical_path}; ${result.remaining_members} ${memberLabel}.`;
57
+ });
58
+ }
35
59
  export function handleStateCommand(runtime, parsed) {
36
60
  const identity = deriveCliIdentity(parsed);
37
61
  const session = resolveSessionForReads(runtime, parsed, identity);
package/dist/commands.js CHANGED
@@ -18,6 +18,12 @@ export class TalkingStickCommands {
18
18
  process_metadata: identity.process_metadata
19
19
  });
20
20
  }
21
+ leaveRoom(identity, input) {
22
+ return this.service.leaveRoom({
23
+ agent_id: identity.agent_id,
24
+ room_id: input.room_id
25
+ });
26
+ }
21
27
  waitForTurn(identity, input) {
22
28
  return this.service.waitForTurn({
23
29
  agent_id: identity.agent_id,
package/dist/config.js CHANGED
@@ -7,7 +7,8 @@ export const defaultPolicy = {
7
7
  waitForTurnMaxWaitMs: 30 * 1000,
8
8
  waitForTurnPollMs: 250,
9
9
  presenceTtlMs: 4 * 60 * 60 * 1000,
10
- waiterGraceMs: 10 * 1000
10
+ waiterGraceMs: 10 * 1000,
11
+ idleRoomTtlMs: 7 * 24 * 60 * 60 * 1000
11
12
  };
12
13
  export function resolveDataDir(options = {}) {
13
14
  const env = options.env ?? process.env;
package/dist/identity.js CHANGED
@@ -34,39 +34,40 @@ export function deriveHumanCliIdentity(options = {}) {
34
34
  export function deriveMcpHarnessIdentity(options = {}) {
35
35
  const env = options.env ?? process.env;
36
36
  const inspector = options.inspector ?? createSystemProcessInspector();
37
- const pid = options.parentPid ?? process.ppid;
37
+ const parentPid = options.parentPid ?? process.ppid;
38
38
  const hostId = options.hostId ?? os.hostname();
39
39
  const username = options.username ?? safeUsername();
40
- const inspection = inspector.inspect(pid);
40
+ const parentInspection = inspector.inspect(parentPid);
41
41
  const signal = detectHarnessSignal(env);
42
42
  if (signal) {
43
- const sessionId = resolveHarnessSessionId(signal, env, pid, inspection, username, hostId);
43
+ const processRef = resolveSignalProcessRef(signal, parentPid, parentInspection, inspector);
44
+ const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId);
44
45
  const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
45
46
  return {
46
47
  agent_id: agentId,
47
48
  process_metadata: {
48
49
  host_id: hostId,
49
- pid,
50
- process_started_at: inspection?.startTime ?? null,
50
+ pid: processRef.pid,
51
+ process_started_at: processRef.inspection?.startTime ?? null,
51
52
  session_kind: "mcp_harness",
52
53
  display_name: signal.harness
53
54
  }
54
55
  };
55
56
  }
56
- const displayName = options.displayName ?? deriveCommandLabel(inspection?.command ?? null);
57
+ const displayName = options.displayName ?? deriveCommandLabel(parentInspection?.command ?? null);
57
58
  const agentId = options.agentId ??
58
59
  `${sanitizeIdentityComponent(displayName)}:${hashIdentityParts([
59
60
  hostId,
60
- String(pid),
61
- inspection?.startTime ?? "",
61
+ String(parentPid),
62
+ parentInspection?.startTime ?? "",
62
63
  options.sessionId ?? ""
63
64
  ])}`;
64
65
  return {
65
66
  agent_id: agentId,
66
67
  process_metadata: {
67
68
  host_id: hostId,
68
- pid,
69
- process_started_at: inspection?.startTime ?? null,
69
+ pid: parentPid,
70
+ process_started_at: parentInspection?.startTime ?? null,
70
71
  session_kind: "mcp_harness",
71
72
  display_name: displayName
72
73
  }
@@ -91,24 +92,25 @@ export function deriveHarnessCliIdentity(options = {}) {
91
92
  }
92
93
  };
93
94
  }
94
- if (!isHarnessCliExportEnabled(env)) {
95
+ let signal = detectHarnessSignal(env);
96
+ if (!signal && !isHarnessCliExportEnabled(env)) {
95
97
  return null;
96
98
  }
97
- let signal = detectHarnessSignal(env);
98
99
  if (!signal) {
99
100
  signal = detectHarnessViaAncestry(parentPid, inspector);
100
101
  }
101
102
  if (!signal)
102
103
  return null;
104
+ const processRef = resolveSignalProcessRef(signal, parentPid, parentInspection, inspector);
103
105
  const username = options.username ?? safeUsername();
104
- const sessionId = resolveHarnessSessionId(signal, env, parentPid, parentInspection, username, hostId);
106
+ const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId);
105
107
  const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
106
108
  return {
107
109
  agent_id: agentId,
108
110
  process_metadata: {
109
111
  host_id: hostId,
110
- pid: parentPid,
111
- process_started_at: parentInspection?.startTime ?? null,
112
+ pid: processRef.pid,
113
+ process_started_at: processRef.inspection?.startTime ?? null,
112
114
  session_kind: "harness_cli",
113
115
  display_name: options.displayName ?? signal.harness
114
116
  }
@@ -180,7 +182,8 @@ function detectHarnessViaAncestry(pid, inspector, maxDepth = 10) {
180
182
  if (HARNESS_COMMAND_MAPPING[label]) {
181
183
  return {
182
184
  harness: HARNESS_COMMAND_MAPPING[label],
183
- sessionId: `pid:${inspection.pid}@${inspection.startTime}`
185
+ sessionId: `pid:${inspection.pid}@${inspection.startTime}`,
186
+ pidHint: null
184
187
  };
185
188
  }
186
189
  currentPid = inspection.ppid;
@@ -189,22 +192,53 @@ function detectHarnessViaAncestry(pid, inspector, maxDepth = 10) {
189
192
  }
190
193
  function detectHarnessSignal(env) {
191
194
  if (env.CLAUDECODE === "1") {
192
- return { harness: "claude", sessionId: nonEmpty(env.CLAUDE_CODE_EXECPATH) };
195
+ return {
196
+ harness: "claude",
197
+ sessionId: null,
198
+ pidHint: parsePositiveInteger(env.CMUX_CLAUDE_PID)
199
+ };
193
200
  }
194
201
  if (env.CODEX_MANAGED_BY_NPM === "1" || nonEmpty(env.CODEX_THREAD_ID)) {
195
- return { harness: "codex", sessionId: nonEmpty(env.CODEX_THREAD_ID) };
202
+ return {
203
+ harness: "codex",
204
+ sessionId: nonEmpty(env.CODEX_THREAD_ID),
205
+ pidHint: null
206
+ };
196
207
  }
197
208
  if (env.GEMINI_CLI === "1") {
198
- return { harness: "gemini", sessionId: null };
209
+ return { harness: "gemini", sessionId: null, pidHint: null };
199
210
  }
200
211
  if (env.OPENCODE === "1") {
201
212
  return {
202
213
  harness: "opencode",
203
- sessionId: nonEmpty(env.OPENCODE_RUN_ID) ?? nonEmpty(env.OPENCODE_PID)
214
+ sessionId: nonEmpty(env.OPENCODE_RUN_ID) ?? nonEmpty(env.OPENCODE_PID),
215
+ pidHint: null
204
216
  };
205
217
  }
206
218
  return null;
207
219
  }
220
+ function resolveSignalProcessRef(signal, fallbackPid, fallbackInspection, inspector) {
221
+ if (signal.pidHint && signal.pidHint !== fallbackPid) {
222
+ const hintedInspection = inspector.inspect(signal.pidHint);
223
+ if (hintedInspection?.startTime) {
224
+ return {
225
+ pid: signal.pidHint,
226
+ inspection: hintedInspection
227
+ };
228
+ }
229
+ }
230
+ return {
231
+ pid: fallbackPid,
232
+ inspection: fallbackInspection
233
+ };
234
+ }
235
+ function parsePositiveInteger(value) {
236
+ if (!value || !/^\d+$/.test(value.trim())) {
237
+ return null;
238
+ }
239
+ const parsed = Number(value.trim());
240
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
241
+ }
208
242
  function nonEmpty(value) {
209
243
  return value && value.trim().length > 0 ? value : null;
210
244
  }
package/dist/index.js CHANGED
@@ -8,5 +8,5 @@ export { createMcpServer, runStdioServer } from "./mcp-server.js";
8
8
  export { SUPPORTED_HARNESSES, MissingHarnessError, detectHarness, parseHarnessList, planInstall, planUninstall, resolveHarnessConfigDir, resolveOpencodeConfigDir, resolveOpencodeConfigPath, runAction, skipAction } from "./install.js";
9
9
  export { DEFAULT_SKILL_NAME, planSkillInstall, planSkillUninstall, resolveBundledSkillPath, resolveSkillTargetPath, syncInstalledSkills } from "./skill-install.js";
10
10
  export { createSystemProcessInspector, terminateKnownProcess } from "./process-utils.js";
11
- export { clearCliSessionLease, findCliSessionByRoom, findCliSessionForContextPath, readCliSessions, resolveCliSessionPath, upsertCliSession, upsertJoinedCliSession, writeCliSessions } from "./session-store.js";
11
+ export { clearCliSessionLease, findCliSessionByRoom, findCliSessionForContextPath, readCliSessions, removeCliSession, removeCliSessionsForRoom, resolveCliSessionPath, upsertCliSession, upsertJoinedCliSession, writeCliSessions } from "./session-store.js";
12
12
  export { TalkingStickService, createDefaultProcessLivenessChecker } from "./service.js";
package/dist/install.js CHANGED
@@ -130,7 +130,10 @@ export function planInstall(harness, options = {}) {
130
130
  harness,
131
131
  command: "claude",
132
132
  args: ["mcp", "add", "-s", "user", resolved.serverName, "--", serverBin, ...serverArgs],
133
- description: `claude mcp add -s user ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`
133
+ description: `claude mcp add -s user ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`,
134
+ operation: "install",
135
+ serverName: resolved.serverName,
136
+ serverCommand: resolved.serverCommand
134
137
  };
135
138
  case "codex":
136
139
  if (resolved.skipMissing && !resolved.hooks.which("codex")) {
@@ -141,7 +144,10 @@ export function planInstall(harness, options = {}) {
141
144
  harness,
142
145
  command: "codex",
143
146
  args: ["mcp", "add", resolved.serverName, "--", serverBin, ...serverArgs],
144
- description: `codex mcp add ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`
147
+ description: `codex mcp add ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`,
148
+ operation: "install",
149
+ serverName: resolved.serverName,
150
+ serverCommand: resolved.serverCommand
145
151
  };
146
152
  case "gemini":
147
153
  if (resolved.skipMissing && !resolved.hooks.which("gemini")) {
@@ -152,7 +158,10 @@ export function planInstall(harness, options = {}) {
152
158
  harness,
153
159
  command: "gemini",
154
160
  args: ["mcp", "add", "-s", "user", "-t", "stdio", resolved.serverName, serverBin, ...serverArgs],
155
- description: `gemini mcp add -s user -t stdio ${resolved.serverName} ${resolved.serverCommand.join(" ")}`
161
+ description: `gemini mcp add -s user -t stdio ${resolved.serverName} ${resolved.serverCommand.join(" ")}`,
162
+ operation: "install",
163
+ serverName: resolved.serverName,
164
+ serverCommand: resolved.serverCommand
156
165
  };
157
166
  case "opencode": {
158
167
  const filePath = resolveOpencodeConfigPath(options);
@@ -165,6 +174,9 @@ export function planInstall(harness, options = {}) {
165
174
  harness,
166
175
  filePath,
167
176
  description: `merge mcp.${resolved.serverName} into ${filePath}`,
177
+ operation: "install",
178
+ serverName: resolved.serverName,
179
+ inspect: () => inspectOpencodeConfig(filePath, resolved),
168
180
  apply: () => patchOpencodeConfig(filePath, resolved, "install")
169
181
  };
170
182
  }
@@ -184,7 +196,9 @@ export function planUninstall(harness, options = {}) {
184
196
  harness,
185
197
  command: "claude",
186
198
  args: ["mcp", "remove", "-s", "user", resolved.serverName],
187
- description: `claude mcp remove -s user ${resolved.serverName}`
199
+ description: `claude mcp remove -s user ${resolved.serverName}`,
200
+ operation: "uninstall",
201
+ serverName: resolved.serverName
188
202
  };
189
203
  case "codex":
190
204
  if (resolved.skipMissing && !resolved.hooks.which("codex")) {
@@ -195,7 +209,9 @@ export function planUninstall(harness, options = {}) {
195
209
  harness,
196
210
  command: "codex",
197
211
  args: ["mcp", "remove", resolved.serverName],
198
- description: `codex mcp remove ${resolved.serverName}`
212
+ description: `codex mcp remove ${resolved.serverName}`,
213
+ operation: "uninstall",
214
+ serverName: resolved.serverName
199
215
  };
200
216
  case "gemini":
201
217
  if (resolved.skipMissing && !resolved.hooks.which("gemini")) {
@@ -206,7 +222,9 @@ export function planUninstall(harness, options = {}) {
206
222
  harness,
207
223
  command: "gemini",
208
224
  args: ["mcp", "remove", "-s", "user", resolved.serverName],
209
- description: `gemini mcp remove -s user ${resolved.serverName}`
225
+ description: `gemini mcp remove -s user ${resolved.serverName}`,
226
+ operation: "uninstall",
227
+ serverName: resolved.serverName
210
228
  };
211
229
  case "opencode": {
212
230
  const filePath = resolveOpencodeConfigPath(options);
@@ -222,6 +240,9 @@ export function planUninstall(harness, options = {}) {
222
240
  harness,
223
241
  filePath,
224
242
  description: `remove mcp.${resolved.serverName} from ${filePath}`,
243
+ operation: "uninstall",
244
+ serverName: resolved.serverName,
245
+ inspect: () => inspectOpencodeConfig(filePath, resolved),
225
246
  apply: () => patchOpencodeConfig(filePath, resolved, "uninstall")
226
247
  };
227
248
  }
@@ -264,6 +285,47 @@ function patchOpencodeConfig(filePath, resolved, mode) {
264
285
  resolved.hooks.ensureDir(path.dirname(filePath));
265
286
  resolved.hooks.writeFile(filePath, JSON.stringify(config, null, 2) + "\n");
266
287
  }
288
+ function inspectOpencodeConfig(filePath, resolved) {
289
+ const existing = resolved.hooks.readFile(filePath);
290
+ if (existing === null)
291
+ return "absent";
292
+ let config;
293
+ try {
294
+ config = parseJsonOrThrow(existing, filePath);
295
+ }
296
+ catch {
297
+ return "unknown";
298
+ }
299
+ const mcp = isPlainObject(config.mcp) ? config.mcp : {};
300
+ if (!(resolved.serverName in mcp))
301
+ return "absent";
302
+ const expected = {
303
+ type: "local",
304
+ command: [...resolved.serverCommand],
305
+ enabled: true
306
+ };
307
+ return valuesEqual(mcp[resolved.serverName], expected) ? "present" : "different";
308
+ }
309
+ function inspectGeminiSettings(action, resolved) {
310
+ const filePath = path.join(resolveHarnessConfigDirFromResolved("gemini", resolved), "settings.json");
311
+ const existing = resolved.hooks.readFile(filePath);
312
+ if (existing === null)
313
+ return "absent";
314
+ let config;
315
+ try {
316
+ config = parseJsonOrThrow(existing, filePath);
317
+ }
318
+ catch {
319
+ return "unknown";
320
+ }
321
+ const servers = isPlainObject(config.mcpServers) ? config.mcpServers : {};
322
+ const serverName = action.serverName ?? resolved.serverName;
323
+ if (!(serverName in servers))
324
+ return "absent";
325
+ const [command, ...args] = action.serverCommand ?? resolved.serverCommand;
326
+ const expected = { command, args };
327
+ return valuesEqual(servers[serverName], expected) ? "present" : "different";
328
+ }
267
329
  function parseJsonOrThrow(raw, filePath) {
268
330
  try {
269
331
  const parsed = JSON.parse(raw);
@@ -279,6 +341,9 @@ function parseJsonOrThrow(raw, filePath) {
279
341
  function isPlainObject(value) {
280
342
  return typeof value === "object" && value !== null && !Array.isArray(value);
281
343
  }
344
+ function valuesEqual(left, right) {
345
+ return JSON.stringify(left) === JSON.stringify(right);
346
+ }
282
347
  export function detectHarness(harness, options = {}) {
283
348
  const resolved = resolveOptions(options);
284
349
  switch (harness) {
@@ -333,6 +398,7 @@ export async function runAction(action, options = {}) {
333
398
  harness: action.harness,
334
399
  ok: true,
335
400
  action,
401
+ status: "skipped",
336
402
  message: action.message,
337
403
  skipped: true
338
404
  };
@@ -344,6 +410,7 @@ export async function runAction(action, options = {}) {
344
410
  harness: action.harness,
345
411
  ok: resolved.skipMissing,
346
412
  action,
413
+ status: resolved.skipMissing ? "skipped" : "failed",
347
414
  message: `${action.command} not on PATH`,
348
415
  skipped: resolved.skipMissing
349
416
  };
@@ -353,9 +420,29 @@ export async function runAction(action, options = {}) {
353
420
  harness: action.harness,
354
421
  ok: false,
355
422
  action,
423
+ status: "failed",
356
424
  message: invocation.error
357
425
  };
358
426
  }
427
+ const beforeState = await inspectExecAction(action, resolved);
428
+ if (action.operation === "install" && beforeState === "present") {
429
+ return {
430
+ harness: action.harness,
431
+ ok: true,
432
+ action,
433
+ status: "already_present",
434
+ message: formatMcpActionMessage(action, "already_present")
435
+ };
436
+ }
437
+ if (action.operation === "uninstall" && beforeState === "absent") {
438
+ return {
439
+ harness: action.harness,
440
+ ok: true,
441
+ action,
442
+ status: "already_absent",
443
+ message: formatMcpActionMessage(action, "already_absent")
444
+ };
445
+ }
359
446
  let result;
360
447
  try {
361
448
  result = await resolved.hooks.run(invocation.command, invocation.args, invocation.options);
@@ -365,31 +452,75 @@ export async function runAction(action, options = {}) {
365
452
  harness: action.harness,
366
453
  ok: false,
367
454
  action,
455
+ status: "failed",
368
456
  message: formatExecError(error)
369
457
  };
370
458
  }
371
459
  if (result.exitCode === 0) {
460
+ const status = successStatusForOperation(action.operation, beforeState);
461
+ return {
462
+ harness: action.harness,
463
+ ok: true,
464
+ action,
465
+ status,
466
+ message: formatMcpActionMessage(action, status, result.stdout.trim() || undefined)
467
+ };
468
+ }
469
+ const errorMessage = result.stderr.trim() || result.stdout.trim() || `${action.command} exited with code ${result.exitCode}`;
470
+ if (action.operation === "install" && isAlreadyPresentMessage(errorMessage)) {
471
+ return {
472
+ harness: action.harness,
473
+ ok: true,
474
+ action,
475
+ status: "already_present",
476
+ message: formatMcpActionMessage(action, "already_present")
477
+ };
478
+ }
479
+ if (action.operation === "uninstall" && isAlreadyAbsentMessage(errorMessage)) {
372
480
  return {
373
481
  harness: action.harness,
374
482
  ok: true,
375
483
  action,
376
- message: result.stdout.trim() || `${action.command} succeeded`
484
+ status: "already_absent",
485
+ message: formatMcpActionMessage(action, "already_absent")
377
486
  };
378
487
  }
379
488
  return {
380
489
  harness: action.harness,
381
490
  ok: false,
382
491
  action,
383
- message: (result.stderr.trim() || result.stdout.trim() || `${action.command} exited with code ${result.exitCode}`)
492
+ status: "failed",
493
+ message: errorMessage
494
+ };
495
+ }
496
+ const beforeState = action.inspect?.() ?? "unknown";
497
+ if (action.operation === "install" && beforeState === "present") {
498
+ return {
499
+ harness: action.harness,
500
+ ok: true,
501
+ action,
502
+ status: "already_present",
503
+ message: formatMcpActionMessage(action, "already_present")
504
+ };
505
+ }
506
+ if (action.operation === "uninstall" && beforeState === "absent") {
507
+ return {
508
+ harness: action.harness,
509
+ ok: true,
510
+ action,
511
+ status: "already_absent",
512
+ message: formatMcpActionMessage(action, "already_absent")
384
513
  };
385
514
  }
386
515
  try {
387
516
  action.apply();
517
+ const status = successStatusForOperation(action.operation, beforeState);
388
518
  return {
389
519
  harness: action.harness,
390
520
  ok: true,
391
521
  action,
392
- message: `Updated ${action.filePath}`
522
+ status,
523
+ message: formatMcpActionMessage(action, status, `Updated ${action.filePath}`)
393
524
  };
394
525
  }
395
526
  catch (error) {
@@ -398,6 +529,7 @@ export async function runAction(action, options = {}) {
398
529
  harness: action.harness,
399
530
  ok: true,
400
531
  action,
532
+ status: "skipped",
401
533
  message: error.message,
402
534
  skipped: true
403
535
  };
@@ -406,10 +538,83 @@ export async function runAction(action, options = {}) {
406
538
  harness: action.harness,
407
539
  ok: false,
408
540
  action,
541
+ status: "failed",
409
542
  message: error.message
410
543
  };
411
544
  }
412
545
  }
546
+ async function inspectExecAction(action, resolved) {
547
+ if (!action.operation || !action.serverName)
548
+ return "unknown";
549
+ if (action.harness === "gemini") {
550
+ return inspectGeminiSettings(action, resolved);
551
+ }
552
+ if (action.harness !== "claude-code" && action.harness !== "codex") {
553
+ return "unknown";
554
+ }
555
+ const invocation = resolveCommandInvocation(action.command, ["mcp", "get", action.serverName], resolved);
556
+ if (!invocation || "error" in invocation)
557
+ return "unknown";
558
+ try {
559
+ const result = await resolved.hooks.run(invocation.command, invocation.args, invocation.options);
560
+ return result.exitCode === 0 ? "present" : "absent";
561
+ }
562
+ catch {
563
+ return "unknown";
564
+ }
565
+ }
566
+ function successStatusForOperation(operation, beforeState) {
567
+ if (operation === "install") {
568
+ return beforeState === "different" ? "updated" : "added";
569
+ }
570
+ if (operation === "uninstall") {
571
+ return "removed";
572
+ }
573
+ return "ok";
574
+ }
575
+ function formatMcpActionMessage(action, status, fallback) {
576
+ if (!action.serverName || !action.operation) {
577
+ return fallback ?? "ok";
578
+ }
579
+ const target = `MCP server '${action.serverName}'`;
580
+ const location = mcpConfigLocation(action);
581
+ switch (status) {
582
+ case "added":
583
+ return `${target} registered in ${location}.`;
584
+ case "updated":
585
+ return `${target} updated in ${location}.`;
586
+ case "already_present":
587
+ return `${target} already registered in ${location}.`;
588
+ case "removed":
589
+ return `${target} removed from ${location}.`;
590
+ case "already_absent":
591
+ return `${target} is not registered in ${location}.`;
592
+ default:
593
+ return fallback ?? "ok";
594
+ }
595
+ }
596
+ function mcpConfigLocation(action) {
597
+ if (action.kind === "file-patch")
598
+ return action.filePath;
599
+ switch (action.harness) {
600
+ case "claude-code":
601
+ return "Claude Code user config";
602
+ case "codex":
603
+ return "Codex global config";
604
+ case "gemini":
605
+ return "Gemini user config";
606
+ case "opencode":
607
+ return "OpenCode config";
608
+ default:
609
+ return "harness config";
610
+ }
611
+ }
612
+ function isAlreadyPresentMessage(message) {
613
+ return /\balready\b.*\b(exists|configured|present|registered|installed)\b/i.test(message);
614
+ }
615
+ function isAlreadyAbsentMessage(message) {
616
+ return /\b(not found|does not exist|not configured|not registered|no mcp server)\b/i.test(message);
617
+ }
413
618
  export function parseHarnessList(values) {
414
619
  const result = [];
415
620
  for (const value of values) {
@@ -422,15 +627,18 @@ export function parseHarnessList(values) {
422
627
  return result;
423
628
  }
424
629
  function resolveExecInvocation(action, resolved) {
425
- const executable = resolved.hooks.which(action.command);
630
+ return resolveCommandInvocation(action.command, action.args, resolved);
631
+ }
632
+ function resolveCommandInvocation(command, args, resolved) {
633
+ const executable = resolved.hooks.which(command);
426
634
  if (!executable) {
427
635
  return null;
428
636
  }
429
637
  if (resolved.platform === "win32" && /\.(cmd|bat)$/i.test(executable)) {
430
- const unsafeArg = action.args.find(containsWindowsCmdMetacharacter);
638
+ const unsafeArg = args.find(containsWindowsCmdMetacharacter);
431
639
  if (unsafeArg !== undefined) {
432
640
  return {
433
- error: `Cannot safely launch ${action.command} through cmd.exe because ` +
641
+ error: `Cannot safely launch ${command} through cmd.exe because ` +
434
642
  `an argument contains Windows command metacharacters (& | < > ^ % ").`
435
643
  };
436
644
  }
@@ -439,13 +647,13 @@ function resolveExecInvocation(action, resolved) {
439
647
  "cmd.exe";
440
648
  return {
441
649
  command: cmdExe,
442
- args: ["/d", "/s", "/c", executable, ...action.args],
650
+ args: ["/d", "/s", "/c", executable, ...args],
443
651
  options: { windowsHide: true }
444
652
  };
445
653
  }
446
654
  return {
447
655
  command: executable,
448
- args: action.args,
656
+ args,
449
657
  options: resolved.platform === "win32" ? { windowsHide: true } : undefined
450
658
  };
451
659
  }
@@ -48,6 +48,13 @@ export function createMcpServer(service = new TalkingStickService()) {
48
48
  context_path: input.context_path,
49
49
  force_new: input.force_new
50
50
  })));
51
+ server.registerTool("leave_room", {
52
+ title: "Leave Room",
53
+ description: "Explicitly leave a room. The room is deleted when no active members remain.",
54
+ inputSchema: {
55
+ room_id: z.string().min(1)
56
+ }
57
+ }, async (input, extra) => toolJson(() => commands.leaveRoom(resolveConnectionIdentity(extra.sessionId), input)));
51
58
  server.registerTool("wait_for_turn", {
52
59
  title: "Wait For Turn",
53
60
  description: "Poll until the caller can claim the stick or takeover is available.",
package/dist/service.js CHANGED
@@ -31,6 +31,7 @@ export class TalkingStickService {
31
31
  }
32
32
  listRooms(input = {}) {
33
33
  const now = this.now();
34
+ this.purgeExpiredIdleRooms(now);
34
35
  if (!input.context_path) {
35
36
  const rows = this.db
36
37
  .prepare("SELECT * FROM path_rooms ORDER BY canonical_path")
@@ -51,6 +52,7 @@ export class TalkingStickService {
51
52
  const resolved = resolveContextPath(input.context_path);
52
53
  const now = this.now();
53
54
  const timestamp = now.toISOString();
55
+ this.purgeExpiredIdleRooms(now);
54
56
  return withImmediateTransaction(this.db, () => {
55
57
  const roomSelection = this.findOrCreateRoomForJoin(resolved, input.force_new === true, timestamp);
56
58
  this.upsertMember(roomSelection.room.room_id, input.agent_id, timestamp, input.process_metadata);
@@ -69,9 +71,67 @@ export class TalkingStickService {
69
71
  };
70
72
  });
71
73
  }
74
+ leaveRoom(input) {
75
+ assertNonEmpty(input.agent_id, "agent_id");
76
+ assertNonEmpty(input.room_id, "room_id");
77
+ const now = this.now();
78
+ const timestamp = now.toISOString();
79
+ this.purgeExpiredIdleRooms(now);
80
+ return withImmediateTransaction(this.db, () => {
81
+ const room = this.requireRoom(input.room_id);
82
+ const member = this.getMember(input.room_id, input.agent_id);
83
+ if (!member) {
84
+ throw new ProtocolError("unknown_member", "Agent is not a member of this room.", { to_agent_id: input.agent_id });
85
+ }
86
+ this.db
87
+ .prepare("DELETE FROM room_members WHERE room_id = ? AND agent_id = ?")
88
+ .run(input.room_id, input.agent_id);
89
+ const remainingMembers = this.getMembers(input.room_id);
90
+ if (remainingMembers.length === 0 ||
91
+ !remainingMembers.some((remaining) => this.isMemberActive(remaining, now))) {
92
+ this.deleteRoom(input.room_id);
93
+ return {
94
+ status: "room_deleted",
95
+ room_id: input.room_id,
96
+ canonical_path: room.canonical_path,
97
+ remaining_members: 0
98
+ };
99
+ }
100
+ const nextOwner = room.owner === input.agent_id ? null : room.owner;
101
+ const nextReservedFor = room.reserved_for === input.agent_id ? null : room.reserved_for;
102
+ const nextState = room.state === "closed"
103
+ ? "closed"
104
+ : nextOwner
105
+ ? "owned"
106
+ : nextReservedFor
107
+ ? "reserved"
108
+ : "idle";
109
+ this.db
110
+ .prepare(`
111
+ UPDATE path_rooms
112
+ SET owner = ?,
113
+ reserved_for = ?,
114
+ pending_handoff_event_seq = ?,
115
+ lease_id = ?,
116
+ lease_expires_at = ?,
117
+ claim_expires_at = ?,
118
+ state = ?,
119
+ updated_at = ?
120
+ WHERE room_id = ?
121
+ `)
122
+ .run(nextOwner, nextReservedFor, room.owner === input.agent_id ? null : room.pending_handoff_event_seq, room.owner === input.agent_id ? null : room.lease_id, room.owner === input.agent_id ? null : room.lease_expires_at, room.reserved_for === input.agent_id ? null : room.claim_expires_at, nextState, timestamp, input.room_id);
123
+ return {
124
+ status: "left",
125
+ room_id: input.room_id,
126
+ canonical_path: room.canonical_path,
127
+ remaining_members: remainingMembers.length
128
+ };
129
+ });
130
+ }
72
131
  async waitForTurn(input) {
73
132
  assertNonEmpty(input.agent_id, "agent_id");
74
133
  assertNonEmpty(input.room_id, "room_id");
134
+ this.purgeExpiredIdleRooms(this.now());
75
135
  const maxWaitMs = input.max_wait_ms ?? this.policy.waitForTurnMaxWaitMs;
76
136
  const deadline = Date.now() + Math.max(0, maxWaitMs);
77
137
  while (true) {
@@ -88,6 +148,7 @@ export class TalkingStickService {
88
148
  const now = this.now();
89
149
  const timestamp = now.toISOString();
90
150
  const nextLeaseExpiresAt = this.expiresAt(now, this.policy.ownerLeaseTtlMs);
151
+ this.purgeExpiredIdleRooms(now);
91
152
  this.warmRoomTurnLiveness(input.room_id);
92
153
  return withImmediateTransaction(this.db, () => {
93
154
  const room = this.requireRoom(input.room_id);
@@ -113,6 +174,7 @@ export class TalkingStickService {
113
174
  validateHandoff(input.handoff);
114
175
  const now = this.now();
115
176
  const timestamp = now.toISOString();
177
+ this.purgeExpiredIdleRooms(now);
116
178
  this.warmRoomTurnLiveness(input.room_id);
117
179
  return withImmediateTransaction(this.db, () => {
118
180
  const room = this.requireRoom(input.room_id);
@@ -161,6 +223,7 @@ export class TalkingStickService {
161
223
  assertNonEmpty(input.to_agent_id, "to_agent_id");
162
224
  const now = this.now();
163
225
  const timestamp = now.toISOString();
226
+ this.purgeExpiredIdleRooms(now);
164
227
  this.warmRoomTurnLiveness(input.room_id);
165
228
  return withImmediateTransaction(this.db, () => {
166
229
  const room = this.requireRoom(input.room_id);
@@ -209,6 +272,7 @@ export class TalkingStickService {
209
272
  assertNonEmpty(input.reason, "reason");
210
273
  const now = this.now();
211
274
  const timestamp = now.toISOString();
275
+ this.purgeExpiredIdleRooms(now);
212
276
  this.warmRoomTurnLiveness(input.room_id);
213
277
  return withImmediateTransaction(this.db, () => {
214
278
  const room = this.requireRoom(input.room_id);
@@ -267,6 +331,7 @@ export class TalkingStickService {
267
331
  getRoomState(input) {
268
332
  const now = this.now();
269
333
  const timestamp = now.toISOString();
334
+ this.purgeExpiredIdleRooms(now);
270
335
  const room = this.requireRoom(input.room_id);
271
336
  this.touchKnownMember(input.room_id, input.agent_id, timestamp);
272
337
  const inspection = this.inspectRoom(room, now);
@@ -276,6 +341,7 @@ export class TalkingStickService {
276
341
  };
277
342
  }
278
343
  getRoomEvents(input) {
344
+ this.purgeExpiredIdleRooms(this.now());
279
345
  this.touchKnownMember(input.room_id, input.agent_id, this.now().toISOString());
280
346
  const afterEventSeq = input.after_event_seq ?? 0;
281
347
  const limit = Math.min(input.limit ?? 100, 500);
@@ -302,6 +368,7 @@ export class TalkingStickService {
302
368
  }
303
369
  const now = this.now();
304
370
  const timestamp = now.toISOString();
371
+ this.purgeExpiredIdleRooms(now);
305
372
  return withImmediateTransaction(this.db, () => {
306
373
  const room = this.requireRoom(input.room_id);
307
374
  if (room.state === "closed") {
@@ -334,6 +401,7 @@ export class TalkingStickService {
334
401
  }
335
402
  listNotes(input) {
336
403
  assertNonEmpty(input.room_id, "room_id");
404
+ this.purgeExpiredIdleRooms(this.now());
337
405
  this.requireRoom(input.room_id);
338
406
  this.touchKnownMember(input.room_id, input.agent_id, this.now().toISOString());
339
407
  const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
@@ -884,6 +952,40 @@ export class TalkingStickService {
884
952
  .prepare("SELECT * FROM room_events WHERE event_seq = ?")
885
953
  .get(eventSeq) ?? null);
886
954
  }
955
+ purgeExpiredIdleRooms(now) {
956
+ if (this.policy.idleRoomTtlMs <= 0) {
957
+ return;
958
+ }
959
+ const cutoffMs = now.getTime() - this.policy.idleRoomTtlMs;
960
+ withImmediateTransaction(this.db, () => {
961
+ const rooms = this.db
962
+ .prepare("SELECT * FROM path_rooms")
963
+ .all();
964
+ for (const room of rooms) {
965
+ const members = this.getMembers(room.room_id);
966
+ if (this.latestRoomActivityMs(room, members) > cutoffMs) {
967
+ continue;
968
+ }
969
+ if (members.some((member) => this.isMemberActive(member, now))) {
970
+ continue;
971
+ }
972
+ this.deleteRoom(room.room_id);
973
+ }
974
+ });
975
+ }
976
+ latestRoomActivityMs(room, members) {
977
+ let latest = parseTimestampMs(room.updated_at);
978
+ for (const member of members) {
979
+ latest = Math.max(latest, parseTimestampMs(member.joined_at), parseTimestampMs(member.last_seen_at), parseTimestampMs(member.last_wait_at));
980
+ }
981
+ return latest;
982
+ }
983
+ deleteRoom(roomId) {
984
+ this.db.prepare("DELETE FROM notes WHERE room_id = ?").run(roomId);
985
+ this.db.prepare("DELETE FROM room_events WHERE room_id = ?").run(roomId);
986
+ this.db.prepare("DELETE FROM room_members WHERE room_id = ?").run(roomId);
987
+ this.db.prepare("DELETE FROM path_rooms WHERE room_id = ?").run(roomId);
988
+ }
887
989
  expiresAt(now, ttlMs) {
888
990
  return new Date(now.getTime() + ttlMs).toISOString();
889
991
  }
@@ -922,9 +1024,16 @@ export class TalkingStickService {
922
1024
  }
923
1025
  const pendingEvent = this.getEventBySeq(room.pending_handoff_event_seq);
924
1026
  const priorOwner = pendingEvent?.from_agent_id ?? null;
1027
+ if (priorOwner === agentId &&
1028
+ this.hasOtherRoomMember(room.room_id, agentId)) {
1029
+ return true;
1030
+ }
925
1031
  const bestKnownMember = this.findBestFairKnownMember(room.room_id, priorOwner, now);
926
1032
  return bestKnownMember !== null && bestKnownMember.agent_id !== agentId;
927
1033
  }
1034
+ hasOtherRoomMember(roomId, agentId) {
1035
+ return this.getMembers(roomId).some((member) => member.agent_id !== agentId);
1036
+ }
928
1037
  inspectRoom(room, now) {
929
1038
  const members = this.getMembers(room.room_id);
930
1039
  const ownerMember = room.owner
@@ -1161,6 +1270,13 @@ function sequenceDistance(ordinal, referenceOrdinal, memberCount) {
1161
1270
  const distance = (ordinal - referenceOrdinal + memberCount) % memberCount;
1162
1271
  return distance === 0 ? memberCount : distance;
1163
1272
  }
1273
+ function parseTimestampMs(timestamp) {
1274
+ if (!timestamp) {
1275
+ return 0;
1276
+ }
1277
+ const parsed = Date.parse(timestamp);
1278
+ return Number.isFinite(parsed) ? parsed : 0;
1279
+ }
1164
1280
  function normalizeProcessMetadata(processMetadata) {
1165
1281
  return {
1166
1282
  host_id: processMetadata?.host_id ?? null,
@@ -61,6 +61,14 @@ export function clearCliSessionLease(sessionPath, agentId, roomId) {
61
61
  updated_at: new Date().toISOString()
62
62
  });
63
63
  }
64
+ export function removeCliSession(sessionPath, agentId, roomId) {
65
+ const sessions = readCliSessions(sessionPath).filter((session) => !(session.agent_id === agentId && session.room_id === roomId));
66
+ writeCliSessions(sessionPath, sessions);
67
+ }
68
+ export function removeCliSessionsForRoom(sessionPath, roomId) {
69
+ const sessions = readCliSessions(sessionPath).filter((session) => session.room_id !== roomId);
70
+ writeCliSessions(sessionPath, sessions);
71
+ }
64
72
  export function findCliSessionForContextPath(sessionPath, agentId, contextPath) {
65
73
  const resolved = resolveContextPath(contextPath);
66
74
  const candidates = readCliSessions(sessionPath).filter((session) => session.agent_id === agentId &&
@@ -57,9 +57,9 @@ Ambient presence needs two distinct operating modes:
57
57
  - **Participant mode** — the runtime can reliably infer or receive the harness identity that the MCP layer would use. In this mode, a spawned shell helper may join, wait, or claim on behalf of that participant.
58
58
  - **Observer mode** — the runtime cannot reliably infer the harness identity. In this mode, ambient surfaces may read room state and tail room events, but they must not join the room or represent themselves as a protocol participant.
59
59
 
60
- This distinction matters because `tt` is currently the human CLI. A shell process launched from inside a harness should not silently become `human:<username>` and pollute room membership. If we can cheaply export the harness identity into child shells, great; if not, observer mode is still useful and should ship first.
60
+ This distinction matters because `tt` also serves ordinary human terminals. A shell process launched from inside a harness must not silently become `human:<username>` and pollute room membership.
61
61
 
62
- Current contract: CLI participant mode is explicit. A harness must set `TT_HARNESS_EXPORT=1` to let `tt` derive a harness-style identity from signals and ancestry, or `TT_HARNESS_AGENT_ID=<agent-id>` to export the exact agent id directly. Without that opt-in, `tt` remains on the human CLI path.
62
+ Current contract: known harness environment markers such as `CLAUDECODE=1`, `CODEX_THREAD_ID`, `GEMINI_CLI=1`, or `OPENCODE=1` make `tt` derive a harness-style identity before the human fallback. `TT_HARNESS_AGENT_ID=<agent-id>` exports the exact agent id directly. `TT_HARNESS_EXPORT=1` remains available for ancestry-based detection when no known harness environment marker is present.
63
63
 
64
64
  That same rule applies to invoked shell helpers: if identity can be inferred or inherited, they may render participant-local state; if not, they should limit themselves to room-level observer status rather than pretending to be a participant.
65
65
 
@@ -0,0 +1,67 @@
1
+ # Talking Stick 0.1.0-alpha.5
2
+
3
+ Date: 2026-04-26
4
+
5
+ Room lifecycle and licensing alpha. Talking Stick rooms are coordination state,
6
+ not a durable history store.
7
+
8
+ ## Added
9
+
10
+ ### Explicit leave
11
+
12
+ Added `leave_room` to the MCP server and `tt leave [path]` to the CLI.
13
+
14
+ Leaving removes the caller's room membership. The CLI also removes the local
15
+ session entry for that room and stops any recorded guardian process for the
16
+ departing identity.
17
+
18
+ ### MIT license
19
+
20
+ The package is now released under MIT:
21
+
22
+ - `LICENSE` contains the MIT license text.
23
+ - `package.json` and `package-lock.json` declare `"license": "MIT"`.
24
+ - `README.md` links to the repository license.
25
+
26
+ ## Changed
27
+
28
+ ### Ephemeral rooms
29
+
30
+ Rooms are physically deleted once no active members remain. This includes the
31
+ simple last-member case and the case where explicit leave removes the only
32
+ recently-active participant while stale member rows remain.
33
+
34
+ Long-idle rooms are also purged opportunistically when the service is invoked.
35
+ The default idle retention is seven days.
36
+
37
+ ### Take-back delay
38
+
39
+ If `release_stick` leaves a handoff idle because no peer is currently waiting,
40
+ the prior owner can no longer immediately reclaim while another room member
41
+ exists. The existing `policy.waiterGraceMs` window now acts as a minimum
42
+ take-back delay so a slower peer has a chance to poll first.
43
+
44
+ ## Fixed
45
+
46
+ ### Harness-launched CLI identity
47
+
48
+ `tt` now checks known harness environment markers before falling back to the
49
+ human CLI identity. A Claude Code shell with `CLAUDECODE=1`, for example,
50
+ resolves to a `claude:*` identity without requiring `TT_HARNESS_EXPORT=1`.
51
+
52
+ `TT_HARNESS_EXPORT=1` remains available for ancestry-based detection when no
53
+ known harness environment marker is present, and `TT_HARNESS_AGENT_ID` still
54
+ exports an exact agent id directly.
55
+
56
+ For Claude sessions, `CMUX_CLAUDE_PID` is treated only as an optional process
57
+ hint after `CLAUDECODE=1` has already identified the shell as Claude. It is not
58
+ a harness signal by itself.
59
+
60
+ ## Verification
61
+
62
+ - `npm run typecheck`
63
+ - `npm test` — 204 tests across 14 files
64
+ - `npm run build`
65
+ - `node dist/cli.js --help`
66
+ - `git diff --check`
67
+ - `npm pack --dry-run`
@@ -0,0 +1,61 @@
1
+ # Talking Stick 0.1.0-alpha.6
2
+
3
+ Date: 2026-04-27
4
+
5
+ Installer idempotency alpha. This release makes repeated `tt install --all`
6
+ runs precise instead of noisy.
7
+
8
+ ## Changed
9
+
10
+ ### Precise install results
11
+
12
+ `tt install` and `tt uninstall` now return and print explicit result statuses:
13
+
14
+ - `added`
15
+ - `already-present`
16
+ - `updated`
17
+ - `removed`
18
+ - `already-absent`
19
+ - `skipped`
20
+ - `failed`
21
+
22
+ The CLI reports the state Talking Stick observed instead of forwarding each
23
+ harness's native wording directly. That keeps Claude Code, Codex, Gemini, and
24
+ OpenCode output consistent.
25
+
26
+ ### Idempotent MCP install preflight
27
+
28
+ Before running native MCP add/remove commands, Talking Stick now checks whether
29
+ the named MCP server is already in the expected state:
30
+
31
+ - Claude Code and Codex use their native `mcp get` command.
32
+ - Gemini is inspected through its user `settings.json`.
33
+ - OpenCode is inspected through its JSON config.
34
+
35
+ If `talking-stick` is already registered, `tt install` reports
36
+ `already-present` and does not call another native add command.
37
+
38
+ ## Fixed
39
+
40
+ ### Claude Code already-present installs
41
+
42
+ Claude Code reports an existing MCP server as a native command failure. Talking
43
+ Stick now recognizes that response as `already-present`, so a rerun of
44
+ `tt install --all` no longer ends with "install completed with failures" just
45
+ because Claude Code was already configured.
46
+
47
+ ### Codex duplicate-looking installs
48
+
49
+ Codex already stores MCP servers by name, but rerunning `tt install --all`
50
+ previously invoked `codex mcp add` again and printed Codex's native "Added"
51
+ message. Talking Stick now reports `already-present` without invoking another
52
+ add when the named server is already present.
53
+
54
+ ## Verification
55
+
56
+ - `npm test -- tests/install.test.ts`
57
+ - `npm run typecheck`
58
+ - `npm run build`
59
+ - `git diff --check`
60
+ - `node dist/cli.js install --all`
61
+ - `npx vitest run --reporter verbose` — 208 tests across 14 files
@@ -80,7 +80,7 @@ Derivation signals, in order of preference:
80
80
 
81
81
  1. `clientInfo.name` and `clientInfo.version` from the MCP `initialize` handshake. Every MCP client sends these; Claude Code, Codex, and Gemini CLI all set distinctive values.
82
82
  2. The MCP server's own parent process identity: `(parent_pid, parent_start_time)`. Together these uniquely identify the harness instance on a host. `parent_pid` alone is unsafe because PIDs are reused after exit.
83
- 3. Environment variables the harness exports, such as `CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `TERM_PROGRAM`, `ITERM_SESSION_ID`, `TMUX`, `SSH_TTY`.
83
+ 3. Environment variables the harness exports, such as `CLAUDECODE`, `CODEX_THREAD_ID`, `GEMINI_CLI`, `OPENCODE`, `TERM_PROGRAM`, `ITERM_SESSION_ID`, `TMUX`, `SSH_TTY`.
84
84
 
85
85
  Composed identity, stable for the life of one harness instance:
86
86
 
@@ -98,7 +98,7 @@ The hash is a short digest over the signals above, so reconnects from the same h
98
98
  For the Human CLI (see deferred extension), the same idea applies with different signals: `$USER`, parent shell `(pid, start_time)`, and tty yield identities like:
99
99
 
100
100
  ```text
101
- human:wojtek:s003
101
+ human:alice:s003
102
102
  ```
103
103
 
104
104
  The derived string is the protocol-facing identity, but the server must also persist the source liveness facts behind it:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "MCP coordination server for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,5 +36,5 @@
36
36
  "engines": {
37
37
  "node": ">=22"
38
38
  },
39
- "license": "UNLICENSED"
39
+ "license": "MIT"
40
40
  }
@@ -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`, `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`, `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 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 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
 
@@ -176,11 +176,14 @@ Exit the wait loop only when one of these is true:
176
176
 
177
177
  In every other case: after `release_stick` or `pass_stick`, go straight back into the wait loop (ideally backgrounded — see §4).
178
178
 
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
+
179
181
  ## Recovery and Inspection
180
182
 
181
183
  Use these reads when you need context:
182
184
 
183
185
  - `list_rooms`: discover active rooms under a path
186
+ - `leave_room`: explicitly remove your membership from a room
184
187
  - `get_room_state`: authoritative current room projection
185
188
  - `get_room_events`: replay recent claims, releases, passes, and takeovers
186
189