talking-stick 0.1.3 → 0.2.0

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/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.2. 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.2.0. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, and OpenCode out of the box. Two agents in the same room can also chat out-of-band — without passing the stick — via `tt msg send/recv` and the matching MCP tools.
6
6
 
7
7
  ## Quickstart
8
8
 
@@ -46,7 +46,7 @@ That's the whole workflow. They negotiate turns automatically, hand off structur
46
46
 
47
47
  | Method | Command | Notes |
48
48
  |---|---|---|
49
- | **From npm** | `npm i -g talking-stick` | Published as `0.1.2`. Requires Node ≥ 22. |
49
+ | **From npm** | `npm i -g talking-stick` | Published as `0.2.0`. Requires Node ≥ 22. |
50
50
  | **From GitHub** | `npm i -g github:mostlydev/talking-stick` | Tracks the `master` branch; builds on install via the `prepare` hook. |
51
51
  | **From source** | `git clone … && npm install && npm link` | For contributors. |
52
52
 
@@ -97,10 +97,13 @@ heartbeat — prove liveness while holding the stick
97
97
  release_stick — normal handoff to the next fair waiter, with structured Handoff
98
98
  pass_stick — explicit handoff to a named agent
99
99
  takeover_stick — deliberate claim when the prior holder is gone/stuck
100
+ kick_member — evict an idle member whose process is gone
100
101
  get_room_state — authoritative state projection
101
102
  get_room_events — audit log of turn transitions
102
103
  add_note — leave an async observation for the current owner
103
104
  list_notes — read notes left for the room
