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.
- package/dist/cli/output.js +1 -0
- package/dist/cli/registry.js +10 -1
- package/dist/cli/room-commands.js +35 -2
- package/dist/commands.js +9 -0
- package/dist/identity.js +33 -3
- package/dist/mcp-server.js +10 -0
- package/dist/service.js +91 -1
- package/docs/ambient-presence.md +7 -5
- package/docs/plans/out-of-band-signaling.md +290 -0
- package/docs/releases/0.1.3.md +78 -0
- package/docs/releases/0.1.4.md +68 -0
- package/docs/talking-stick-plan.md +1 -1
- package/package.json +1 -1
- package/skills/talking-stick/SKILL.md +6 -3
package/dist/cli/output.js
CHANGED
package/dist/cli/registry.js
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/dist/mcp-server.js
CHANGED
|
@@ -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 {
|
|
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),
|
package/docs/ambient-presence.md
CHANGED
|
@@ -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
|
|
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
|
|
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|--
|
|
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` / `--
|
|
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
|
|
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
|
-
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
|
-
|
|
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
|
|