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