105
+ send_message — out-of-band chat into the room event log (direct or broadcast)
106
+ wait_for_events — observer-safe long-poll over the event log with type/target/sender filters
104
107
  ```
105
108
 
106
109
  A workspace maps to a room — usually the `git` root or nearest project marker — so two agents `cd`'d anywhere under the same repo join the same room automatically.
@@ -118,6 +121,30 @@ While you wait your turn you may still need to flag something to the current own
118
121
  - `list_notes` returns notes for the room; readers can paginate with `after_note_id` and opt into resolved entries with `include_resolved`.
119
122
  - Notes are for observations and pointers, not for coordinating shared edits. Shared workspace changes still require holding the stick.
120
123
 
124
+ ## Out-of-band messaging
125
+
126
+ The stick guarantees single-writer authority over shared workspace state. It is **not** a chat protocol. When two agents need to talk — design questions, "are you about to break X?", live coordination — use messages instead of churning the stick.
127
+
128
+ ```bash
129
+ tt msg send <recipient|room> "<body>" [--interrupt] [--stdin]
130
+ tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent]
131
+ tt events --wait|--follow [--event TYPE[,TYPE]] [--target self|any|agent]
132
+ ```
133
+
134
+ - `<recipient>` is a full `agent_id`, an unambiguous active display name (`codex`, `claude`), or the literal `room` for broadcast.
135
+ - `--interrupt` marks the message time-sensitive; receivers decide whether to act on it now.
136
+ - `tt msg recv --follow` is a long-running tail (one JSON line per event) suited to harnesses that can monitor child stdout (Claude Code Monitor, terminals).
137
+ - `tt msg recv --wait` exits on the next matching batch — ideal for harnesses that can launch a background command and notice when it completes; restart with `--after <last_event_seq>` to resume.
138
+ - `wait_for_events` is observer-safe: it never mutates room state, so non-holders can use it freely without disturbing turn-fairness bookkeeping.
139
+
140
+ **When to message vs note vs handoff.**
141
+
142
+ - **Message** — conversational, ephemeral, between live processes. Six round-trips of "what about line 84?" cost about as much as one structured handoff and zero stick churn.
143
+ - **Note** (`tt notes add`) — durable, resolvable artifacts. Leave a note when the next holder should consider something at handoff, or when the observation should outlive the conversation.
144
+ - **Handoff** (`release_stick` / `pass_stick`) — transfer of work. Messages do not replace handoffs; they live alongside them.
145
+
146
+ **`to_agent_id` is routing, not ACL.** Any room member can read any message via `get_room_events` or `tt events --follow --target any`. Messages are not private. They also do not grant the stick — a non-holder paging the holder gets attention, not write authority.
147
+
121
148
  ## How installation works per harness
122
149
 
123
150
  `tt install` installs both pieces a harness needs: the MCP server registration and the bundled `talking-stick` skill.
@@ -160,7 +187,9 @@ tt leave [path] # leave the room for p
160
187
  tt wait [path] [--timeout 30s] # block until your turn
161
188
  tt try [path] # non-blocking claim attempt
162
189
  tt state [path] # full room state
163
- tt events [path] [--after N] [--limit N] # room event log
190
+ tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent] # room event log; --wait/--follow long-polls
191
+ tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR] # send an OOB message
192
+ tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR] # receive OOB messages
164
193
  tt release [path] --status TEXT --next-action TEXT # normal handoff
165
194
  tt pass [path] --status TEXT --next-action TEXT # pass/end your turn
166
195
  tt assign <target|next> [path] --status TEXT --next-action TEXT # explicit handoff
@@ -0,0 +1,124 @@
1
+ import { ProtocolError } from "../errors.js";
2
+ import { getStringOption, parseOptionalInteger, parseWaitTimeout } from "./parser.js";
3
+ import { shouldUseJson } from "./output.js";
4
+ export async function runEventStream(runtime, parsed, identity, roomId, options) {
5
+ const follow = parsed.options.has("follow");
6
+ const wait = parsed.options.has("wait");
7
+ if (follow && wait) {
8
+ throw new Error("Pass only one of --wait or --follow.");
9
+ }
10
+ const tailMode = follow || wait || options.force_tail_cursor;
11
+ const explicitAfter = parseOptionalInteger(parsed, "after");
12
+ const afterEventSeq = explicitAfter ??
13
+ (tailMode ? runtime.commands.getLatestEventSeq({ room_id: roomId }) : 0);
14
+ const targetAgentId = resolveTargetFilter(runtime, identity, roomId, getStringOption(parsed, "target") ?? options.default_target);
15
+ const fromAgentId = resolveOptionalAgentSelector(runtime, identity, roomId, getStringOption(parsed, "from"));
16
+ const waitInput = {
17
+ agent_id: identity.agent_id,
18
+ room_id: roomId,
19
+ after_event_seq: afterEventSeq,
20
+ event_type: options.event_type,
21
+ target_agent_id: targetAgentId,
22
+ from_agent_id: fromAgentId,
23
+ max_wait_ms: follow || wait ? parseWaitTimeout(parsed) : 0
24
+ };
25
+ if (!follow) {
26
+ const result = await runtime.commands.waitForEvents(waitInput);
27
+ writeEventLines(parsed, result.events);
28
+ return;
29
+ }
30
+ await followEvents(runtime, parsed, waitInput);
31
+ }
32
+ export function resolveOptionalAgentSelector(runtime, identity, roomId, raw) {
33
+ if (!raw) {
34
+ return undefined;
35
+ }
36
+ return resolveAgentSelector(runtime, identity, roomId, raw);
37
+ }
38
+ export function resolveAgentSelector(runtime, identity, roomId, raw) {
39
+ const members = runtime.commands.getRoomState({
40
+ room_id: roomId,
41
+ agent_id: identity.agent_id
42
+ }).members;
43
+ const exact = members.find((member) => member.agent_id === raw);
44
+ if (exact) {
45
+ return exact.agent_id;
46
+ }
47
+ const candidates = members.filter((member) => member.status === "active" && member.display_name === raw);
48
+ if (candidates.length === 1) {
49
+ return candidates[0].agent_id;
50
+ }
51
+ if (candidates.length > 1) {
52
+ throw new ProtocolError("ambiguous_recipient", `Multiple active room members match '${raw}'.`, { candidates: candidates.map((member) => member.agent_id) });
53
+ }
54
+ throw new ProtocolError("unknown_recipient", `No active room member matches '${raw}'.`);
55
+ }
56
+ export function parseEventTypeFilter(value) {
57
+ if (!value) {
58
+ return undefined;
59
+ }
60
+ const values = value
61
+ .split(",")
62
+ .map((item) => item.trim())
63
+ .filter(Boolean);
64
+ if (values.length === 0) {
65
+ return undefined;
66
+ }
67
+ return values.length === 1 ? values[0] : values;
68
+ }
69
+ export function writeEventLines(parsed, events) {
70
+ for (const event of events) {
71
+ const line = shouldUseJson(parsed)
72
+ ? JSON.stringify(event)
73
+ : formatEventLine(event);
74
+ process.stdout.write(`${line}\n`);
75
+ }
76
+ }
77
+ export function formatEventLine(event) {
78
+ if (event.event_type === "message_sent") {
79
+ const target = event.to_agent_id ?? "room";
80
+ const hint = event.payload?.delivery_hint === "interrupt" ? " [interrupt]" : "";
81
+ return `[${event.created_at}] ${event.from_agent_id ?? "-"} -> ${target}${hint}: ${event.payload?.body ?? ""}`;
82
+ }
83
+ const reason = event.reason ? ` (${event.reason})` : "";
84
+ const target = event.from_agent_id && event.to_agent_id
85
+ ? `${event.from_agent_id} -> ${event.to_agent_id}`
86
+ : event.to_agent_id
87
+ ? `-> ${event.to_agent_id}`
88
+ : event.from_agent_id ?? "-";
89
+ return `[${event.created_at}] ${event.event_type} ${target}${reason}`;
90
+ }
91
+ async function followEvents(runtime, parsed, input) {
92
+ let cursor = input.after_event_seq ?? 0;
93
+ let shouldExit = false;
94
+ const markExit = () => {
95
+ shouldExit = true;
96
+ };
97
+ process.once("SIGTERM", markExit);
98
+ process.once("SIGHUP", markExit);
99
+ process.once("SIGINT", markExit);
100
+ try {
101
+ while (!shouldExit) {
102
+ const result = await runtime.commands.waitForEvents({
103
+ ...input,
104
+ after_event_seq: cursor
105
+ });
106
+ if (result.events.length > 0) {
107
+ writeEventLines(parsed, result.events);
108
+ cursor = result.cursor_event_seq;
109
+ }
110
+ }
111
+ }
112
+ finally {
113
+ process.off("SIGTERM", markExit);
114
+ process.off("SIGHUP", markExit);
115
+ process.off("SIGINT", markExit);
116
+ process.stderr.write(`cursor_event_seq=${cursor}\n`);
117
+ }
118
+ }
119
+ function resolveTargetFilter(runtime, identity, roomId, raw) {
120
+ if (raw === "self" || raw === "any") {
121
+ return raw;
122
+ }
123
+ return resolveAgentSelector(runtime, identity, roomId, raw);
124
+ }
@@ -0,0 +1,81 @@
1
+ import { deriveCliIdentity } from "./identity.js";
2
+ import { readAllStdin } from "./handoff.js";
3
+ import { hasOption } from "./parser.js";
4
+ import { printResult } from "./output.js";
5
+ import { resolveAgentSelector, runEventStream } from "./event-stream.js";
6
+ import { resolveSessionForNotes } from "./session.js";
7
+ export async function handleMsgCommand(runtime, parsed) {
8
+ const [subcommand, ...rest] = parsed.positionals;
9
+ if (!subcommand) {
10
+ throw new Error("Usage: tt msg <send|recv> [...]. See `tt --help` for details.");
11
+ }
12
+ const subParsed = {
13
+ name: `msg ${subcommand}`,
14
+ positionals: rest,
15
+ options: parsed.options
16
+ };
17
+ switch (subcommand) {
18
+ case "send":
19
+ await handleMsgSendCommand(runtime, subParsed);
20
+ return;
21
+ case "recv":
22
+ await handleMsgRecvCommand(runtime, subParsed);
23
+ return;
24
+ default:
25
+ throw new Error(`Unknown msg subcommand: ${subcommand}`);
26
+ }
27
+ }
28
+ async function handleMsgSendCommand(runtime, parsed) {
29
+ const identity = deriveCliIdentity(parsed);
30
+ const session = resolveSessionForNotes(runtime, parsed, identity);
31
+ const usesRoomFlag = hasOption(parsed, "room");
32
+ repairBooleanFlag(parsed, "room", 0);
33
+ repairBooleanFlag(parsed, "interrupt", usesRoomFlag ? 0 : 1);
34
+ const recipientSelector = usesRoomFlag ? "room" : parsed.positionals[0];
35
+ if (!recipientSelector) {
36
+ throw new Error("Usage: tt msg send <recipient|room> <body...> [--interrupt] [--stdin].");
37
+ }
38
+ const bodyStart = usesRoomFlag ? 0 : 1;
39
+ const positionalBody = parsed.positionals.slice(bodyStart).join(" ");
40
+ const body = positionalBody.length > 0
41
+ ? positionalBody
42
+ : hasOption(parsed, "stdin")
43
+ ? await readAllStdin()
44
+ : "";
45
+ if (body.length === 0) {
46
+ throw new Error("Message body is required (pass as a positional or use --stdin to read from stdin).");
47
+ }
48
+ const toAgentId = recipientSelector === "room"
49
+ ? null
50
+ : resolveAgentSelector(runtime, identity, session.room_id, recipientSelector);
51
+ const result = runtime.commands.sendMessage(identity, {
52
+ room_id: session.room_id,
53
+ body,
54
+ to_agent_id: toAgentId,
55
+ delivery_hint: hasOption(parsed, "interrupt") ? "interrupt" : "normal"
56
+ });
57
+ printResult(parsed, result, () => {
58
+ const target = toAgentId ?? "room";
59
+ const hint = hasOption(parsed, "interrupt") ? " interrupt" : "";
60
+ return `Sent${hint} message ${shortEventId(result.event_id)} to ${target}.`;
61
+ });
62
+ }
63
+ async function handleMsgRecvCommand(runtime, parsed) {
64
+ const identity = deriveCliIdentity(parsed);
65
+ const session = resolveSessionForNotes(runtime, parsed, identity);
66
+ await runEventStream(runtime, parsed, identity, session.room_id, {
67
+ event_type: "message_sent",
68
+ default_target: "self",
69
+ force_tail_cursor: false
70
+ });
71
+ }
72
+ function repairBooleanFlag(parsed, key, insertAt) {
73
+ const value = parsed.options.get(key);
74
+ if (typeof value === "string") {
75
+ parsed.positionals.splice(insertAt, 0, value);
76
+ parsed.options.set(key, true);
77
+ }
78
+ }
79
+ function shortEventId(eventId) {
80
+ return eventId.slice(0, 8);
81
+ }
@@ -126,7 +126,9 @@ Commands:
126
126
  tt wait [path] [--timeout 30s]
127
127
  tt try [path]
128
128
  tt state [path]
129
- tt events [path] [--after N] [--limit N]
129
+ tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent]
130
+ tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR]
131
+ tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR]
130
132
  tt release [path] (--status TEXT --next-action TEXT | --stdin)
131
133
  tt pass [path] (--status TEXT --next-action TEXT | --stdin)
132
134
  tt assign <target|next> [path] (--status TEXT --next-action TEXT | --stdin)
@@ -1,6 +1,7 @@
1
1
  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
+ import { handleMsgCommand } from "./msg-commands.js";
4
5
  import { handleNotesCommand } from "./notes-commands.js";
5
6
  import { handleEventsCommand, handleJoinCommand, handleKickCommand, handleLeaveCommand, handleListCommand, handleStateCommand, handleWhoAmICommand } from "./room-commands.js";
6
7
  import { handleAssignCommand, handlePassCommand, handleReleaseCommand, handleTakeCommand, handleWaitCommand } from "./turn-commands.js";
@@ -127,10 +128,19 @@ export const COMMAND_REGISTRY = [
127
128
  needsRuntime: true,
128
129
  startupMaintenance: true,
129
130
  internal: false,
130
- usage: "tt events [path] [--after N] [--limit N]",
131
+ usage: "tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent]",
131
132
  description: "Show room events.",
132
133
  handler: ({ runtime, parsed }) => handleEventsCommand(requireRuntime(runtime), parsed)
133
134
  },
135
+ {
136
+ name: "msg",
137
+ needsRuntime: true,
138
+ startupMaintenance: true,
139
+ internal: false,
140
+ usage: "tt msg <send|recv> [...]",
141
+ description: "Send or receive transient messages on a room's event stream.",
142
+ handler: ({ runtime, parsed }) => handleMsgCommand(requireRuntime(runtime), parsed)
143
+ },
134
144
  {
135
145
  name: "wait",
136
146
  needsRuntime: true,
@@ -1,8 +1,9 @@
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 { getStringOption, hasOption, parseOptionalInteger } from "./parser.js";
4
+ import { getStringOption, hasOption, normalizeBooleanFlag, parseOptionalInteger } from "./parser.js";
5
5
  import { formatRelativeTime, printResult } from "./output.js";
6
+ import { parseEventTypeFilter, runEventStream } from "./event-stream.js";
6
7
  import { resolveSessionForReads, upsertSessionFromJoin } from "./session.js";
7
8
  export function handleListCommand(runtime, parsed) {
8
9
  const contextPath = parsed.positionals[0] ?? process.cwd();
@@ -31,7 +32,11 @@ export function handleJoinCommand(runtime, parsed) {
31
32
  });
32
33
  upsertSessionFromJoin(identity, joined);
33
34
  printResult(parsed, joined, () => {
34
- return `Joined ${joined.canonical_path} as ${joined.agent_id}`;
35
+ const lines = [`Joined ${joined.canonical_path} as ${joined.agent_id}`];
36
+ if (joined.warning) {
37
+ lines.push(`Warning: ${joined.warning}`);
38
+ }
39
+ return lines.join("\n");
35
40
  });
36
41
  }
37
42
  export function handleLeaveCommand(runtime, parsed) {
@@ -122,9 +127,19 @@ export function handleStateCommand(runtime, parsed) {
122
127
  return lines.join("\n");
123
128
  });
124
129
  }
125
- export function handleEventsCommand(runtime, parsed) {
130
+ export async function handleEventsCommand(runtime, parsed) {
131
+ normalizeBooleanFlag(parsed, "wait");
132
+ normalizeBooleanFlag(parsed, "follow");
126
133
  const identity = deriveCliIdentity(parsed);
127
134
  const session = resolveSessionForReads(runtime, parsed, identity);
135
+ if (hasOption(parsed, "wait") || hasOption(parsed, "follow")) {
136
+ await runEventStream(runtime, parsed, identity, session.room_id, {
137
+ event_type: parseEventTypeFilter(getStringOption(parsed, "event")),
138
+ default_target: "any",
139
+ force_tail_cursor: false
140
+ });
141
+ return;
142
+ }
128
143
  const events = runtime.commands.getRoomEvents({
129
144
  room_id: session.room_id,
130
145
  agent_id: identity.agent_id,
package/dist/commands.js CHANGED
@@ -82,6 +82,21 @@ export class TalkingStickCommands {
82
82
  getRoomEvents(input) {
83
83
  return this.service.getRoomEvents(input);
84
84
  }
85
+ sendMessage(identity, input) {
86
+ return this.service.sendMessage({
87
+ agent_id: identity.agent_id,
88
+ room_id: input.room_id,
89
+ body: input.body,
90
+ to_agent_id: input.to_agent_id,
91
+ delivery_hint: input.delivery_hint
92
+ });
93
+ }
94
+ waitForEvents(input) {
95
+ return this.service.waitForEvents(input);
96
+ }
97
+ getLatestEventSeq(input) {
98
+ return this.service.getLatestEventSeq(input);
99
+ }
85
100
  addNote(identity, input) {
86
101
  return this.service.addNote({
87
102
  agent_id: identity.agent_id,
package/dist/config.js CHANGED
@@ -6,6 +6,9 @@ export const defaultPolicy = {
6
6
  claimTtlMs: 20 * 60 * 1000,
7
7
  waitForTurnMaxWaitMs: 30 * 1000,
8
8
  waitForTurnPollMs: 250,
9
+ waitForEventsMaxWaitMs: 30 * 1000,
10
+ waitForEventsPollMs: 250,
11
+ waitForEventsBatchLimit: 100,
9
12
  presenceTtlMs: 4 * 60 * 60 * 1000,
10
13
  waiterGraceMs: 10 * 1000,
11
14
  idleRoomTtlMs: 7 * 24 * 60 * 60 * 1000
package/dist/db.js CHANGED
@@ -96,6 +96,13 @@ const migrations = [
96
96
  up: `
