talking-stick 0.1.1 → 0.1.3
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 +13 -17
- package/dist/cli/install-commands.js +53 -6
- package/dist/cli/output.js +2 -3
- package/dist/cli/registry.js +13 -4
- package/dist/cli/room-commands.js +30 -1
- package/dist/commands.js +9 -0
- package/dist/identity.js +33 -3
- package/dist/mcp-server.js +11 -1
- package/dist/service.js +86 -0
- package/docs/ambient-presence.md +7 -5
- package/docs/plans/out-of-band-signaling.md +290 -0
- package/docs/releases/0.1.2.md +40 -0
- package/docs/releases/0.1.3.md +78 -0
- package/package.json +1 -1
- package/skills/talking-stick/SKILL.md +7 -4
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.
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
@@ -18,7 +18,6 @@ npm i -g talking-stick
|
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
tt install --all
|
|
21
|
-
tt install-skill --all
|
|
22
21
|
```
|
|
23
22
|
|
|
24
23
|
Restart any harness that was already running so it loads the new MCP server. The `talking_stick` tools and skill now appear in every workspace.
|
|
@@ -47,7 +46,7 @@ That's the whole workflow. They negotiate turns automatically, hand off structur
|
|
|
47
46
|
|
|
48
47
|
| Method | Command | Notes |
|
|
49
48
|
|---|---|---|
|
|
50
|
-
| **From npm** | `npm i -g talking-stick` | Published as `0.1.
|
|
49
|
+
| **From npm** | `npm i -g talking-stick` | Published as `0.1.2`. Requires Node ≥ 22. |
|
|
51
50
|
| **From GitHub** | `npm i -g github:mostlydev/talking-stick` | Tracks the `master` branch; builds on install via the `prepare` hook. |
|
|
52
51
|
| **From source** | `git clone … && npm install && npm link` | For contributors. |
|
|
53
52
|
|
|
@@ -55,21 +54,19 @@ All three produce a `tt` binary on your `PATH`. Everything else below works iden
|
|
|
55
54
|
|
|
56
55
|
### Verify without installing
|
|
57
56
|
|
|
58
|
-
Want to see exactly what `tt install
|
|
57
|
+
Want to see exactly what `tt install` would change before touching anything?
|
|
59
58
|
|
|
60
59
|
```bash
|
|
61
60
|
tt install --all --print
|
|
62
|
-
tt install-skill --all --print
|
|
63
61
|
```
|
|
64
62
|
|
|
65
63
|
### Install into a subset
|
|
66
64
|
|
|
67
65
|
```bash
|
|
68
66
|
tt install claude-code codex
|
|
69
|
-
tt install-skill gemini
|
|
70
67
|
```
|
|
71
68
|
|
|
72
|
-
During normal execution, install commands skip harnesses that are not present instead of failing or creating new harness config roots.
|
|
69
|
+
During normal execution, install commands skip harnesses that are not present instead of failing or creating new harness config roots.
|
|
73
70
|
|
|
74
71
|
### Update
|
|
75
72
|
|
|
@@ -85,7 +82,6 @@ Skills are symlinked automatically, so they don't need an update.
|
|
|
85
82
|
|
|
86
83
|
```bash
|
|
87
84
|
tt uninstall --all
|
|
88
|
-
tt uninstall-skill --all
|
|
89
85
|
```
|
|
90
86
|
|
|
91
87
|
## What it gives your agent
|
|
@@ -124,7 +120,9 @@ While you wait your turn you may still need to flag something to the current own
|
|
|
124
120
|
|
|
125
121
|
## How installation works per harness
|
|
126
122
|
|
|
127
|
-
`tt install`
|
|
123
|
+
`tt install` installs both pieces a harness needs: the MCP server registration and the bundled `talking-stick` skill.
|
|
124
|
+
|
|
125
|
+
For MCP registration, it prefers each harness's own `mcp add` subcommand when available (so the server ends up in the right user-global config with the right schema), and falls back to direct JSON editing when it isn't.
|
|
128
126
|
|
|
129
127
|
| Harness | Scope | Under the hood |
|
|
130
128
|
|---------------|--------------|-----------------------------------------------------------------------------|
|
|
@@ -135,9 +133,9 @@ While you wait your turn you may still need to flag something to the current own
|
|
|
135
133
|
|
|
136
134
|
All four install into **user-global scope**, not project-local. A coordination server is only useful if every workspace your agent enters can see the same rooms — project-scoped MCP would defeat the point.
|
|
137
135
|
|
|
138
|
-
If you'd rather
|
|
136
|
+
If you'd rather apply setup by hand, run `tt install --print <harness>` to see the exact MCP and skill actions, then apply them yourself.
|
|
139
137
|
|
|
140
|
-
##
|
|
138
|
+
## Skill paths per harness
|
|
141
139
|
|
|
142
140
|
Talking Stick also ships with a portable `talking-stick` skill:
|
|
143
141
|
|
|
@@ -146,9 +144,9 @@ Talking Stick also ships with a portable `talking-stick` skill:
|
|
|
146
144
|
- Gemini: installed with `gemini skills install ... --scope user` or linked with `gemini skills link ... --scope user`
|
|
147
145
|
- OpenCode: copied or linked into `~/.opencode/skills/talking-stick`
|
|
148
146
|
|
|
149
|
-
By default, `tt install
|
|
147
|
+
By default, `tt install` links the bundled skill into each harness so local updates are picked up immediately. Pass `--copy` if you want a standalone snapshot instead.
|
|
150
148
|
|
|
151
|
-
Human CLI invocations also perform a silent best-effort sync for already-installed file-based skills in Claude Code, Codex, and OpenCode. If the installed skill is a copy, it is refreshed from the bundled skill; if it is a stale symlink, it is relinked. Missing harness config directories and missing skill installs are skipped. Gemini skills are managed by Gemini's own registry, so use `tt install
|
|
149
|
+
Human CLI invocations also perform a silent best-effort sync for already-installed file-based skills in Claude Code, Codex, and OpenCode. If the installed skill is a copy, it is refreshed from the bundled skill; if it is a stale symlink, it is relinked. Missing harness config directories and missing skill installs are skipped. Gemini skills are managed by Gemini's own registry, so use `tt install gemini` after updating when needed.
|
|
152
150
|
|
|
153
151
|
## Human CLI
|
|
154
152
|
|
|
@@ -171,10 +169,8 @@ tt takeover [path] [--reason TEXT] # alias for take
|
|
|
171
169
|
tt notes add <body> [--turn N] [--path DIR] [--stdin] # leave an async note
|
|
172
170
|
tt notes list [--all] [--after ID] [--limit N] [--path DIR] # read notes
|
|
173
171
|
tt mcp # run the MCP stdio server
|
|
174
|
-
tt install <harness...> | --all [--print]
|
|
175
|
-
tt uninstall <harness...> | --all [--print] # remove MCP server
|
|
176
|
-
tt install-skill <harness...> | --all [--print] [--copy] [--link] # install global talking-stick skill
|
|
177
|
-
tt uninstall-skill <harness...> | --all [--print] # remove global talking-stick skill
|
|
172
|
+
tt install <harness...> | --all [--print] [--copy] [--link] # install MCP server and skill
|
|
173
|
+
tt uninstall <harness...> | --all [--print] # remove MCP server and skill
|
|
178
174
|
tt self-update [--print] [--manager npm|pnpm|yarn|bun] # update to the latest published tt
|
|
179
175
|
```
|
|
180
176
|
|
|
@@ -5,17 +5,21 @@ import { detectInstallSource, isPackageManager, planSelfUpdate, resolveCurrentBi
|
|
|
5
5
|
import { getStringOption, hasOption, normalizeBooleanFlag } from "./parser.js";
|
|
6
6
|
export async function runInstallCommand(parsed) {
|
|
7
7
|
normalizeBooleanFlag(parsed, "print");
|
|
8
|
+
normalizeBooleanFlag(parsed, "copy");
|
|
9
|
+
normalizeBooleanFlag(parsed, "link");
|
|
8
10
|
const harnesses = selectHarnesses(parsed);
|
|
9
11
|
const dryRun = hasOption(parsed, "print");
|
|
10
|
-
const installOptions = {
|
|
11
|
-
|
|
12
|
+
const installOptions = {
|
|
13
|
+
link: resolveSkillInstallLinkMode(parsed),
|
|
14
|
+
skipMissing: true
|
|
15
|
+
};
|
|
12
16
|
if (dryRun) {
|
|
13
|
-
for (const action of
|
|
17
|
+
for (const action of planCombinedInstallActions(harnesses, installOptions)) {
|
|
14
18
|
printActionPlan(action);
|
|
15
19
|
}
|
|
16
20
|
return;
|
|
17
21
|
}
|
|
18
|
-
const results = await Promise.all(
|
|
22
|
+
const results = (await Promise.all(harnesses.map((harness) => runCombinedInstall(harness, installOptions)))).flat();
|
|
19
23
|
reportInstallResults(results, "install");
|
|
20
24
|
}
|
|
21
25
|
export async function runUninstallCommand(parsed) {
|
|
@@ -23,14 +27,14 @@ export async function runUninstallCommand(parsed) {
|
|
|
23
27
|
const harnesses = selectHarnesses(parsed);
|
|
24
28
|
const dryRun = hasOption(parsed, "print");
|
|
25
29
|
const installOptions = { skipMissing: true };
|
|
26
|
-
const actions = harnesses
|
|
30
|
+
const actions = planCombinedUninstallActions(harnesses, installOptions);
|
|
27
31
|
if (dryRun) {
|
|
28
32
|
for (const action of actions) {
|
|
29
33
|
printActionPlan(action);
|
|
30
34
|
}
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
33
|
-
const results = await Promise.all(
|
|
37
|
+
const results = (await Promise.all(harnesses.map((harness) => runCombinedUninstall(harness, installOptions)))).flat();
|
|
34
38
|
reportInstallResults(results, "uninstall");
|
|
35
39
|
}
|
|
36
40
|
export async function runInstallSkillCommand(parsed) {
|
|
@@ -107,6 +111,49 @@ function resolveSkillInstallLinkMode(parsed) {
|
|
|
107
111
|
}
|
|
108
112
|
return true;
|
|
109
113
|
}
|
|
114
|
+
function planCombinedInstallActions(harnesses, installOptions) {
|
|
115
|
+
return harnesses.flatMap((harness) => {
|
|
116
|
+
const mcpAction = planInstall(harness, installOptions);
|
|
117
|
+
if (mcpAction.kind === "skip") {
|
|
118
|
+
return [mcpAction];
|
|
119
|
+
}
|
|
120
|
+
return [
|
|
121
|
+
mcpAction,
|
|
122
|
+
planSkillInstall(harness, {
|
|
123
|
+
...installOptions,
|
|
124
|
+
// In dry-run mode, show the skill action that will follow MCP setup
|
|
125
|
+
// even when the MCP installer is what creates the harness config root.
|
|
126
|
+
skipMissing: false
|
|
127
|
+
})
|
|
128
|
+
];
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function planCombinedUninstallActions(harnesses, installOptions) {
|
|
132
|
+
return harnesses.flatMap((harness) => [
|
|
133
|
+
planUninstall(harness, installOptions),
|
|
134
|
+
planSkillUninstall(harness, {
|
|
135
|
+
...installOptions,
|
|
136
|
+
skipMissing: false
|
|
137
|
+
})
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
async function runCombinedInstall(harness, installOptions) {
|
|
141
|
+
const mcpAction = planInstall(harness, installOptions);
|
|
142
|
+
const mcpResult = await runAction(mcpAction, installOptions);
|
|
143
|
+
if (!mcpResult.ok || mcpResult.skipped) {
|
|
144
|
+
return [mcpResult];
|
|
145
|
+
}
|
|
146
|
+
const skillAction = planSkillInstall(harness, installOptions);
|
|
147
|
+
const skillResult = await runAction(skillAction, installOptions);
|
|
148
|
+
return [mcpResult, skillResult];
|
|
149
|
+
}
|
|
150
|
+
async function runCombinedUninstall(harness, installOptions) {
|
|
151
|
+
const mcpAction = planUninstall(harness, installOptions);
|
|
152
|
+
const mcpResult = await runAction(mcpAction, installOptions);
|
|
153
|
+
const skillAction = planSkillUninstall(harness, installOptions);
|
|
154
|
+
const skillResult = await runAction(skillAction, installOptions);
|
|
155
|
+
return [mcpResult, skillResult];
|
|
156
|
+
}
|
|
110
157
|
function selectHarnesses(parsed) {
|
|
111
158
|
if (hasOption(parsed, "all")) {
|
|
112
159
|
const detected = SUPPORTED_HARNESSES.filter((harness) => detectHarness(harness).detected);
|
package/dist/cli/output.js
CHANGED
|
@@ -122,6 +122,7 @@ Commands:
|
|
|
122
122
|
tt list [path]
|
|
123
123
|
tt join [path] [--force-new]
|
|
124
124
|
tt leave [path]
|
|
125
|
+
tt kick <agent_id> [path] [--reason TEXT] [--force]
|
|
125
126
|
tt wait [path] [--timeout 30s]
|
|
126
127
|
tt try [path]
|
|
127
128
|
tt state [path]
|
|
@@ -134,10 +135,8 @@ Commands:
|
|
|
134
135
|
tt notes add <body> [--turn N] [--path DIR] [--stdin]
|
|
135
136
|
tt notes list [--all] [--after NOTE_ID] [--limit N] [--path DIR]
|
|
136
137
|
tt mcp
|
|
137
|
-
tt install <harness...> | --all [--print]
|
|
138
|
+
tt install <harness...> | --all [--print] [--copy] [--link]
|
|
138
139
|
tt uninstall <harness...> | --all [--print]
|
|
139
|
-
tt install-skill <harness...> | --all [--print] [--copy] [--link]
|
|
140
|
-
tt uninstall-skill <harness...> | --all [--print]
|
|
141
140
|
tt self-update [--print] [--manager npm|pnpm|yarn|bun]
|
|
142
141
|
|
|
143
142
|
Harnesses: ${SUPPORTED_HARNESSES.join(", ")}
|
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
|
{
|
|
@@ -28,8 +28,8 @@ export const COMMAND_REGISTRY = [
|
|
|
28
28
|
needsRuntime: false,
|
|
29
29
|
startupMaintenance: false,
|
|
30
30
|
internal: false,
|
|
31
|
-
usage: "tt install <harness...> | --all [--print]",
|
|
32
|
-
description: "Install Talking Stick into harness MCP configs.",
|
|
31
|
+
usage: "tt install <harness...> | --all [--print] [--copy] [--link]",
|
|
32
|
+
description: "Install Talking Stick into harness MCP configs and skills.",
|
|
33
33
|
handler: ({ parsed }) => runInstallCommand(parsed)
|
|
34
34
|
},
|
|
35
35
|
{
|
|
@@ -38,7 +38,7 @@ export const COMMAND_REGISTRY = [
|
|
|
38
38
|
startupMaintenance: false,
|
|
39
39
|
internal: false,
|
|
40
40
|
usage: "tt uninstall <harness...> | --all [--print]",
|
|
41
|
-
description: "Remove Talking Stick from harness MCP configs.",
|
|
41
|
+
description: "Remove Talking Stick from harness MCP configs and skills.",
|
|
42
42
|
handler: ({ parsed }) => runUninstallCommand(parsed)
|
|
43
43
|
},
|
|
44
44
|
{
|
|
@@ -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) {
|
|
@@ -56,6 +56,35 @@ export function handleLeaveCommand(runtime, parsed) {
|
|
|
56
56
|
return `Left ${session.canonical_path}; ${result.remaining_members} ${memberLabel}.`;
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
|
+
export function handleKickCommand(runtime, parsed) {
|
|
60
|
+
const [target, ...rest] = parsed.positionals;
|
|
61
|
+
if (!target) {
|
|
62
|
+
throw new Error("Missing required argument: <agent_id>");
|
|
63
|
+
}
|
|
64
|
+
const sessionParsed = { ...parsed, positionals: rest };
|
|
65
|
+
const identity = deriveCliIdentity(sessionParsed);
|
|
66
|
+
const session = resolveSessionForReads(runtime, sessionParsed, identity);
|
|
67
|
+
const result = runtime.commands.kickMember(identity, {
|
|
68
|
+
room_id: session.room_id,
|
|
69
|
+
target_agent_id: target,
|
|
70
|
+
force: hasOption(parsed, "force"),
|
|
71
|
+
reason: getStringOption(parsed, "reason")
|
|
72
|
+
});
|
|
73
|
+
const sessionPath = resolveCliSessionPath();
|
|
74
|
+
if (result.status === "room_deleted") {
|
|
75
|
+
removeCliSessionsForRoom(sessionPath, session.room_id);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
removeCliSession(sessionPath, result.kicked_agent_id, session.room_id);
|
|
79
|
+
}
|
|
80
|
+
printResult(parsed, result, () => {
|
|
81
|
+
if (result.status === "room_deleted") {
|
|
82
|
+
return `Kicked ${result.kicked_agent_id}; room deleted.`;
|
|
83
|
+
}
|
|
84
|
+
const memberLabel = result.remaining_members === 1 ? "member remains" : "members remain";
|
|
85
|
+
return `Kicked ${result.kicked_agent_id}; ${result.remaining_members} ${memberLabel}.`;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
59
88
|
export function handleStateCommand(runtime, parsed) {
|
|
60
89
|
const identity = deriveCliIdentity(parsed);
|
|
61
90
|
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
|
@@ -27,7 +27,7 @@ export function createMcpServer(service = new TalkingStickService()) {
|
|
|
27
27
|
const resolveConnectionIdentity = createConnectionIdentityResolver();
|
|
28
28
|
const server = new McpServer({
|
|
29
29
|
name: "talking-stick",
|
|
30
|
-
version: "0.1.
|
|
30
|
+
version: "0.1.2"
|
|
31
31
|
});
|
|
32
32
|
server.registerTool("list_rooms", {
|
|
33
33
|
title: "List Rooms",
|
|
@@ -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");
|
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,40 @@
|
|
|
1
|
+
# Talking Stick 0.1.2
|
|
2
|
+
|
|
3
|
+
Date: 2026-04-27
|
|
4
|
+
|
|
5
|
+
Patch release focused on simplifying first-time setup and keeping the README in
|
|
6
|
+
sync with the CLI.
|
|
7
|
+
|
|
8
|
+
## Changed
|
|
9
|
+
|
|
10
|
+
### Combined harness setup
|
|
11
|
+
|
|
12
|
+
`tt install` now installs both pieces each harness needs: the MCP server
|
|
13
|
+
registration and the bundled Talking Stick skill. The command accepts the skill
|
|
14
|
+
mode flags directly, so `tt install --all --copy` produces standalone skill
|
|
15
|
+
copies while the default still links the bundled skill.
|
|
16
|
+
|
|
17
|
+
`tt uninstall` now removes both the MCP registration and the installed skill for
|
|
18
|
+
each selected harness.
|
|
19
|
+
|
|
20
|
+
The older `tt install-skill` and `tt uninstall-skill` commands remain available
|
|
21
|
+
for targeted maintenance, but they are no longer part of the normal README/help
|
|
22
|
+
setup path.
|
|
23
|
+
|
|
24
|
+
### README setup path
|
|
25
|
+
|
|
26
|
+
The quickstart, dry-run instructions, subset install example, removal example,
|
|
27
|
+
installation details, skill path section, and CLI reference now document a
|
|
28
|
+
single-command setup flow:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
tt install --all
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Verification
|
|
35
|
+
|
|
36
|
+
- `npm run typecheck`
|
|
37
|
+
- `npm test` — 212 tests across 14 files
|
|
38
|
+
- `npm run build`
|
|
39
|
+
- `git diff --check`
|
|
40
|
+
- `npm pack --dry-run --ignore-scripts`
|
|
@@ -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`
|
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,11 +26,11 @@ 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
|
|
|
33
|
-
Human CLI runs silently keep already-installed Claude Code, Codex, and OpenCode skill copies/symlinks aligned with the bundled Talking Stick skill. This is best effort and only updates existing installs; Gemini skills are registry-managed and should be refreshed with `tt install
|
|
33
|
+
Human CLI runs silently keep already-installed Claude Code, Codex, and OpenCode skill copies/symlinks aligned with the bundled Talking Stick skill. This is best effort and only updates existing installs; Gemini skills are registry-managed and should be refreshed with `tt install gemini` when needed.
|
|
34
34
|
|
|
35
35
|
### 2. Join the workspace room once
|
|
36
36
|
|
|
@@ -153,7 +153,7 @@ Example:
|
|
|
153
153
|
}
|
|
154
154
|
],
|
|
155
155
|
"open_questions": [
|
|
156
|
-
"Should install
|
|
156
|
+
"Should tt install default to copy or link for local development?"
|
|
157
157
|
]
|
|
158
158
|
}
|
|
159
159
|
```
|
|
@@ -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
|
|