97
97
  ALTER TABLE room_members ADD COLUMN last_wait_at TEXT;
98
98
  `
99
+ },
100
+ {
101
+ id: 5,
102
+ name: "room_events_payload_json",
103
+ up: `
104
+ ALTER TABLE room_events ADD COLUMN payload_json TEXT;
105
+ `
99
106
  }
100
107
  ];
101
108
  export function resolveDatabasePath(options = {}) {
@@ -126,6 +126,38 @@ export function createMcpServer(service = new TalkingStickService()) {
126
126
  ...input,
127
127
  agent_id: resolveConnectionIdentity(extra.sessionId).agent_id
128
128
  })));
129
+ server.registerTool("send_message", {
130
+ title: "Send Message",
131
+ description: "Send a transient message into the room event log. Routes via to_agent_id; omit it for room broadcast.",
132
+ inputSchema: {
133
+ room_id: z.string().min(1),
134
+ body: z.string().min(1),
135
+ to_agent_id: z.string().min(1).optional(),
136
+ delivery_hint: z.enum(["normal", "interrupt"]).optional()
137
+ }
138
+ }, async (input, extra) => toolJson(() => commands.sendMessage(resolveConnectionIdentity(extra.sessionId), input)));
139
+ server.registerTool("wait_for_events", {
140
+ title: "Wait for Events",
141
+ description: "Long-poll the room event log past a cursor with optional event_type, target, and sender filters.",
142
+ inputSchema: {
143
+ room_id: z.string().min(1),
144
+ after_event_seq: z.number().int().nonnegative().optional(),
145
+ event_type: z
146
+ .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
147
+ .optional(),
148
+ target_agent_id: z.string().min(1).optional(),
149
+ from_agent_id: z.string().min(1).optional(),
150
+ max_wait_ms: z.number().int().nonnegative().optional()
151
+ }
152
+ }, async (input, extra) => toolJson(() => commands.waitForEvents({
153
+ room_id: input.room_id,
154
+ after_event_seq: input.after_event_seq,
155
+ event_type: input.event_type,
156
+ target_agent_id: input.target_agent_id,
157
+ from_agent_id: input.from_agent_id,
158
+ max_wait_ms: input.max_wait_ms,
159
+ agent_id: resolveConnectionIdentity(extra.sessionId).agent_id
160
+ })));
129
161
  server.registerTool("add_note", {
130
162
  title: "Add Note",
131
163
  description: "Leave an async note on a room. Any joined member can author; authoring refreshes presence.",